Content merge

This commit is contained in:
Benoit Schweblin 2017-08-19 11:24:08 +01:00
parent d258d1c9c4
commit 0280df2bbb
8 changed files with 373 additions and 567 deletions

View File

@ -1,7 +1,7 @@
export default () => ({ export default () => ({
id: null, id: null,
type: 'syncContent', type: 'syncContent',
contentRevisions: {}, historyData: {},
syncLocationData: {}, syncLocationData: {},
updated: 0, updated: 0,
}); });

View File

@ -1,482 +0,0 @@
import 'clunderscore';
import DiffMatchPatch from 'diff-match-patch';
var clDiffUtils = {
cloneObject: cloneObject,
offsetToPatch: offsetToPatch,
patchToOffset: patchToOffset,
serializeObject: serializeObject,
flattenContent: flattenContent,
makePatchableText: makePatchableText,
restoreDiscussionOffsets: restoreDiscussionOffsets,
makeContentChange: makeContentChange,
applyContentChanges: applyContentChanges,
getTextPatches: getTextPatches,
getObjectPatches: getObjectPatches,
quickPatch: quickPatch,
mergeObjects: mergeObjects,
mergeFlattenContent: mergeFlattenContent
}
var marker = '\uF111\uF222\uF333\uF444'
var DIFF_DELETE = -1
var DIFF_INSERT = 1
var DIFF_EQUAL = 0
var diffMatchPatch = new DiffMatchPatch() // eslint-disable-line new-cap
var diffMatchPatchStrict = new DiffMatchPatch() // eslint-disable-line new-cap
diffMatchPatchStrict.Match_Threshold = 0
diffMatchPatchStrict.Patch_DeleteThreshold = 0
var diffMatchPatchPermissive = new DiffMatchPatch() // eslint-disable-line new-cap
diffMatchPatchPermissive.Match_Distance = 999999999
function cloneObject (obj) {
return JSON.parse(JSON.stringify(obj))
}
function offsetToPatch (text, offset) {
var patch = diffMatchPatchPermissive.patch_make(text, [
[0, text.slice(0, offset)],
[1, marker],
[0, text.slice(offset)]
])[0]
var diffs = patch.diffs.cl_map(function (diff) {
if (!diff[0]) {
return diff[1]
} else if (diff[1] === marker) {
return ''
}
})
return {
diffs: diffs,
length: patch.length1,
start: patch.start1
}
}
function patchToOffset (text, patch) {
var markersLength = 0
var diffs = patch.diffs.cl_map(function (diff) {
if (!diff) {
markersLength += marker.length
return [1, marker]
} else {
return [0, diff]
}
})
return diffMatchPatchPermissive.patch_apply([{
diffs: diffs,
length1: patch.length,
length2: patch.length + markersLength,
start1: patch.start,
start2: patch.start
}], text)[0].indexOf(marker)
}
function flattenObject (obj) {
return obj.cl_reduce(function (result, value, key) {
result[key] = value[1]
return result
}, {})
}
function flattenContent (content) {
var result = ({}).cl_extend(content)
result.properties = flattenObject(content.properties)
result.discussions = flattenObject(content.discussions)
result.comments = flattenObject(content.comments)
result.text = content.text.cl_reduce(function (text, item) {
switch (item.type) {
case 'discussion':
if (result.discussions[item.id]) {
result.discussions[item.id][item.name] = text.length
}
return text
default:
return text + item[1]
}
}, '')
return result
}
function getTextPatches (oldText, newText) {
var diffs = diffMatchPatch.diff_main(oldText, newText)
diffMatchPatch.diff_cleanupEfficiency(diffs)
var patches = []
var startOffset = 0
diffs.cl_each(function (change) {
var changeType = change[0]
var changeText = change[1]
switch (changeType) {
case DIFF_EQUAL:
startOffset += changeText.length
break
case DIFF_DELETE:
changeText && patches.push({
o: startOffset,
d: changeText
})
break
case DIFF_INSERT:
changeText && patches.push({
o: startOffset,
a: changeText
})
startOffset += changeText.length
break
}
})
return patches.length ? patches : undefined
}
function getObjectPatches (oldObject, newObjects) {
var valueHash = Object.create(null)
var valueArray = []
oldObject = hashObject(oldObject, valueHash, valueArray)
newObjects = hashObject(newObjects, valueHash, valueArray)
var diffs = diffMatchPatch.diff_main(oldObject, newObjects)
var patches = []
diffs.cl_each(function (change) {
var changeType = change[0]
var changeHash = change[1]
if (changeType === DIFF_EQUAL) {
return
}
changeHash.split('').cl_each(function (objHash) {
var obj = valueArray[objHash.charCodeAt(0)]
var patch = {
k: obj[0]
}
patch[changeType === DIFF_DELETE ? 'd' : 'a'] = obj[1]
patches.push(patch)
})
})
return patches.length ? patches : undefined
}
function makePatchableText (content, markerKeys, markerIdxMap) {
var markers = []
// Sort keys to have predictable marker positions, in case of same offset
var discussionKeys = Object.keys(content.discussions).sort()
discussionKeys.cl_each(function (discussionId) {
function addMarker (offsetName) {
var markerKey = discussionId + offsetName
if (discussion[offsetName] !== undefined) {
var idx = markerIdxMap[markerKey]
if (idx === undefined) {
idx = markerKeys.length
markerIdxMap[markerKey] = idx
markerKeys.push({
id: discussionId,
offsetName: offsetName
})
}
markers.push({
idx: idx,
offset: discussion[offsetName]
})
}
}
var discussion = content.discussions[discussionId]
if (discussion.offset0 === discussion.offset1) {
// Remove discussion offsets if markers are at the same position
discussion.offset0 = discussion.offset1 = undefined
} else {
addMarker('offset0')
addMarker('offset1')
}
})
var lastOffset = 0
var result = ''
markers
.sort(function (marker1, marker2) {
return marker1.offset - marker2.offset
})
.cl_each(function (marker) {
result +=
content.text.slice(lastOffset, marker.offset) +
String.fromCharCode(0xe000 + marker.idx) // Use a character from the private use area
lastOffset = marker.offset
})
return result + content.text.slice(lastOffset)
}
function stripDiscussionOffsets (objectMap) {
return objectMap.cl_reduce(function (result, object, id) {
result[id] = {
text: object.text
}
return result
}, {})
}
function restoreDiscussionOffsets (content, markerKeys) {
var len = content.text.length
var maxIdx = markerKeys.length
for (var i = 0; i < len; i++) {
var idx = content.text.charCodeAt(i) - 0xe000
if (idx >= 0 && idx < maxIdx) {
var markerKey = markerKeys[idx]
content.text = content.text.slice(0, i) + content.text.slice(i + 1)
var discussion = content.discussions[markerKey.id]
if (discussion) {
discussion[markerKey.offsetName] = i
}
i-- // We just removed the current character, we may have multiple markers with same offset
}
}
}
function makeContentChange (oldContent, newContent) {
var markerKeys = []
var markerIdxMap = Object.create(null)
var oldText = makePatchableText(oldContent, markerKeys, markerIdxMap)
var newText = makePatchableText(newContent, markerKeys, markerIdxMap)
var textPatches = getTextPatches(oldText, newText)
textPatches && textPatches.cl_each(function (patch) {
// If markers are present, replace changeText with an array of text and markers
var changeText = patch.a || patch.d
var textItems = []
var lastItem = ''
var len = changeText.length
var maxIdx = markerKeys.length
for (var i = 0; i < len; i++) {
var idx = changeText.charCodeAt(i) - 0xe000
if (idx >= 0 && idx < maxIdx) {
var markerKey = markerKeys[idx]
lastItem.length && textItems.push(lastItem)
textItems.push({
type: 'discussion',
name: markerKey.offsetName,
id: markerKey.id
})
lastItem = ''
} else {
lastItem += changeText[i]
}
}
if (textItems.length) {
lastItem.length && textItems.push(lastItem)
if (patch.a) {
patch.a = textItems
} else {
patch.d = textItems
}
}
})
var propertiesPatches = getObjectPatches(oldContent.properties, newContent.properties)
var discussionsPatches = getObjectPatches(
stripDiscussionOffsets(oldContent.discussions),
stripDiscussionOffsets(newContent.discussions)
)
var commentsPatches = getObjectPatches(oldContent.comments, newContent.comments)
if (textPatches || propertiesPatches || discussionsPatches || commentsPatches) {
return {
text: textPatches,
properties: propertiesPatches,
discussions: discussionsPatches,
comments: commentsPatches
}
}
}
function applyContentChanges (content, contentChanges, isBackward) {
function applyObjectPatches (obj, patches) {
if (patches) {
patches.cl_each(function (patch) {
if (!patch.a ^ !isBackward) {
obj[patch.k] = patch.a || patch.d
} else {
delete obj[patch.k]
}
})
}
}
var markerKeys = []
var markerIdxMap = Object.create(null)
var result = {
text: makePatchableText(content, markerKeys, markerIdxMap),
properties: cloneObject(content.properties),
discussions: stripDiscussionOffsets(content.discussions),
comments: cloneObject(content.comments)
}
contentChanges.cl_each(function (contentChange) {
var textPatches = contentChange.text || []
if (isBackward) {
textPatches = textPatches.slice().reverse()
}
result.text = textPatches.cl_reduce(function (text, patch) {
var isAdd = !patch.a ^ !isBackward
var textChanges = patch.a || patch.d || ''
// When no marker is present, textChanges is a string
if (typeof textChanges === 'string') {
textChanges = [textChanges]
}
var textChange = textChanges.cl_map(function (textChange) {
if (!textChange.type) {
// textChange is a string
return textChange
}
// textChange is a marker
var markerKey = textChange.id + textChange.name
var idx = markerIdxMap[markerKey]
if (idx === undefined) {
idx = markerKeys.length
markerIdxMap[markerKey] = idx
markerKeys.push({
id: textChange.id,
offsetName: textChange.name
})
}
return String.fromCharCode(0xe000 + idx)
}).join('')
if (!textChange) {
return text
} else if (isAdd) {
return text.slice(0, patch.o).concat(textChange).concat(text.slice(patch.o))
} else {
return text.slice(0, patch.o).concat(text.slice(patch.o + textChange.length))
}
}, result.text)
applyObjectPatches(result.properties, contentChange.properties)
applyObjectPatches(result.discussions, contentChange.discussions)
applyObjectPatches(result.comments, contentChange.comments)
})
restoreDiscussionOffsets(result, markerKeys)
return result
}
function serializeObject (obj) {
return JSON.stringify(obj, function (key, value) {
return Object.prototype.toString.call(value) === '[object Object]'
? Object.keys(value).sort().cl_reduce(function (sorted, key) {
sorted[key] = value[key]
return sorted
}, {})
: value
})
}
function hashArray (arr, valueHash, valueArray) {
var hash = []
arr.cl_each(function (obj) {
var serializedObj = serializeObject(obj)
var objHash = valueHash[serializedObj]
if (objHash === undefined) {
objHash = valueArray.length
valueArray.push(obj)
valueHash[serializedObj] = objHash
}
hash.push(objHash)
})
return String.fromCharCode.apply(null, hash)
}
function hashObject (obj, valueHash, valueArray) {
return hashArray(Object.keys(obj || {}).sort().cl_map(function (key) {
return [key, obj[key]]
}), valueHash, valueArray)
}
function mergeText (oldText, newText, serverText) {
var diffs = diffMatchPatch.diff_main(oldText, newText)
diffMatchPatch.diff_cleanupSemantic(diffs)
var patches = diffMatchPatch.patch_make(oldText, diffs)
var patchResult = diffMatchPatch.patch_apply(patches, serverText)
if (!patchResult[1]
.cl_some(function (changeApplied) {
return !changeApplied
})) {
return patchResult[0]
}
diffs = diffMatchPatchStrict.diff_main(patchResult[0], newText)
diffMatchPatch.diff_cleanupSemantic(diffs)
return diffs.cl_map(function (diff) {
return diff[1]
}).join('')
}
function quickPatch (oldStr, newStr, destStr, strict) {
var dmp = strict ? diffMatchPatchStrict : diffMatchPatch
var diffs = dmp.diff_main(oldStr, newStr)
var patches = dmp.patch_make(oldStr, diffs)
var patchResult = dmp.patch_apply(patches, destStr)
return patchResult[0]
}
function mergeObjects (oldObject, newObject, serverObject) {
var mergedObject = ({}).cl_extend(newObject).cl_extend(serverObject)
mergedObject.cl_each(function (value, key) {
if (!oldObject[key]) {
return // There might be conflict, keep the server value
}
var newValue = newObject[key] && serializeObject(newObject[key])
var serverValue = serverObject[key] && serializeObject(serverObject[key])
if (newValue === serverValue) {
return // no conflict
}
var oldValue = serializeObject(oldObject[key])
if (oldValue !== newValue && !serverValue) {
return // Removed on server but changed on client
}
if (oldValue !== serverValue && !newValue) {
return // Removed on client but changed on server
}
if (oldValue !== newValue && oldValue === serverValue) {
// Take the client value
if (!newValue) {
delete mergedObject[key]
} else {
mergedObject[key] = newObject[key]
}
} else if (oldValue !== serverValue && oldValue === newValue) {
// Take the server value
if (!serverValue) {
delete mergedObject[key]
}
}
// Take the server value otherwise
})
return cloneObject(mergedObject)
}
function mergeFlattenContent (oldContent, newContent, serverContent) {
var markerKeys = []
var markerIdxMap = Object.create(null)
var oldText = makePatchableText(oldContent, markerKeys, markerIdxMap)
var serverText = makePatchableText(serverContent, markerKeys, markerIdxMap)
var localText = makePatchableText(newContent, markerKeys, markerIdxMap)
var isServerTextChanges = oldText !== serverText
var isTextSynchronized = serverText === localText
var result = {
text: isTextSynchronized || !isServerTextChanges
? localText
: mergeText(oldText, serverText, localText),
properties: mergeObjects(
oldContent.properties,
newContent.properties,
serverContent.properties
),
discussions: mergeObjects(
stripDiscussionOffsets(oldContent.discussions),
stripDiscussionOffsets(newContent.discussions),
stripDiscussionOffsets(serverContent.discussions)
),
comments: mergeObjects(
oldContent.comments,
newContent.comments,
serverContent.comments
)
}
restoreDiscussionOffsets(result, markerKeys)
return result
}
export default clDiffUtils;

204
src/services/diffUtils.js Normal file
View File

@ -0,0 +1,204 @@
import DiffMatchPatch from 'diff-match-patch';
import utils from './utils';
const diffMatchPatch = new DiffMatchPatch();
const diffMatchPatchStrict = new DiffMatchPatch();
diffMatchPatchStrict.Match_Threshold = 0;
diffMatchPatchStrict.Patch_DeleteThreshold = 0;
const diffMatchPatchPermissive = new DiffMatchPatch();
diffMatchPatchPermissive.Match_Distance = 999999999;
function makePatchableText(content, markerKeys, markerIdxMap) {
const markers = [];
// Sort keys to have predictable marker positions in case of same offset
const discussionKeys = Object.keys(content.discussions).sort();
discussionKeys.forEach((discussionId) => {
const discussion = content.discussions[discussionId];
function addMarker(offsetName) {
const markerKey = discussionId + offsetName;
if (discussion[offsetName] !== undefined) {
let idx = markerIdxMap[markerKey];
if (idx === undefined) {
idx = markerKeys.length;
markerIdxMap[markerKey] = idx;
markerKeys.push({
id: discussionId,
offsetName,
});
}
markers.push({
idx,
offset: discussion[offsetName],
});
}
}
if (discussion.offset0 === discussion.offset1) {
// Remove discussion offsets if markers are at the same position
discussion.offset0 = undefined;
discussion.offset1 = undefined;
} else {
addMarker('offset0');
addMarker('offset1');
}
});
let lastOffset = 0;
let result = '';
markers
.sort((marker1, marker2) => marker1.offset - marker2.offset)
.forEach((marker) => {
result +=
content.text.slice(lastOffset, marker.offset) +
String.fromCharCode(0xe000 + marker.idx); // Use a character from the private use area
lastOffset = marker.offset;
});
return result + content.text.slice(lastOffset);
}
function stripDiscussionOffsets(objectMap) {
const result = {};
Object.keys(objectMap).forEach((id) => {
result[id] = {
text: objectMap[id].text,
};
});
return result;
}
function restoreDiscussionOffsets(content, markerKeys) {
const len = content.text.length;
const maxIdx = markerKeys.length;
for (let i = 0; i < len; i += 1) {
const idx = content.text.charCodeAt(i) - 0xe000;
if (idx >= 0 && idx < maxIdx) {
const markerKey = markerKeys[idx];
content.text = content.text.slice(0, i) + content.text.slice(i + 1);
const discussion = content.discussions[markerKey.id];
if (discussion) {
discussion[markerKey.offsetName] = i;
}
// We just removed the current character, we may have multiple markers with same offset
i -= 1;
}
}
}
function serializeObject(obj) {
if (!obj) {
return obj;
}
return JSON.stringify(obj, (key, value) => {
if (Object.prototype.toString.call(value) !== '[object Object]') {
return value;
}
return Object.keys(value).sort().reduce((sorted, valueKey) => {
sorted[valueKey] = value[valueKey];
return sorted;
}, {});
});
}
function mergeText(oldText, newText, serverText) {
let diffs = diffMatchPatch.diff_main(oldText, newText);
diffMatchPatch.diff_cleanupSemantic(diffs);
const patches = diffMatchPatch.patch_make(oldText, diffs);
const patchResult = diffMatchPatch.patch_apply(patches, serverText);
if (!patchResult[1].some(changeApplied => !changeApplied)) {
return patchResult[0];
}
diffs = diffMatchPatchStrict.diff_main(patchResult[0], newText);
diffMatchPatch.diff_cleanupSemantic(diffs);
return diffs.map(diff => diff[1]).join('');
}
function quickPatch(oldStr, newStr, destStr, strict) {
const dmp = strict ? diffMatchPatchStrict : diffMatchPatch;
const diffs = dmp.diff_main(oldStr, newStr);
const patches = dmp.patch_make(oldStr, diffs);
const patchResult = dmp.patch_apply(patches, destStr);
return patchResult[0];
}
function mergeValue(oldValue, newValue, serverValue) {
if (!oldValue) {
return serverValue; // There might be conflict, keep the server value
}
const newSerializedValue = serializeObject(newValue);
const serverSerializedValue = serializeObject(serverValue);
if (newSerializedValue === serverSerializedValue) {
return serverValue; // no conflict
}
const oldSerializedValue = serializeObject(oldValue);
if (oldSerializedValue !== newSerializedValue && !serverValue) {
return newValue; // Removed on server but changed on client
}
if (oldSerializedValue !== serverSerializedValue && !newValue) {
return serverValue; // Removed on client but changed on server
}
if (oldSerializedValue !== newSerializedValue && oldSerializedValue === serverSerializedValue) {
return newValue; // Take the client value
}
return serverValue; // Take the server value otherwise
}
function mergeObjects(oldObject, newObject, serverObject) {
const mergedObject = {};
Object.keys({
...newObject,
...serverObject,
}).forEach((key) => {
const mergedValue = mergeValue(oldObject[key], newObject[key], serverObject[key]);
if (mergedValue != null) {
mergedObject[key] = mergedValue;
}
});
return utils.deepCopy(mergedObject);
}
function mergeContent(oldContent, newContent, serverContent) {
const markerKeys = [];
const markerIdxMap = Object.create(null);
const oldText = makePatchableText(oldContent, markerKeys, markerIdxMap);
const serverText = makePatchableText(serverContent, markerKeys, markerIdxMap);
const localText = makePatchableText(newContent, markerKeys, markerIdxMap);
const isServerTextChanges = oldText !== serverText;
const isTextSynchronized = serverText === localText;
const result = {
text: isTextSynchronized || !isServerTextChanges
? localText
: mergeText(oldText, serverText, localText),
properties: mergeValue(
oldContent.properties,
newContent.properties,
serverContent.properties,
),
discussions: mergeObjects(
stripDiscussionOffsets(oldContent.discussions),
stripDiscussionOffsets(newContent.discussions),
stripDiscussionOffsets(serverContent.discussions),
),
comments: mergeObjects(
oldContent.comments,
newContent.comments,
serverContent.comments,
),
};
restoreDiscussionOffsets(result, markerKeys);
return result
}
export default {
serializeObject,
makePatchableText,
restoreDiscussionOffsets,
applyContentChanges,
getTextPatches,
getObjectPatches,
quickPatch,
mergeObjects,
mergeContent,
};

View File

@ -23,11 +23,11 @@ const tokenExpirationMargin = 10 * 60 * 1000; // 10 min
// ], // ],
// }; // };
const request = (googleToken, options) => utils.request({ const request = (token, options) => utils.request({
...options, ...options,
headers: { headers: {
...options.headers, ...options.headers,
Authorization: `Bearer ${googleToken.accessToken}`, Authorization: `Bearer ${token.accessToken}`,
}, },
}); });
@ -68,28 +68,28 @@ export default {
}; };
})) }))
// Call the tokeninfo endpoint // Call the tokeninfo endpoint
.then(googleToken => request(googleToken, { .then(token => request(token, {
method: 'GET', method: 'GET',
url: 'https://www.googleapis.com/plus/v1/people/me', url: 'https://www.googleapis.com/plus/v1/people/me',
}).then((res) => { }).then((res) => {
// Add name to googleToken // Add name to token
googleToken.name = res.body.displayName; token.name = res.body.displayName;
const existingToken = store.getters['data/googleTokens'][googleToken.sub]; const existingToken = store.getters['data/googleTokens'][token.sub];
if (existingToken) { if (existingToken) {
if (!sub) { if (!sub) {
throw new Error('Google account already linked.'); throw new Error('Google account already linked.');
} }
// Add isLogin and nextPageToken to googleToken // Add isLogin and nextPageToken to token
googleToken.isLogin = existingToken.isLogin; token.isLogin = existingToken.isLogin;
googleToken.nextPageToken = existingToken.nextPageToken; token.nextPageToken = existingToken.nextPageToken;
} }
// Add googleToken to googleTokens // Add token to googleTokens
store.dispatch('data/setGoogleToken', googleToken); store.dispatch('data/setGoogleToken', token);
return googleToken; return token;
})); }));
}, },
refreshToken(scopes, googleToken) { refreshToken(scopes, token) {
const sub = googleToken.sub; const sub = token.sub;
const lastToken = store.getters['data/googleTokens'][sub]; const lastToken = store.getters['data/googleTokens'][sub];
const mergedScopes = [...new Set([ const mergedScopes = [...new Set([
...scopes, ...scopes,
@ -115,9 +115,9 @@ export default {
.catch(() => this.startOauth2(mergedScopes, sub)); .catch(() => this.startOauth2(mergedScopes, sub));
}); });
}, },
getChanges(googleToken) { getChanges(token) {
let changes = []; let changes = [];
return this.refreshToken(['https://www.googleapis.com/auth/drive.appdata'], googleToken) return this.refreshToken(['https://www.googleapis.com/auth/drive.appdata'], token)
.then((refreshedToken) => { .then((refreshedToken) => {
const getPage = (pageToken = '1') => request(refreshedToken, { const getPage = (pageToken = '1') => request(refreshedToken, {
method: 'GET', method: 'GET',
@ -158,8 +158,8 @@ export default {
return getPage(refreshedToken.nextPageToken); return getPage(refreshedToken.nextPageToken);
}); });
}, },
updateNextPageToken(googleToken, changes) { updateNextPageToken(token, changes) {
const lastToken = store.getters['data/googleTokens'][googleToken.sub]; const lastToken = store.getters['data/googleTokens'][token.sub];
if (changes.nextPageToken !== lastToken.nextPageToken) { if (changes.nextPageToken !== lastToken.nextPageToken) {
store.dispatch('data/setGoogleToken', { store.dispatch('data/setGoogleToken', {
...lastToken, ...lastToken,
@ -167,8 +167,8 @@ export default {
}); });
} }
}, },
saveItem(googleToken, item, syncData, ifNotTooLate = cb => res => cb(res)) { saveItem(token, item, syncData, ifNotTooLate = cb => res => cb(res)) {
return this.refreshToken(['https://www.googleapis.com/auth/drive.appdata'], googleToken) return this.refreshToken(['https://www.googleapis.com/auth/drive.appdata'], token)
// Refreshing a token can take a while if an oauth window pops up, so check if it's too late // Refreshing a token can take a while if an oauth window pops up, so check if it's too late
.then(ifNotTooLate((refreshedToken) => { .then(ifNotTooLate((refreshedToken) => {
const options = { const options = {
@ -229,12 +229,22 @@ export default {
})); }));
})); }));
}, },
removeItem(googleToken, syncData, ifNotTooLate = cb => res => cb(res)) { removeItem(token, syncData, ifNotTooLate = cb => res => cb(res)) {
return this.refreshToken(['https://www.googleapis.com/auth/drive.appdata'], googleToken) return this.refreshToken(['https://www.googleapis.com/auth/drive.appdata'], token)
// Refreshing a token can take a while if an oauth window pops up, so check if it's too late // Refreshing a token can take a while if an oauth window pops up, so check if it's too late
.then(ifNotTooLate(refreshedToken => request(refreshedToken, { .then(ifNotTooLate(refreshedToken => request(refreshedToken, {
method: 'DELETE', method: 'DELETE',
url: `https://www.googleapis.com/drive/v3/files/${syncData.id}`, url: `https://www.googleapis.com/drive/v3/files/${syncData.id}`,
})).then(() => syncData)); })).then(() => syncData));
}, },
downloadFile(refreshedToken, id) {
return request(refreshedToken, {
method: 'GET',
url: `https://www.googleapis.com/drive/v3/files/${id}?alt=media`,
}).then(res => res.body);
},
downloadAppDataFile(token, id) {
return this.refreshToken(['https://www.googleapis.com/auth/drive.appdata'], token)
.then(refreshedToken => this.downloadFile(refreshedToken, id));
},
}; };

View File

@ -86,16 +86,11 @@ utils.types.forEach((type) => {
updatedMap[type] = Object.create(null); updatedMap[type] = Object.create(null);
}); });
function isContentType(type) { const contentTypes = {
switch (type) { content: true,
case 'content': contentState: true,
case 'contentState': syncContent: true,
case 'syncContent': };
return true;
default:
return false;
}
}
export default { export default {
lastTx: 0, lastTx: 0,
@ -171,8 +166,8 @@ export default {
Object.keys(this.updatedMap).forEach((type) => { Object.keys(this.updatedMap).forEach((type) => {
// Remove this type only if file is deleted // Remove this type only if file is deleted
let checker = cb => id => !storeItemMap[id] && cb(id); let checker = cb => id => !storeItemMap[id] && cb(id);
if (isContentType(type)) { if (contentTypes[type]) {
// For content types, remove only if file is deleted // For content types, remove item only if file is deleted
checker = cb => (id) => { checker = cb => (id) => {
if (!storeItemMap[id]) { if (!storeItemMap[id]) {
const [fileId] = id.split('/'); const [fileId] = id.split('/');
@ -227,7 +222,7 @@ export default {
// DB item is different from the corresponding store item // DB item is different from the corresponding store item
this.updatedMap[dbItem.type][dbItem.id] = dbItem.updated; this.updatedMap[dbItem.type][dbItem.id] = dbItem.updated;
// Update content only if it exists in the store // Update content only if it exists in the store
if (existingStoreItem || !isContentType(dbItem.type)) { if (existingStoreItem || !contentTypes[dbItem.type]) {
// Put item in the store // Put item in the store
store.commit(`${dbItem.type}/setItem`, dbItem); store.commit(`${dbItem.type}/setItem`, dbItem);
storeItemMap[dbItem.id] = dbItem; storeItemMap[dbItem.id] = dbItem;
@ -236,9 +231,9 @@ export default {
}, },
/** /**
* Retrieve an item from the DB. * Retrieve an item from the DB and put it in the store.
*/ */
retrieveItem(id) { loadItem(id) {
// Check if item is in the store // Check if item is in the store
const itemInStore = store.getters.allItemMap[id]; const itemInStore = store.getters.allItemMap[id];
if (itemInStore) { if (itemInStore) {
@ -259,11 +254,30 @@ export default {
this.updatedMap[dbItem.type][dbItem.id] = dbItem.updated; this.updatedMap[dbItem.type][dbItem.id] = dbItem.updated;
// Put item in the store // Put item in the store
store.commit(`${dbItem.type}/setItem`, dbItem); store.commit(`${dbItem.type}/setItem`, dbItem);
// Use deepCopy to freeze item
resolve(dbItem); resolve(dbItem);
} }
}; };
}, () => onError()); }, () => onError());
}); });
}, },
/**
* Unload from the store contents that haven't been opened recently
*/
unloadContents() {
const lastOpenedFileIds = store.getters['data/lastOpenedIds']
.slice(0, 10).reduce((result, id) => {
result[id] = true;
return result;
}, {});
Object.keys(contentTypes).forEach((type) => {
store.getters(`${type}/items`).forEach((item) => {
const [fileId] = item.id.split('/');
if (!lastOpenedFileIds[fileId]) {
// Remove item from the store
store.commit(`${type}/deleteItem`, item.id);
}
});
});
},
}; };

View File

@ -1 +1,24 @@
export default {}; import store from '../../store';
import googleHelper from '../helpers/googleHelper';
export default {
downloadContent(token, fileId) {
const syncData = store.getters['data/syncDataByItemId'][`${fileId}/content`];
return googleHelper.downloadAppDataFile(token, syncData.id)
.then((content) => {
if (content.updated !== syncData.updated) {
store.dispatch('data/setSyncData', {
...store.getters['data/syncData'],
[syncData.id]: {
...syncData,
updated: content.updated,
},
});
}
return {
history: [],
...content,
};
});
},
};

View File

@ -5,8 +5,6 @@ import utils from './utils';
import userActivitySvc from './userActivitySvc'; import userActivitySvc from './userActivitySvc';
import gdriveAppDataProvider from './providers/gdriveAppDataProvider'; import gdriveAppDataProvider from './providers/gdriveAppDataProvider';
import googleHelper from './helpers/googleHelper'; import googleHelper from './helpers/googleHelper';
import emptyContent from '../data/emptyContent';
import emptySyncContent from '../data/emptySyncContent';
const lastSyncActivityKey = 'lastSyncActivity'; const lastSyncActivityKey = 'lastSyncActivity';
let lastSyncActivity; let lastSyncActivity;
@ -36,6 +34,7 @@ function setLastSyncActivity() {
function getSyncProvider(syncLocation) { function getSyncProvider(syncLocation) {
switch (syncLocation.provider) { switch (syncLocation.provider) {
case 'gdriveAppData':
default: default:
return gdriveAppDataProvider; return gdriveAppDataProvider;
} }
@ -43,11 +42,21 @@ function getSyncProvider(syncLocation) {
function getSyncToken(syncLocation) { function getSyncToken(syncLocation) {
switch (syncLocation.provider) { switch (syncLocation.provider) {
case 'gdriveAppData':
default: default:
return store.getters['data/loginToken']; return store.getters['data/loginToken'];
} }
} }
const loader = type => fileId => localDbSvc.loadItem(`${fileId}/${type}`)
// Item does not exist, create it
.catch(() => store.commit(`${type}/setItem`, {
id: `${fileId}/${type}`,
}));
const loadContent = loader('content');
const loadSyncContent = loader('syncContent');
const loadContentState = loader('contentState');
function applyChanges(changes) { function applyChanges(changes) {
const storeItemMap = { ...store.getters.allItemMap }; const storeItemMap = { ...store.getters.allItemMap };
const syncData = { ...store.getters['data/syncData'] }; const syncData = { ...store.getters['data/syncData'] };
@ -164,23 +173,51 @@ function sync() {
const existingSyncData = store.getters['data/syncDataByItemId'][contentId]; const existingSyncData = store.getters['data/syncDataByItemId'][contentId];
}); });
const syncOneContent = fileId => localDbSvc.retrieveItem(`${fileId}/syncContent`) const syncOneContent = fileId => loadSyncContent(fileId)
.catch(() => ({ ...emptySyncContent(), id: `${fileId}/syncContent` })) .then(() => loadContent(fileId))
.then(syncContent => localDbSvc.retrieveItem(`${fileId}/content`)
.catch(() => ({ ...emptyContent(), id: `${fileId}/content` }))
.then((content) => {
const syncOneContentLocation = (syncLocation) => {
return Promise.resolve()
.then(() => { .then(() => {
const getContent = () => store.state.content.itemMap[`${fileId}/content`];
const getSyncContent = () => store.state.content.itemMap[`${fileId}/syncContent`];
const syncLocations = [
{ id: 'main', provider: 'gdriveAppData' },
...getContent().syncLocations.filter(syncLocation => getSyncToken(syncLocation),
)];
const downloadedLocations = {};
const syncOneContentLocation = () => {
let result;
syncLocations.some((syncLocation) => {
if (!downloadedLocations[syncLocation.id]) {
const provider = getSyncProvider(syncLocation); const provider = getSyncProvider(syncLocation);
const token = getSyncToken(syncLocation); const token = getSyncToken(syncLocation);
return provider && token && provider.downloadContent() result = provider && token && provider.downloadContent(fileId, syncLocation)
.then((content) => {
const syncContent = getSyncContent();
const syncLocationData = syncContent.syncLocationData[syncLocation.id] || {
history: [],
};
let lastMergedContent;
syncLocationData.history.some((updated) => {
if (content.history.indexOf(updated) !== -1) {
}
return lastMergedContent;
}); });
})
.then(() => syncOneContentLocation());
}
return result;
});
return result;
}; };
const syncLocations = [{ provider: null }, ...content.syncLocations]; return syncOneContentLocation();
return syncOneContentLocation(syncLocations[0]); })
})); .then(() => localDbSvc.unloadContents(), (err) => {
localDbSvc.unloadContents();
throw err;
});
// Called until no content to save // Called until no content to save
const saveNextContent = ifNotTooLate(() => { const saveNextContent = ifNotTooLate(() => {
@ -189,7 +226,7 @@ function sync() {
const updated = getContentUpdated(contentId); const updated = getContentUpdated(contentId);
const existingSyncData = store.getters['data/syncDataByItemId'][contentId]; const existingSyncData = store.getters['data/syncDataByItemId'][contentId];
if (!existingSyncData || existingSyncData.updated !== updated) { if (!existingSyncData || existingSyncData.updated !== updated) {
saveContentPromise = localDbSvc.retrieveItem(contentId) saveContentPromise = localDbSvc.loadItem(contentId)
.then(content => googleHelper.saveItem( .then(content => googleHelper.saveItem(
googleToken, googleToken,
// Use deepCopy to freeze objects // Use deepCopy to freeze objects
@ -299,19 +336,11 @@ localDbSvc.sync()
store.dispatch('data/setLastOpenedId', currentFile.id); store.dispatch('data/setLastOpenedId', currentFile.id);
return Promise.resolve() return Promise.resolve()
// Load contentState from DB // Load contentState from DB
.then(() => localDbSvc.retrieveItem(`${currentFile.id}/contentState`) .then(() => loadContentState(currentFile.id))
// contentState does not exist, create it
.catch(() => store.commit('contentState/setItem', {
id: `${currentFile.id}/contentState`,
})))
// Load syncContent from DB // Load syncContent from DB
.then(() => localDbSvc.retrieveItem(`${currentFile.id}/syncContent`) .then(() => loadSyncContent(currentFile.id))
// syncContent does not exist, create it
.catch(() => store.commit('syncContent/setItem', {
id: `${currentFile.id}/syncContent`,
})))
// Load content from DB // Load content from DB
.then(() => localDbSvc.retrieveItem(`${currentFile.id}/content`)); .then(() => localDbSvc.loadItem(`${currentFile.id}/content`));
}), }),
{ {
immediate: true, immediate: true,
@ -320,6 +349,14 @@ localDbSvc.sync()
// Sync local DB periodically // Sync local DB periodically
utils.setInterval(() => localDbSvc.sync(), 1000); utils.setInterval(() => localDbSvc.sync(), 1000);
// Unload contents from memory periodically
utils.setInterval(() => {
// Wait for sync and publish to finish
if (store.state.queue.isEmpty) {
localDbSvc.unloadContents();
}
}, 5000);
export default { export default {
isSyncAvailable, isSyncAvailable,
requestSync, requestSync,

View File

@ -25,13 +25,13 @@ const patcher = id => ({ state, commit }, data) => {
}, },
}); });
}; };
const localSettingsToggler = propertyName => ({ getters, dispatch }, value) => dispatch('patchLocalSettings', {
[propertyName]: value === undefined ? !getters.localSettings[propertyName] : value,
});
// Local settings // Local settings
module.getters.localSettings = getter('localSettings'); module.getters.localSettings = getter('localSettings');
module.actions.patchLocalSettings = patcher('localSettings'); module.actions.patchLocalSettings = patcher('localSettings');
const localSettingsToggler = propertyName => ({ getters, dispatch }, value) => dispatch('patchLocalSettings', {
[propertyName]: value === undefined ? !getters.localSettings[propertyName] : value,
});
module.actions.toggleNavigationBar = localSettingsToggler('showNavigationBar'); module.actions.toggleNavigationBar = localSettingsToggler('showNavigationBar');
module.actions.toggleEditor = localSettingsToggler('showEditor'); module.actions.toggleEditor = localSettingsToggler('showEditor');
module.actions.toggleSidePreview = localSettingsToggler('showSidePreview'); module.actions.toggleSidePreview = localSettingsToggler('showSidePreview');
@ -48,7 +48,7 @@ module.getters.lastOpened = getter('lastOpened');
const getLastOpenedIds = (lastOpened, rootState) => Object.keys(lastOpened) const getLastOpenedIds = (lastOpened, rootState) => Object.keys(lastOpened)
.filter(id => rootState.file.itemMap[id]) .filter(id => rootState.file.itemMap[id])
.sort((id1, id2) => lastOpened[id2] - lastOpened[id1]) .sort((id1, id2) => lastOpened[id2] - lastOpened[id1])
.slice(0, 10); .slice(0, 20);
module.getters.lastOpenedIds = (state, getters, rootState) => module.getters.lastOpenedIds = (state, getters, rootState) =>
getLastOpenedIds(getters.lastOpened, rootState); getLastOpenedIds(getters.lastOpened, rootState);
module.actions.setLastOpenedId = ({ getters, commit, rootState }, fileId) => { module.actions.setLastOpenedId = ({ getters, commit, rootState }, fileId) => {
@ -80,18 +80,18 @@ module.actions.setSyncData = setter('syncData');
module.getters.tokens = getter('tokens'); module.getters.tokens = getter('tokens');
module.getters.googleTokens = (state, getters) => getters.tokens.google || {}; module.getters.googleTokens = (state, getters) => getters.tokens.google || {};
module.getters.loginToken = (state, getters) => { module.getters.loginToken = (state, getters) => {
// Return the first googleToken that has the isLogin flag // Return the first google token that has the isLogin flag
const googleTokens = getters.googleTokens; const googleTokens = getters.googleTokens;
const loginSubs = Object.keys(googleTokens) const loginSubs = Object.keys(googleTokens)
.filter(sub => googleTokens[sub].isLogin); .filter(sub => googleTokens[sub].isLogin);
return googleTokens[loginSubs[0]]; return googleTokens[loginSubs[0]];
}; };
module.actions.patchTokens = patcher('tokens'); module.actions.patchTokens = patcher('tokens');
module.actions.setGoogleToken = ({ getters, dispatch }, googleToken) => { module.actions.setGoogleToken = ({ getters, dispatch }, token) => {
dispatch('patchTokens', { dispatch('patchTokens', {
google: { google: {
...getters.googleTokens, ...getters.googleTokens,
[googleToken.sub]: googleToken, [token.sub]: token,
}, },
}); });
}; };