<commit_before>/* eslint-disable no-unused-expressions */

import { expect } from 'chai';
import { handleActions, createAction } from 'redux-actions';

import {
  STATE_KEY_PROPERTY,
  bindStateKeyToActionCreator,
  bindStateKeyToActionCreators,
  bindStateKeyToSelector,
  bindStateKeyToSelectors,
  createSelectorWithSubstate,
  createSelectorsWithSubstate,
  createReducerWithSubstate,
} from '.';

const ACTION = {
  A: 'A',
  B: 'B',
};

describe('reduxFork', () => {
  describe('bindStateKeyToActionCreator', () => {

  });

  describe('bindStateKeyToActionCreators', () => {

  });

  describe('bindStateKeyToSelector', () => {

  });

  describe('bindStateKeyToSelectors', () => {

  });

  describe('createSelectorWithSubstate', () => {

  });

  describe('createSelectorsWithSubstate', () => {

  });

  describe('createReducerWithSubstate', () => {
    function setup() {
      function applyA(state, action) {
        return { ...state, a: action.payload };
      }

      function applyB(state, action) {
        return { ...state, b: action.payload };
      }

      const INITIAL_STATE = {};

      const INITIAL_SUBSTATE = {
        a: 0,
        b: 1,
      };

      const reducer = handleActions({
        [ACTION.A]: applyA,
        [ACTION.B]: applyB,
      }, INITIAL_STATE);

      const selectors = {
        getA: (state) => state.a,
        getB: (state) => state.b,
      };

      const actionCreators = {
        setA: (val) => createAction(ACTION.A)(val),
        setB: (val) => createAction(ACTION.B)(val),
      };

      const substateReducer = createReducerWithSubstate(reducer, INITIAL_SUBSTATE);
      const substateSelectors = createSelectorsWithSubstate(selectors, INITIAL_SUBSTATE);

      return {
        reducer: substateReducer,
        selectors: substateSelectors,
        actionCreators,
        INITIAL_STATE,
        INITIAL_SUBSTATE
      };
    }

    it('returns default initial substate when nothing es is present', () => {
      const stateKey = 'x';
      const { reducer, selectors, actionCreators, INITIAL_STATE, INITIAL_SUBSTATE } = setup();

      const boundSelectors = bindStateKeyToSelectors(stateKey, selectors);
      const boundActionCreators = bindStateKeyToActionCreators(stateKey, actionCreators);

      expect(boundSelectors.getA(INITIAL_STATE)).to.equal(INITIAL_SUBSTATE.a);
      expect(boundSelectors.getB(INITIAL_STATE)).to.equal(INITIAL_SUBSTATE.b);

      const actionA = boundActionCreators.setA(1);
      const actionB = boundActionCreators.setB(2);

      const stateAfterA = reducer(INITIAL_STATE, actionA);
      expect(boundSelectors.getA(stateAfterA)).to.equal(1);

      const stateAfterB = reducer(INITIAL_STATE, actionB);
      expect(boundSelectors.getB(stateAfterB)).to.equal(2);
    });

    it('updates substate when a bound action is thrown', () => {
      const stateKey = 'x';
      const { reducer, selectors, actionCreators, INITIAL_STATE} = setup();

      const boundSelectors = bindStateKeyToSelectors(stateKey, selectors);
      const boundActionCreators = bindStateKeyToActionCreators(stateKey, actionCreators);

      const actionA = boundActionCreators.setA(1);
      const actionB = boundActionCreators.setB(2);

      const stateAfterA = reducer(INITIAL_STATE, actionA);
      expect(boundSelectors.getA(stateAfterA)).to.equal(1);

      const stateAfterB = reducer(INITIAL_STATE, actionB);
      expect(boundSelectors.getB(stateAfterB)).to.equal(2);
    });

    it('holds updates made to substates keyed by stateKeys', () => {
      const stateKeyX = 'x';
      const stateKeyY = 'y';
      const { reducer, selectors, actionCreators, INITIAL_STATE, INITIAL_SUBSTATE} = setup();

      const boundSelectorsX = bindStateKeyToSelectors(stateKeyX, selectors);
      const boundActionCreatorsX = bindStateKeyToActionCreators(stateKeyX, actionCreators);
      const boundSelectorsY = bindStateKeyToSelectors(stateKeyY, selectors);
      const boundActionCreatorsY = bindStateKeyToActionCreators(stateKeyY, actionCreators);

      let nextState = INITIAL_STATE;

      const nextAOfX = 1;
      const nextBOfY = 2;

      const actionAOfX = boundActionCreatorsX.setA(nextAOfX);
      const actionBOfY = boundActionCreatorsY.setB(nextBOfY);

      nextState = reducer(nextState, actionAOfX);
      nextState = reducer(nextState, actionBOfY);

      expect(boundSelectorsX.getA(nextState)).to.equal(nextAOfX);
      expect(boundSelectorsX.getB(nextState)).to.equal(INITIAL_SUBSTATE.b);

      expect(boundSelectorsY.getA(nextState)).to.equal(INITIAL_SUBSTATE.a);
      expect(boundSelectorsY.getB(nextState)).to.equal(nextBOfY);

      expect(nextState).to.deep.equal({
        [stateKeyX]: {
          a: nextAOfX,
          b: INITIAL_SUBSTATE.b
        },
        [stateKeyY]: {
          a: INITIAL_SUBSTATE.a,
          b: nextBOfY
        }
      });
    });
  });
});
<commit_msg>fix(*) Update naming in specs<commit_after>/* eslint-disable no-unused-expressions */

import { expect } from 'chai';
import { handleActions, createAction } from 'redux-actions';

import {
  STATE_KEY_PROPERTY,
  bindStateKeyToActionCreator,
  bindStateKeyToActionCreators,
  bindStateKeyToSelector,
  bindStateKeyToSelectors,
  createSelectorWithSubstateHandling,
  createSelectorsWithSubstateHandling,
  createReducerWithSubstateHandling,
} from '.';

const ACTION = {
  A: 'A',
  B: 'B',
};

describe('reduxFork', () => {
  describe('bindStateKeyToActionCreator', () => {

  });

  describe('bindStateKeyToActionCreators', () => {

  });

  describe('bindStateKeyToSelector', () => {

  });

  describe('bindStateKeyToSelectors', () => {

  });

  describe('createSelectorWithSubstate', () => {

  });

  describe('createSelectorsWithSubstate', () => {

  });

  describe('createReducerWithSubstate', () => {
    function setup() {
      function applyA(state, action) {
        return { ...state, a: action.payload };
      }

      function applyB(state, action) {
        return { ...state, b: action.payload };
      }

      const INITIAL_STATE = {};

      const INITIAL_SUBSTATE = {
        a: 0,
        b: 1,
      };

      const reducer = handleActions({
        [ACTION.A]: applyA,
        [ACTION.B]: applyB,
      }, INITIAL_STATE);

      const selectors = {
        getA: (state) => state.a,
        getB: (state) => state.b,
      };

      const actionCreators = {
        setA: (val) => createAction(ACTION.A)(val),
        setB: (val) => createAction(ACTION.B)(val),
      };

      const substateReducer = createReducerWithSubstateHandling(reducer, INITIAL_SUBSTATE);
      const substateSelectors = createSelectorsWithSubstateHandling(selectors, INITIAL_SUBSTATE);

      return {
        reducer: substateReducer,
        selectors: substateSelectors,
        actionCreators,
        INITIAL_STATE,
        INITIAL_SUBSTATE
      };
    }

    it('returns default initial substate when nothing es is present', () => {
      const stateKey = 'x';
      const { reducer, selectors, actionCreators, INITIAL_STATE, INITIAL_SUBSTATE } = setup();

      const boundSelectors = bindStateKeyToSelectors(stateKey, selectors);
      const boundActionCreators = bindStateKeyToActionCreators(stateKey, actionCreators);

      expect(boundSelectors.getA(INITIAL_STATE)).to.equal(INITIAL_SUBSTATE.a);
      expect(boundSelectors.getB(INITIAL_STATE)).to.equal(INITIAL_SUBSTATE.b);

      const actionA = boundActionCreators.setA(1);
      const actionB = boundActionCreators.setB(2);

      const stateAfterA = reducer(INITIAL_STATE, actionA);
      expect(boundSelectors.getA(stateAfterA)).to.equal(1);

      const stateAfterB = reducer(INITIAL_STATE, actionB);
      expect(boundSelectors.getB(stateAfterB)).to.equal(2);
    });

    it('updates substate when a bound action is thrown', () => {
      const stateKey = 'x';
      const { reducer, selectors, actionCreators, INITIAL_STATE} = setup();

      const boundSelectors = bindStateKeyToSelectors(stateKey, selectors);
      const boundActionCreators = bindStateKeyToActionCreators(stateKey, actionCreators);

      const actionA = boundActionCreators.setA(1);
      const actionB = boundActionCreators.setB(2);

      const stateAfterA = reducer(INITIAL_STATE, actionA);
      expect(boundSelectors.getA(stateAfterA)).to.equal(1);

      const stateAfterB = reducer(INITIAL_STATE, actionB);
      expect(boundSelectors.getB(stateAfterB)).to.equal(2);
    });

    it('holds updates made to substates keyed by stateKeys', () => {
      const stateKeyX = 'x';
      const stateKeyY = 'y';
      const { reducer, selectors, actionCreators, INITIAL_STATE, INITIAL_SUBSTATE} = setup();

      const boundSelectorsX = bindStateKeyToSelectors(stateKeyX, selectors);
      const boundActionCreatorsX = bindStateKeyToActionCreators(stateKeyX, actionCreators);
      const boundSelectorsY = bindStateKeyToSelectors(stateKeyY, selectors);
      const boundActionCreatorsY = bindStateKeyToActionCreators(stateKeyY, actionCreators);

      let nextState = INITIAL_STATE;

      const nextAOfX = 1;
      const nextBOfY = 2;

      const actionAOfX = boundActionCreatorsX.setA(nextAOfX);
      const actionBOfY = boundActionCreatorsY.setB(nextBOfY);

      nextState = reducer(nextState, actionAOfX);
      nextState = reducer(nextState, actionBOfY);

      expect(boundSelectorsX.getA(nextState)).to.equal(nextAOfX);
      expect(boundSelectorsX.getB(nextState)).to.equal(INITIAL_SUBSTATE.b);

      expect(boundSelectorsY.getA(nextState)).to.equal(INITIAL_SUBSTATE.a);
      expect(boundSelectorsY.getB(nextState)).to.equal(nextBOfY);

      expect(nextState).to.deep.equal({
        [stateKeyX]: {
          a: nextAOfX,
          b: INITIAL_SUBSTATE.b
        },
        [stateKeyY]: {
          a: INITIAL_SUBSTATE.a,
          b: nextBOfY
        }
      });
    });
  });
});
<|endoftext|>


<commit_before>
// SmoothScroll v0.9.8
// Licensed under the terms of the MIT license.

// People involved
// - Balazs Galambosi: maintainer (CHANGELOG.txt)
// - Patrick Brunner (patrickb1991@gmail.com)
// - Michael Herf: Pulse Algorithm

// Scroll Variables (tweakable)
var framerate = 150; // [Hz]
var animtime  = 400; // [px]
var stepsize  = 120; // [px]

// Pulse (less tweakable)
// ratio of "tail" to "acceleration"
var pulseAlgorithm = true;
var pulseScale     = 8;
var pulseNormalize = 1;

// Keyboard Settings
var keyboardsupport = true;
var disableKeyboard = false;
var arrowscroll     = 50; // [px]

// Excluded pages
var exclude = "";

// Other Variables
var frame = false;
var direction = { x: 0, y: 0 };
var initdone  = false;
var activeElement;
var root;

var key = { left: 37, up: 38, right: 39, down: 40, spacebar: 32, pageup: 33, pagedown: 34, end: 35, home: 36 };

/***********************************************
 * SETTINGS
 ***********************************************/

chrome.extension.connect({ name: "smoothscroll" }).
onMessage.addListener(function (settings) {

    // NOTE: + converts to {Number}
    framerate = +settings.framerate;
    animtime  = +settings.animtime;
    stepsize  = +settings.scrollsz;
    exclude   = settings.exclude;
    pulseAlgorithm  = (settings.pulseAlgorithm == "true");
    pulseScale      = +settings.pulseScale;
    keyboardsupport = (settings.keyboardsupport == "true");
    arrowscroll     = +settings.arrscroll;

    computePulseScale();

    // it seems that sometimes settings come late
    // and we need to test again for excluded pages
    initTest();

    if (keyboardsupport && !disableKeyboard) {
        addEvent("keydown", keydown);
    }

    // If extension settings were deleted somehow
    if (!framerate) {
        alert("SmoothScroll: Please restart Chrome");
    }
});

/***********************************************
 * INITIALIZE
 ***********************************************/

/**
 * Tests if smooth scrolling is allowed. Shuts down everything if not.
 */
function initTest() {

    // disable keys for google reader (spacebar conflict)
    if (document.URL.indexOf("google.com/reader/view") > -1) {
        disableKeyboard = true;
    }

    // disable everything if the page is blacklisted
    if (exclude) {
        var domains = exclude.split(/[,\n] ?/);
        for (var i = domains.length; i--;) {
            if (document.URL.indexOf(domains[i]) > -1) {
                removeEvent("mousewheel", wheel);
                disableKeyboard = true;
                break;
            }
        }
    }

    // disable keyboard support if anything above requested it
    if (disableKeyboard) {
        removeEvent("keydown", keydown);
    }
}

/**
 * Sets up scrolls array, determines if frames are involved.
 */
function init() {

    if (!document.body) return;

    var body = document.body;
    var html = document.documentElement;
    var windowHeight = window.innerHeight;
    var scrollHeight = body.scrollHeight;

    // check compat mode for root element
    root = (document.compatMode.indexOf('CSS') >= 0) ? html : body;
    activeElement = body;

    initTest();
    initdone = true;

    // Checks if this script is running in a frame
    if (top != self) {
        frame = true;
    }

    /**
     * This fixes a bug where the areas left and right to
     * the content does not trigger the onmousewheel event
     * on some pages. e.g.: html, body { height: 100% }
     */
    else if (scrollHeight > windowHeight &&
            (body.offsetHeight <= windowHeight ||
             html.offsetHeight <= windowHeight)) {
        root.style.height = "auto";
    }
}

/************************************************
 * SCROLLING
 ************************************************/

var que = [];
var pending = false;

/**
 * Pushes scroll actions to the scrolling queue.
 */
function scrollArray(elem, multiplyX, multiplyY, delay) {

    delay || (delay = 1000);
    directionCheck(multiplyX, multiplyY);

    // push a scroll command
    que.push({
        x: multiplyX,
        y: multiplyY,
        lastX: (multiplyX < 0) ? 0.99 : -0.99,
        lastY: (multiplyY < 0) ? 0.99 : -0.99,
        start: +new Date
    });

    // don't act if there's a pending queue
    if (pending) {
        return;
    }

    var step = function() {

        var now = +new Date;
        var totalX = 0;
        var totalY = 0;

        for (var i = 0; i < que.length; i++) {

            var item = que[i];
            var elapsed  = now - item.start;
            var finished = (elapsed >= animtime);

            // scroll position: [0, 1]
            var position = (finished) ? 1 : elapsed / animtime;

            // easing [optional]
            if (pulseAlgorithm) {
                position = pulse(position);
            }

            // only need the difference
            var scrollX = (item.x * position - item.lastX) >> 0;
            var scrollY = (item.y * position - item.lastY) >> 0;

            // add this to the total scrolling
            totalX += scrollX;
            totalY += scrollY;

            // update last values
            item.lastX += scrollX;
            item.lastY += scrollY;

            // delete and step back if it's over
            if (finished) {
                que.splice(i, 1); i--;
            }
        }

        // scroll left
        if (multiplyX) {
            var lastLeft = elem.scrollLeft;
            elem.scrollLeft += totalX;

            // scroll left failed (edge)
            if (totalX && elem.scrollLeft === lastLeft) {
                multiplyX = 0;
            }
        }

        // scroll top
        if (multiplyY) {
            var lastTop = elem.scrollTop;
            elem.scrollTop += totalY;

            // scroll top failed (edge)
            if (totalY && elem.scrollTop === lastTop) {
                multiplyY = 0;
            }
        }

        // clean up if there's nothing left to do
        if (!multiplyX && !multiplyY) {
            que = [];
        }

        if (que.length) {
            setTimeout(step, delay / framerate + 1);
        } else {
            pending = false;
        }
    }

    // start a new queue of actions
    setTimeout(step, 0);
    pending = true;
}

/***********************************************
 * EVENTS
 ***********************************************/

/**
 * Mouse wheel handler.
 * @param {Object} event
 */
function wheel(event) {

    if (!initdone) {
        init();
    }

    var target = event.target;
    var elem   = overflowingAncestor(target);

    // use default if there's no overflowing
    // element or default action is prevented
    if (!elem || event.defaultPrevented ||
        isNodeName(activeElement, "embed") ||
       (isNodeName(target, "embed") && /\.pdf/i.test(target.src))) {
        return true;
    }

    var deltaX = event.wheelDeltaX || 0;
    var deltaY = event.wheelDeltaY || 0;

    // use wheelDelta if deltaX/Y is not available
    if (!deltaX && !deltaY) {
        deltaY = event.wheelDelta || 0;
    }

    // scale by step size
    // delta is 120 most of the time
    // synaptics seems to send 1 sometimes
    if (Math.abs(deltaX) > 1.2) {
        deltaX *= stepsize / 120;
    }
    if (Math.abs(deltaY) > 1.2) {
        deltaY *= stepsize / 120;
    }

    scrollArray(elem, -deltaX, -deltaY);
    event.preventDefault();
}

/**
 * Keydown event handler.
 * @param {Object} event
 */
function keydown(event) {

    var target   = event.target;
    var modifier = event.ctrlKey || event.altKey || event.metaKey;

    // do nothing if user is editing text
    // or using a modifier key (except shift)
    if ( /input|textarea|embed/i.test(target.nodeName) ||
         target.isContentEditable ||
         event.defaultPrevented   ||
         modifier ) {
      return true;
    }
    // spacebar should trigger button press
    if (isNodeName(target, "button") &&
        event.keyCode === key.spacebar) {
      return true;
    }

    var shift, x = 0, y = 0;
    var elem = overflowingAncestor(activeElement);
    var clientHeight = elem.clientHeight;

    if (elem == document.body) {
        clientHeight = window.innerHeight;
    }

    switch (event.keyCode) {
        case key.up:
            y = -arrowscroll;
            break;
        case key.down:
            y = arrowscroll;
            break;
        case key.spacebar: // (+ shift)
            shift = event.shiftKey ? 1 : -1;
            y = -shift * clientHeight * 0.9;
            break;
        case key.pageup:
            y = -clientHeight * 0.9;
            break;
        case key.pagedown:
            y = clientHeight * 0.9;
            break;
        case key.home:
            y = -elem.scrollTop;
            break;
        case key.end:
            var damt = elem.scrollHeight - elem.scrollTop - clientHeight;
            y = (damt > 0) ? damt+10 : 0;
            break;
        case key.left:
            x = -arrowscroll;
            break;
        case key.right:
            x = arrowscroll;
            break;
        default:
            return true; // a key we don't care about
    }

    scrollArray(elem, x, y);
    event.preventDefault();
}

/**
 * Mousedown event only for updating activeElement
 */
function mousedown(event) {
    activeElement = event.target;
}

/***********************************************
 * HELPERS
 ***********************************************/

function addEvent(type, fn, bubble) {
    window.addEventListener(type, fn, (bubble||false));
}

function removeEvent(type, fn, bubble) {
    window.removeEventListener(type, fn, (bubble||false));
}

function isNodeName(el, tag) {
    return el.nodeName.toLowerCase() === tag.toLowerCase();
}

function directionCheck(x, y) {
    x = (x > 0) ? 1 : -1;
    y = (y > 0) ? 1 : -1;
    if (direction.x !== x || direction.y !== y) {
        que = [];
        direction.x = x;
        direction.y = y;
    }
}

function overflowingAncestor(el) {
    var rootScrollHeight = root.scrollHeight;
    do {
        if (rootScrollHeight === el.scrollHeight) {
            if (!frame || root.clientHeight + 10 < rootScrollHeight) {
                return document.body; // scrolling root in WebKit
            }
        }
        else if (el.clientHeight + 10 < el.scrollHeight) {
            overflow = getComputedStyle(el, "").getPropertyValue("overflow");
            if (overflow === "scroll" || overflow === "auto") {
                return el;
            }
        }
    } while (el = el.parentNode);
}

/***********************************************
 * PULSE
 ***********************************************/

/**
 * Viscous fluid with a pulse for part and decay for the rest.
 * - Applies a fixed force over an interval (a damped acceleration), and
 * - Lets the exponential bleed away the velocity over a longer interval
 * - Michael Herf, http://stereopsis.com/stopping/
 */
function pulse_(x) {
    var val, start, expx;
    // test
    x = x * pulseScale;
    if (x < 1) { // acceleartion
        val = x - (1 - Math.exp(-x));
    } else {     // tail
        // the previous animation ended here:
        start = Math.exp(-1);
        // simple viscous drag
        x -= 1;
        expx = 1 - Math.exp(-x);
        val = start + (expx * (1 - start));
    }
    return val * pulseNormalize;
}

function computePulseScale() {
    pulseNormalize = 1 / pulse_(1);
}

/**
 * Viscous fluid with a pulse for part and decay for the rest
 */
function pulse(x) {
    if (x >= 1) return 1;
    if (x <= 0) return 0;

    if (pulseNormalize == 1) {
        computePulseScale();
    }
    return pulse_(x);
}

addEvent("mousedown", mousedown);
addEvent("mousewheel", wheel);
addEvent("load", init);
<commit_msg>initial: implemented caching in overflowingAncestor. added GMail hack.<commit_after>
// SmoothScroll v0.9.8
// Licensed under the terms of the MIT license.

// People involved
// - Balazs Galambosi: maintainer (CHANGELOG.txt)
// - Patrick Brunner (patrickb1991@gmail.com)
// - Michael Herf: Pulse Algorithm

// Scroll Variables (tweakable)
var framerate = 150; // [Hz]
var animtime  = 400; // [px]
var stepsize  = 120; // [px]

// Pulse (less tweakable)
// ratio of "tail" to "acceleration"
var pulseAlgorithm = true;
var pulseScale     = 8;
var pulseNormalize = 1;

// Keyboard Settings
var keyboardsupport = true;
var disableKeyboard = false;
var arrowscroll     = 50; // [px]

// Excluded pages
var exclude = "";

// Other Variables
var frame = false;
var direction = { x: 0, y: 0 };
var initdone  = false;
var activeElement;
var root, overflowing;

var key = { left: 37, up: 38, right: 39, down: 40, spacebar: 32, pageup: 33, pagedown: 34, end: 35, home: 36 };

/***********************************************
 * SETTINGS
 ***********************************************/

chrome.extension.connect({ name: "smoothscroll" }).
onMessage.addListener(function (settings) {

    // NOTE: + converts to {Number}
    framerate = +settings.framerate;
    animtime  = +settings.animtime;
    stepsize  = +settings.scrollsz;
    exclude   = settings.exclude;
    pulseAlgorithm  = (settings.pulseAlgorithm == "true");
    pulseScale      = +settings.pulseScale;
    keyboardsupport = (settings.keyboardsupport == "true");
    arrowscroll     = +settings.arrscroll;

    computePulseScale();

    // it seems that sometimes settings come late
    // and we need to test again for excluded pages
    initTest();

    if (keyboardsupport && !disableKeyboard) {
        addEvent("keydown", keydown);
    }

    // If extension settings were deleted somehow
    if (!framerate) {
        alert("SmoothScroll: Please restart Chrome");
    }
});

/***********************************************
 * INITIALIZE
 ***********************************************/

/**
 * Tests if smooth scrolling is allowed. Shuts down everything if not.
 */
function initTest() {

    // disable keys for google reader (spacebar conflict)
    if (document.URL.indexOf("google.com/reader/view") > -1) {
        disableKeyboard = true;
    }

    // disable everything if the page is blacklisted
    if (exclude) {
        var domains = exclude.split(/[,\n] ?/);
        for (var i = domains.length; i--;) {
            if (document.URL.indexOf(domains[i]) > -1) {
                removeEvent("mousewheel", wheel);
                disableKeyboard = true;
                break;
            }
        }
    }

    // disable keyboard support if anything above requested it
    if (disableKeyboard) {
        removeEvent("keydown", keydown);
    }
}

/**
 * Sets up scrolls array, determines if frames are involved.
 */
function init() {

    if (!document.body) return;

    var body = document.body;
    var html = document.documentElement;
    var windowHeight = window.innerHeight;
    var scrollHeight = body.scrollHeight;

    // check compat mode for root element
    root = (document.compatMode.indexOf('CSS') >= 0) ? html : body;
    activeElement = body;

    initTest();
    initdone = true;

    // Checks if this script is running in a frame
    if (top != self) {
        frame = true;
    }

    /**
     * This fixes a bug where the areas left and right to
     * the content does not trigger the onmousewheel event
     * on some pages. e.g.: html, body { height: 100% }
     */
    else if (scrollHeight > windowHeight &&
            (body.offsetHeight <= windowHeight ||
             html.offsetHeight <= windowHeight)) {
        root.style.height = "auto";
    }

    if (document.URL.indexOf("mail.google.com") > -1) {
        var s = document.createElement("style");
        s.innerHTML = ".iu { visibility: hidden }";
        document.body.appendChild(s);
    }
}

/************************************************
 * SCROLLING
 ************************************************/

var que = [];
var pending = false;

/**
 * Pushes scroll actions to the scrolling queue.
 */
function scrollArray(elem, multiplyX, multiplyY, delay) {

    delay || (delay = 1000);
    directionCheck(multiplyX, multiplyY);

    // push a scroll command
    que.push({
        x: multiplyX,
        y: multiplyY,
        lastX: (multiplyX < 0) ? 0.99 : -0.99,
        lastY: (multiplyY < 0) ? 0.99 : -0.99,
        start: +new Date
    });

    // don't act if there's a pending queue
    if (pending) {
        return;
    }

    var step = function() {

        var now = +new Date;
        var totalX = 0;
        var totalY = 0;

        for (var i = 0; i < que.length; i++) {

            var item = que[i];
            var elapsed  = now - item.start;
            var finished = (elapsed >= animtime);

            // scroll position: [0, 1]
            var position = (finished) ? 1 : elapsed / animtime;

            // easing [optional]
            if (pulseAlgorithm) {
                position = pulse(position);
            }

            // only need the difference
            var scrollX = (item.x * position - item.lastX) >> 0;
            var scrollY = (item.y * position - item.lastY) >> 0;

            // add this to the total scrolling
            totalX += scrollX;
            totalY += scrollY;

            // update last values
            item.lastX += scrollX;
            item.lastY += scrollY;

            // delete and step back if it's over
            if (finished) {
                que.splice(i, 1); i--;
            }
        }

        // scroll left
        if (multiplyX) {
            var lastLeft = elem.scrollLeft;
            elem.scrollLeft += totalX;

            // scroll left failed (edge)
            if (totalX && elem.scrollLeft === lastLeft) {
                multiplyX = 0;
            }
        }

        // scroll top
        if (multiplyY) {
            var lastTop = elem.scrollTop;
            elem.scrollTop += totalY;

            // scroll top failed (edge)
            if (totalY && elem.scrollTop === lastTop) {
                multiplyY = 0;
            }
        }

        // clean up if there's nothing left to do
        if (!multiplyX && !multiplyY) {
            que = [];
        }

        if (que.length) {
            setTimeout(step, delay / framerate + 1);
        } else {
            pending = false;
        }
    }

    // start a new queue of actions
    setTimeout(step, 0);
    pending = true;
}

/***********************************************
 * EVENTS
 ***********************************************/
var el;
/**
 * Mouse wheel handler.
 * @param {Object} event
 */
function wheel(event) {

    if (!initdone) {
        init();
    }

    var target = event.target;
    var overflowing = overflowingAncestor(target);

    // use default if there's no overflowing
    // element or default action is prevented
    if (!overflowing || event.defaultPrevented ||
        isNodeName(activeElement, "embed") ||
       (isNodeName(target, "embed") && /\.pdf/i.test(target.src))) {
        return true;
    }

    var deltaX = event.wheelDeltaX || 0;
    var deltaY = event.wheelDeltaY || 0;

    // use wheelDelta if deltaX/Y is not available
    if (!deltaX && !deltaY) {
        deltaY = event.wheelDelta || 0;
    }

    // scale by step size
    // delta is 120 most of the time
    // synaptics seems to send 1 sometimes
    if (Math.abs(deltaX) > 1.2) {
        deltaX *= stepsize / 120;
    }
    if (Math.abs(deltaY) > 1.2) {
        deltaY *= stepsize / 120;
    }

    scrollArray(overflowing, -deltaX, -deltaY);
    event.preventDefault();
}

/**
 * Keydown event handler.
 * @param {Object} event
 */
function keydown(event) {

    var target   = event.target;
    var modifier = event.ctrlKey || event.altKey || event.metaKey;

    // do nothing if user is editing text
    // or using a modifier key (except shift)
    if ( /input|textarea|embed/i.test(target.nodeName) ||
         target.isContentEditable ||
         event.defaultPrevented   ||
         modifier ) {
      return true;
    }
    // spacebar should trigger button press
    if (isNodeName(target, "button") &&
        event.keyCode === key.spacebar) {
      return true;
    }

    var shift, x = 0, y = 0;
    var elem = overflowingAncestor(activeElement);
    var clientHeight = elem.clientHeight;

    if (elem == document.body) {
        clientHeight = window.innerHeight;
    }

    switch (event.keyCode) {
        case key.up:
            y = -arrowscroll;
            break;
        case key.down:
            y = arrowscroll;
            break;
        case key.spacebar: // (+ shift)
            shift = event.shiftKey ? 1 : -1;
            y = -shift * clientHeight * 0.9;
            break;
        case key.pageup:
            y = -clientHeight * 0.9;
            break;
        case key.pagedown:
            y = clientHeight * 0.9;
            break;
        case key.home:
            y = -elem.scrollTop;
            break;
        case key.end:
            var damt = elem.scrollHeight - elem.scrollTop - clientHeight;
            y = (damt > 0) ? damt+10 : 0;
            break;
        case key.left:
            x = -arrowscroll;
            break;
        case key.right:
            x = arrowscroll;
            break;
        default:
            return true; // a key we don't care about
    }

    scrollArray(elem, x, y);
    event.preventDefault();
}

/**
 * Mousedown event only for updating activeElement
 */
function mousedown(event) {
    activeElement = event.target;
}

/***********************************************
 * HELPERS
 ***********************************************/

function addEvent(type, fn, bubble) {
    window.addEventListener(type, fn, (bubble||false));
}

function removeEvent(type, fn, bubble) {
    window.removeEventListener(type, fn, (bubble||false));
}

function isNodeName(el, tag) {
    return el.nodeName.toLowerCase() === tag.toLowerCase();
}

function directionCheck(x, y) {
    x = (x > 0) ? 1 : -1;
    y = (y > 0) ? 1 : -1;
    if (direction.x !== x || direction.y !== y) {
        que = [];
        direction.x = x;
        direction.y = y;
    }
}

var cache = {};
setInterval(function(){ cache = {}; }, 10 * 1000);

var uniqueID = (function() {
    var i = 0;
    return function (el) {
        return el.uniqueID || (el.uniqueID = i++);
    };
})();

function setCache(elems, overflowing) {
    for (var i = elems.length; i--;) {
        var uid = uniqueID(elems[i]);
        cache[uid] || (cache[uid] = overflowing);
    }
}

function overflowingAncestor(el) {
    var elems = [];
    var rootScrollHeight = root.scrollHeight;
    do {
        var cached = cache[uniqueID(el)];
        if (cached) {
            setCache(elems, cached);
            return cached;
        }

        elems.push(el);
        if (rootScrollHeight === el.scrollHeight) {
            if (!frame || root.clientHeight + 10 < rootScrollHeight) {
                setCache(elems, document.body);
                return document.body; // scrolling root in WebKit
            }
        }
        else if (el.clientHeight + 10 < el.scrollHeight) {
            overflow = getComputedStyle(el, "").getPropertyValue("overflow");
            if (overflow === "scroll" || overflow === "auto") {
                setCache(elems, el);
                return el;
            }
        }
    } while (el = el.parentNode);
}

/***********************************************
 * PULSE
 ***********************************************/

/**
 * Viscous fluid with a pulse for part and decay for the rest.
 * - Applies a fixed force over an interval (a damped acceleration), and
 * - Lets the exponential bleed away the velocity over a longer interval
 * - Michael Herf, http://stereopsis.com/stopping/
 */
function pulse_(x) {
    var val, start, expx;
    // test
    x = x * pulseScale;
    if (x < 1) { // acceleartion
        val = x - (1 - Math.exp(-x));
    } else {     // tail
        // the previous animation ended here:
        start = Math.exp(-1);
        // simple viscous drag
        x -= 1;
        expx = 1 - Math.exp(-x);
        val = start + (expx * (1 - start));
    }
    return val * pulseNormalize;
}

function computePulseScale() {
    pulseNormalize = 1 / pulse_(1);
}

/**
 * Viscous fluid with a pulse for part and decay for the rest
 */
function pulse(x) {
    if (x >= 1) return 1;
    if (x <= 0) return 0;

    if (pulseNormalize == 1) {
        computePulseScale();
    }
    return pulse_(x);
}

addEvent("mousedown", mousedown);
addEvent("mousewheel", wheel);
addEvent("load", init);
<|endoftext|>


<commit_before>/* exported loadFromKinto */
/* exported saveToKinto */
/* exported BrowserStorageCredentials */

let syncDebounce = null;

const cryptographer = new Jose.WebCryptographer();
cryptographer.setKeyEncryptionAlgorithm('A256KW');
cryptographer.setContentEncryptionAlgorithm('A256GCM');

function shared_key(key) {
  return crypto.subtle.importKey(
    'jwk',
    { kty: key.kty, k: key.k.replace(/=/, '') },
    'AES-KW',
    true,
    ['wrapKey', 'unwrapKey']
  );
}

function encrypt(key, content) {
  const encrypter = new JoseJWE.Encrypter(cryptographer, shared_key(key));
  return encrypter.encrypt(JSON.stringify(content));
}

function decrypt(key, encrypted) {
  const decrypter = new JoseJWE.Decrypter(cryptographer, shared_key(key));
  return decrypter.decrypt(encrypted).then(result => {
    return JSON.parse(result);
  });
}

// An "id schema" used to validate Kinto IDs and generate new ones.
const notesIdSchema = {
  // FIXME: Maybe this should generate IDs?
  generate() {
    throw new Error('cannot generate IDs');
  },

  validate() {
    // FIXME: verify that at least this matches Kinto server ID format
    return true;
  },
};

class ServerKeyNewerError extends Error {
  constructor() {
    super('key used to encrypt the record appears to be newer than our key');
  }
}

class ServerKeyOlderError extends Error {
  constructor() {
    super('key used to encrypt the record appears to be older than our key');
  }
}

class JWETransformer {
  constructor(key) {
    this.key = key;
  }

  async encode(record) {
    // FIXME: should we try to obfuscate the record ID?
    const ciphertext = await encrypt(this.key, record);
    // Copy over the _status field, so that we handle concurrency
    // headers (If-Match, If-None-Match) correctly.
    // DON'T copy over "deleted" status, because then we'd leak
    // plaintext deletes.
    const status = record._status && (record._status === 'deleted' ? 'updated' : record._status);
    const encryptedResult = {
      content: ciphertext,
      id: record.id,
      _status: status,
      kid: this.key.kid,
    };
    if (record.hasOwnProperty('last_modified')) {
      encryptedResult.last_modified = record.last_modified;
    }

    return encryptedResult;
  }

  async decode(record) {
    if (!record.content) {
      // This can happen for tombstones if a record is deleted.
      if (record.deleted) {
        return record;
      }
      throw new Error('No ciphertext: nothing to decrypt?');
    }

    // FIXME: this is hack to work around a bug with FxA keys, which
    // seem to have timestamps embedded in them that are affected by
    // TZ offsets. Take out the substr calls once vladikoff says we can.
    if (record.kid.substr(15) !== this.key.kid.substr(15)) {
      if (this.key.kid < record.kid) {
        throw new ServerKeyNewerError();
      } else {
        throw new ServerKeyOlderError();
      }
    }

    // FIXME: Make sure this is backwards compatible??
    let decoded = await decrypt(this.key, record.content);
    if (!decoded.hasOwnProperty('id')) {
      // Old-style encrypted notes aren't true Kinto records --
      // they're just the content field.
      decoded = {
        content: decoded,
        id: 'singleNote',
      };
    }
    if (record.hasOwnProperty('last_modified')) {
      decoded.last_modified = record.last_modified;
    }

    // _status: deleted records were deleted on a client, but
    // uploaded as an encrypted blob so we don't leak deletions.
    // If we get such a record, flag it as deleted.
    if (decoded._status === 'deleted') {
      decoded.deleted = true;
    }
    return decoded;
  }
}

/**
 * Interface describing a mechanism to fetch credentials.
 */
class Credentials {
  async get() {
    return Promise.reject('Implement me');
  }

  /**
   * Call this if, for example, credentials were invalid.
   */
  async clear() {
    return Promise.reject('Implement me');
  }
}

class BrowserStorageCredentials extends Credentials {
  constructor(storage) {
    super();
    this.storage = storage;
  }

  async get() {
    const data = await this.storage.get(['credentials']);
    return data.credentials;
  }

  async clear() {
    return this.storage.remove('credentials');
  }
}

/**
 * Try to sync our data against the Kinto server.
 *
 * Returns a promise. The promise can reject in case of sync failure
 * or any other reason. This is so that programming errors can be
 * caught more easily in testing. Since this application is
 * offline-first, sync failure should not be a failure for callers.
 */
function syncKinto(client, credentials) {
  // Get credentials and lastmodified
  let credential;
  return credentials.get()
    .then(received => {
      credential = received;
      // XXX: Ask for an refresh token
      // Query Kinto with the Bearer Token
      if (!received) return;
      return client
        .collection('notes', {
          idSchema: notesIdSchema,
          remoteTransformers: [new JWETransformer(credential.key)],
        })
        .sync({
          headers: { Authorization: `Bearer ${credential.access_token}` },
          strategy: 'manual',
        });
    })
    .then(syncResult => {
      // FIXME: conflicts would happen here.
      // Do we need to do anything with errors, published, updated, etc.?
      console.log('sync result', syncResult);
      return syncResult;
    })
    .catch(error => {
      if (error.response && error.response.status === 401) {
        // In case of 401 log the user out.
        // FIXME: Fetch a new token and retry?
        return credentials.clear();
      } else if (error instanceof ServerKeyNewerError) {
        // If the key date is greater than current one, log the user out.
        console.error(error);
        return credentials.clear();
      } else if (error instanceof ServerKeyOlderError) {
        // If the key date is older than the current one, we can't help
        // because there is no way we get the previous key.
        // Flush the server because whatever was there is wrong.
        // FIXME: need to reset sync status.
        console.error(error);
        const kintoHttp = client.api.remote;
        return kintoHttp.bucket('default').deleteCollection('notes', {
          headers: { Authorization: `Bearer ${credential.access_token}` }
        });
      } else {
        console.error(error);
        return Promise.reject(error);
      }
    });
}

function retrieveNote(client) {
  return client.collection('notes', {
    idSchema: notesIdSchema,
  }).getAny('singleNote');
}

/**
 * Try to sync against the Kinto server, and retrieve the current note
 * contents.
 *
 * On completion, a 'kinto-loaded' event will be fired with the
 * following structure:
 *
 * {
 *   action: 'kinto-loaded',
 *   data: the "content" that was previously saved to Kinto, or null
 *     if nothing was previously saved to Kinto (for example, a new
 *     FxA account, or if syncing failed on a fresh profile)
 *   last_modified: the timestamp of the sync, or null
 * }
 */
function loadFromKinto(client, credentials) {
  return syncKinto(client, credentials)
  // Ignore failure of syncKinto by retrieving note even when promise rejected
    .then(() => retrieveNote(client), () => retrieveNote(client))
    .then(result => {
      console.log('Collection had record', result);
      browser.runtime.sendMessage({
        action: 'kinto-loaded',
        data: result ? result.data.content : null,
        last_modified: result ? result.data.last_modified : null,
      });
    });
}

function saveToKinto(client, credentials, content) {
  let resolve;
  const promise = new Promise(thisResolve => {
    resolve = thisResolve;
  });

  // XXX: Debounce the call and set the status to Editing
  browser.runtime.sendMessage('notes@mozilla.com', {
    action: 'text-editing'
  });

  const later = function() {
    syncDebounce = null;
    const notes = client.collection('notes', {
      idSchema: notesIdSchema,
    });
    return notes.upsert({ id: 'singleNote', content })
      .then(() => {
        browser.runtime.sendMessage('notes@mozilla.com', {
          action: 'text-saved'
        });
        return syncKinto(client, credentials);
      })
      .then(() => retrieveNote(client), () => retrieveNote(client))
      .then(result => {
        // Set the status to synced
        return browser.runtime.sendMessage('notes@mozilla.com', {
          action: 'text-synced',
          last_modified: result.data.last_modified,
        });
      })
      .then(() => {
        resolve();
      });
  };

  clearTimeout(syncDebounce);
  syncDebounce = setTimeout(later, 1000);
  return promise;
}
<commit_msg>Handle conflicts<commit_after>/* exported loadFromKinto */
/* exported saveToKinto */
/* exported BrowserStorageCredentials */

let syncDebounce = null;

const cryptographer = new Jose.WebCryptographer();
cryptographer.setKeyEncryptionAlgorithm('A256KW');
cryptographer.setContentEncryptionAlgorithm('A256GCM');

function shared_key(key) {
  return crypto.subtle.importKey(
    'jwk',
    { kty: key.kty, k: key.k.replace(/=/, '') },
    'AES-KW',
    true,
    ['wrapKey', 'unwrapKey']
  );
}

function encrypt(key, content) {
  const encrypter = new JoseJWE.Encrypter(cryptographer, shared_key(key));
  return encrypter.encrypt(JSON.stringify(content));
}

function decrypt(key, encrypted) {
  const decrypter = new JoseJWE.Decrypter(cryptographer, shared_key(key));
  return decrypter.decrypt(encrypted).then(result => {
    return JSON.parse(result);
  });
}

// An "id schema" used to validate Kinto IDs and generate new ones.
const notesIdSchema = {
  // FIXME: Maybe this should generate IDs?
  generate() {
    throw new Error('cannot generate IDs');
  },

  validate() {
    // FIXME: verify that at least this matches Kinto server ID format
    return true;
  },
};

class ServerKeyNewerError extends Error {
  constructor() {
    super('key used to encrypt the record appears to be newer than our key');
  }
}

class ServerKeyOlderError extends Error {
  constructor() {
    super('key used to encrypt the record appears to be older than our key');
  }
}

class JWETransformer {
  constructor(key) {
    this.key = key;
  }

  async encode(record) {
    // FIXME: should we try to obfuscate the record ID?
    const ciphertext = await encrypt(this.key, record);
    // Copy over the _status field, so that we handle concurrency
    // headers (If-Match, If-None-Match) correctly.
    // DON'T copy over "deleted" status, because then we'd leak
    // plaintext deletes.
    const status = record._status && (record._status === 'deleted' ? 'updated' : record._status);
    const encryptedResult = {
      content: ciphertext,
      id: record.id,
      _status: status,
      kid: this.key.kid,
    };
    if (record.hasOwnProperty('last_modified')) {
      encryptedResult.last_modified = record.last_modified;
    }

    return encryptedResult;
  }

  async decode(record) {
    if (!record.content) {
      // This can happen for tombstones if a record is deleted.
      if (record.deleted) {
        return record;
      }
      throw new Error('No ciphertext: nothing to decrypt?');
    }

    // FIXME: this is hack to work around a bug with FxA keys, which
    // seem to have timestamps embedded in them that are affected by
    // TZ offsets. Take out the substr calls once vladikoff says we can.
    if (record.kid.substr(15) !== this.key.kid.substr(15)) {
      if (this.key.kid < record.kid) {
        throw new ServerKeyNewerError();
      } else {
        throw new ServerKeyOlderError();
      }
    }

    // FIXME: Make sure this is backwards compatible??
    let decoded = await decrypt(this.key, record.content);
    if (!decoded.hasOwnProperty('id')) {
      // Old-style encrypted notes aren't true Kinto records --
      // they're just the content field.
      decoded = {
        content: decoded,
        id: 'singleNote',
      };
    }
    if (record.hasOwnProperty('last_modified')) {
      decoded.last_modified = record.last_modified;
    }

    // _status: deleted records were deleted on a client, but
    // uploaded as an encrypted blob so we don't leak deletions.
    // If we get such a record, flag it as deleted.
    if (decoded._status === 'deleted') {
      decoded.deleted = true;
    }
    return decoded;
  }
}

/**
 * Interface describing a mechanism to fetch credentials.
 */
class Credentials {
  async get() {
    return Promise.reject('Implement me');
  }

  /**
   * Call this if, for example, credentials were invalid.
   */
  async clear() {
    return Promise.reject('Implement me');
  }
}

class BrowserStorageCredentials extends Credentials {
  constructor(storage) {
    super();
    this.storage = storage;
  }

  async get() {
    const data = await this.storage.get(['credentials']);
    return data.credentials;
  }

  async clear() {
    return this.storage.remove('credentials');
  }
}

/**
 * Try to sync our data against the Kinto server.
 *
 * Returns a promise. The promise can reject in case of sync failure
 * or any other reason. This is so that programming errors can be
 * caught more easily in testing. Since this application is
 * offline-first, sync failure should not be a failure for callers.
 */
function syncKinto(client, credentials) {
  // Get credentials and lastmodified
  let collection, credential;
  return credentials.get()
    .then(received => {
      credential = received;
      // XXX: Ask for an refresh token
      // Query Kinto with the Bearer Token
      if (!received) return;
      collection = client
        .collection('notes', {
          idSchema: notesIdSchema,
          remoteTransformers: [new JWETransformer(credential.key)],
        });
      return collection
        .sync({
          headers: { Authorization: `Bearer ${credential.access_token}` },
          strategy: 'manual',
        });
    })
    .then(syncResult => {
      // FIXME: Do we need to do anything with errors, published,
      // updated, etc.?
      return Promise.all(syncResult.conflicts.map(conflict => {
        console.log(conflict);
        let totalOps = conflict.remote.content.ops.slice();
        totalOps.push({ insert: '\n====== On this computer: ======\n\n' });
        totalOps = totalOps.concat(conflict.local.content.ops);
        const resolution = {
          id: conflict.remote.id,
          content: {ops: totalOps},
        };
        return collection.resolve(conflict, resolution);
      }));
      // FIXME: Maybe sync again to "push" the resolution?
    })
    .catch(error => {
      if (error.response && error.response.status === 401) {
        // In case of 401 log the user out.
        // FIXME: Fetch a new token and retry?
        return credentials.clear();
      } else if (error instanceof ServerKeyNewerError) {
        // If the key date is greater than current one, log the user out.
        console.error(error);
        return credentials.clear();
      } else if (error instanceof ServerKeyOlderError) {
        // If the key date is older than the current one, we can't help
        // because there is no way we get the previous key.
        // Flush the server because whatever was there is wrong.
        // FIXME: need to reset sync status.
        console.error(error);
        const kintoHttp = client.api.remote;
        return kintoHttp.bucket('default').deleteCollection('notes', {
          headers: { Authorization: `Bearer ${credential.access_token}` }
        });
      } else {
        console.error(error);
        return Promise.reject(error);
      }
    });
}

function retrieveNote(client) {
  return client.collection('notes', {
    idSchema: notesIdSchema,
  }).getAny('singleNote');
}

/**
 * Try to sync against the Kinto server, and retrieve the current note
 * contents.
 *
 * On completion, a 'kinto-loaded' event will be fired with the
 * following structure:
 *
 * {
 *   action: 'kinto-loaded',
 *   data: the "content" that was previously saved to Kinto, or null
 *     if nothing was previously saved to Kinto (for example, a new
 *     FxA account, or if syncing failed on a fresh profile)
 *   last_modified: the timestamp of the sync, or null
 * }
 */
function loadFromKinto(client, credentials) {
  return syncKinto(client, credentials)
  // Ignore failure of syncKinto by retrieving note even when promise rejected
    .then(() => retrieveNote(client), () => retrieveNote(client))
    .then(result => {
      console.log('Collection had record', result);
      browser.runtime.sendMessage({
        action: 'kinto-loaded',
        data: result ? result.data.content : null,
        last_modified: result ? result.data.last_modified : null,
      });
    });
}

function saveToKinto(client, credentials, content) {
  let resolve;
  const promise = new Promise(thisResolve => {
    resolve = thisResolve;
  });

  // XXX: Debounce the call and set the status to Editing
  browser.runtime.sendMessage('notes@mozilla.com', {
    action: 'text-editing'
  });

  const later = function() {
    syncDebounce = null;
    const notes = client.collection('notes', {
      idSchema: notesIdSchema,
    });
    return notes.upsert({ id: 'singleNote', content })
      .then(() => {
        browser.runtime.sendMessage('notes@mozilla.com', {
          action: 'text-saved'
        });
        return syncKinto(client, credentials);
      })
      .then(() => retrieveNote(client), () => retrieveNote(client))
      .then(result => {
        // Set the status to synced
        return browser.runtime.sendMessage('notes@mozilla.com', {
          action: 'text-synced',
          last_modified: result.data.last_modified,
        });
      })
      .then(() => {
        resolve();
      });
  };

  clearTimeout(syncDebounce);
  syncDebounce = setTimeout(later, 1000);
  return promise;
}
<|endoftext|>


<commit_before>/* @flow weak */

import async from 'async'
import Promise from 'bluebird'
import EventEmitter from 'events'

import Ignore from './ignore'
import Local from './local'
import logger from './logger'
import { extractRevNumber, inRemoteTrash } from './metadata'
import Pouch from './pouch'
import Remote from './remote'
import { REVOKED } from './remote/watcher'
import { PendingMap } from './utils/pending'

import type { Metadata } from './metadata'
import type { Side } from './side' // eslint-disable-line

const log = logger({
  prefix: 'Synchronize   ',
  date: true
})

export const TRASHING_DELAY = 1000

type Change = {
  changes: {rev: string}[],
  doc: Metadata,
  id: string,
  seq: number
};

// Sync listens to PouchDB about the metadata changes, and calls local and
// remote sides to apply the changes on the filesystem and remote CouchDB
// respectively.
class Sync {
  changes: any
  events: EventEmitter
  ignore: Ignore
  local: Local
  pending: PendingMap
  pouch: Pouch
  remote: Remote
  stopped: ?boolean
  moveFrom: any
  moveTo: ?string

  constructor (pouch, local, remote, ignore, events) {
    this.pouch = pouch
    this.local = local
    this.remote = remote
    this.ignore = ignore
    this.events = events
    this.local.other = this.remote
    this.remote.other = this.local
    this.pending = new PendingMap()

    Promise.promisifyAll(this)
  }

  // Start to synchronize the remote cozy with the local filesystem
  // First, start metadata synchronization in pouch, with the watchers
  // Then, when a stable state is reached, start applying changes from pouch
  //
  // The mode can be:
  // - pull if only changes from the remote cozy are applied to the fs
  // - push if only changes from the fs are applied to the remote cozy
  // - full for the full synchronization of the both sides
  start (mode) {
    this.stopped = false
    let promise = new Promise((resolve, reject) => {
      this.pouch.addAllViews((err) => {
        if (err) {
          reject(err)
        } else {
          resolve()
        }
      })
    })
    if (mode !== 'pull') {
      promise = promise.then(this.local.start)
    }
    let running = Promise.resolve()
    if (mode !== 'push') {
      promise = promise.then(() => {
        let res = this.remote.start()
        running = res.running
        return res.started
      })
    }
    return promise.then(() => {
      return new Promise((resolve, reject) => {
        running.catch((err) => reject(err))
        async.forever(this.sync, err => reject(err))
      }).catch((err) => {
        this.stop()
        return Promise.reject(err)
      })
    })
  }

  // Stop the synchronization
  stop () {
    this.stopped = true
    if (this.changes) {
      this.changes.cancel()
      this.changes = null
    }
    return Promise.all([this.local.stop(), this.remote.stop()])
  }

  // Start taking changes from pouch and applying them
  sync (callback) {
    this.pop((err, change) => {
      if (this.stopped) { return }
      if (err) {
        log.error(err)
        callback(err)
      } else {
        this.apply(change, (err) => {
          if (this.stopped) { err = null }
          callback(err)
        })
      }
    })
  }

  // Take the next change from pouch
  // We filter with the byPath view to reject design documents
  //
  // Note: it is difficult to pick only one change at a time because pouch can
  // emit several docs in a row, and `limit: 1` seems to be not effective!
  pop (callback) {
    this.pouch.getLocalSeq((err, seq) => {
      if (err) {
        callback(err)
        return
      }
      let opts = {
        limit: 1,
        since: seq,
        include_docs: true,
        filter: '_view',
        view: 'byPath'
      }
      this.pouch.db.changes(opts)
        .on('change', info => callback(null, info))
        .on('error', err => callback(err))
        .on('complete', info => {
          if (info.results && info.results.length) { return }
          this.events.emit('up-to-date')
          log.debug('No more metadata changes for now')
          log.lineBreak()
          opts.live = true
          opts.returnDocs = false
          this.changes = this.pouch.db.changes(opts)
            .on('change', info => {
              if (this.changes) {
                this.changes.cancel()
                this.changes = null
                callback(null, info)
              }
            })
            .on('error', err => {
              if (this.changes) {
                this.changes = null
                callback(err, null)
              }
            })
        })
    })
  }

  // Apply a change to both local and remote
  // At least one side should say it has already this change
  // In some cases, both sides have the change
  apply (change, callback) {
    let { doc } = change
    log.debug(`${doc.path}: Applying change ${change.seq}...`)
    log.inspect(change)

    if (this.ignore.isIgnored(doc)) {
      this.pouch.setLocalSeq(change.seq, _ => callback())
      return
    }

    let [side, sideName, rev] = this.selectSide(doc)
    let done = this.applied(change, sideName, callback)

    switch (true) {
      case side == null:
        this.pouch.setLocalSeq(change.seq, callback)
        break
      case (sideName === 'remote' && doc.toBeTrashed && !inRemoteTrash(doc)):
        // File or folder was just deleted locally
        // TODO: Retry on failure instead of going unsynced
        this.trashLaterWithParentOrByItself(doc, side)
        done()
        break
      case doc.docType === 'file':
        this.fileChanged(doc, side, rev, done)
        break
      case doc.docType === 'folder':
        this.folderChanged(doc, side, rev, done)
        break
      default:
        callback(new Error(`Unknown doctype: ${doc.docType}`))
    }
  }

  // Select which side will apply the change
  // It returns the side, its name, and also the last rev applied by this side
  selectSide (doc) {
    let localRev = doc.sides.local || 0
    let remoteRev = doc.sides.remote || 0
    if (localRev > remoteRev) {
      return [this.remote, 'remote', remoteRev]
    } else if (remoteRev > localRev) {
      return [this.local, 'local', localRev]
    } else {
      log.debug(`${doc.path}: Nothing to do`)
      return []
    }
  }

  // Keep track of the sequence number, save side rev, and log errors
  applied (change, side, callback) {
    return err => {
      if (err) { log.error(err) }
      if (err && err.code === 'ENOSPC') {
        callback(new Error('The disk space on your computer is full!'))
      } else if (err && err.message === REVOKED) {
        callback(err)
      } else if (err) {
        if (!change.doc.errors) { change.doc.errors = 0 }
        this.isCozyFull((err, full) => {
          if (err) {
            // TODO: v3: Ping remote on error?
            callback(err)
            /*
            this.remote.couch.ping(available => {
              if (available) {
                this.updateErrors(change, callback)
              } else {
                this.remote.couch.whenAvailable(callback)
              }
            })
            */
          } else if (full) {
            callback(new Error(
              'Your Cozy is full! ' +
              'You can delete some files to be able' +
              'to add new ones or upgrade your storage plan.'
            ))
          } else {
            this.updateErrors(change, callback)
          }
        })
      } else {
        log.debug(`${change.doc.path}: Applied change ${change.seq}`)
        this.pouch.setLocalSeq(change.seq, err => {
          if (err) { log.error(err) }
          if (change.doc._deleted) {
            callback(err)
          } else {
            this.updateRevs(change.doc, side, callback)
          }
        })
      }
    }
  }

  // Says is the Cozy has no more free disk space
  isCozyFull (callback) {
    // TODO: v3: Reimplement Sync#isCozyFull()
    callback(false)
    /*
    this.getDiskSpace(function (err, res) {
      if (err) {
        callback(err)
      } else {
        callback(null, ['', '0'].includes(res.diskSpace.freeDiskSpace))
      }
    })
    */
  }

  // Increment the counter of errors for this document
  updateErrors (change, callback) {
    let { doc } = change
    doc.errors++
    // Don't try more than 10 times for the same operation
    if (doc.errors >= 10) {
      this.pouch.setLocalSeq(change.seq, callback)
      return
    }
    this.pouch.put(doc, err => {
      // If the doc can't be saved, it's because of a new revision.
      // So, we can skip this revision
      if (err) {
        log.info(`Ignored ${change.seq}`, err)
        this.pouch.setLocalSeq(change.seq, callback)
        return
      }
      // The sync error may be due to the remote cozy being overloaded.
      // So, it's better to wait a bit before trying the next operation.
      setTimeout(callback, 3000)
    })
  }

  // Update rev numbers for both local and remote sides
  updateRevs (doc, side, callback) {
    let rev = extractRevNumber(doc) + 1
    for (let s of ['local', 'remote']) {
      doc.sides[s] = rev
    }
    delete doc.errors
    this.pouch.put(doc, err => {
      // Conflicts can happen here, for example if the data-system has
      // generated a thumbnail before apply has finished. In that case, we
      // try to reconciliate the documents.
      if (err && err.status === 409) {
        this.pouch.db.get(doc._id, (err, doc) => {
          if (err) {
            log.warn('Race condition', err)
            callback()
          } else {
            doc.sides[side] = rev
            this.pouch.put(doc, function (err) {
              if (err) { log.warn('Race condition', err) }
              callback()
            })
          }
        })
      } else {
        if (err) { log.warn('Race condition', err) }
        callback()
      }
    })
  }

  // If a file has been changed, we had to check what operation it is.
  // For a move, the first call will just keep a reference to the document,
  // and only at the second call, the move operation will be executed.
  fileChanged (doc, side: Side, rev, callback) {
    let from
    switch (true) {
      case doc._deleted && (rev === 0):
        callback()
        break
      case this.moveFrom != null:
        from = this.moveFrom
        this.moveFrom = null
        if (from.moveTo === doc._id) {
          side.moveFile(doc, from, err => {
            if (err) { this.moveFrom = from }
            callback(err)
          })
        } else {
          log.error('Invalid move')
          log.error(from)
          log.error(doc)
          side.addFile(doc, function (err) {
            if (err) { log.error(err) }
            side.destroy(from, function (err) {
              if (err) { log.error(err) }
              callback(new Error('Invalid move'))
            })
          })
        }
        break
      case doc.moveTo != null:
        this.moveFrom = doc
        callback()
        break
      case doc._deleted:
        side.destroy(doc, callback)
        break
      case rev === 0:
        side.addFile(doc, callback)
        break
      default:
        this.pouch.getPreviousRev(doc._id, rev, function (err, old) {
          if (err) {
            side.overwriteFile(doc, old, callback)
          } else if (old.checksum === doc.checksum) {
            side.updateFileMetadata(doc, old, callback)
          } else if (old.remote && !old.checksum) {
            // Photos uploaded by cozy-mobile have no checksum,
            // but it's useless to reupload the binary
            side.updateFileMetadata(doc, old, callback)
          } else {
            side.overwriteFile(doc, old, callback)
          }
        })
    }
  }

  // Same as fileChanged, but for folder
  folderChanged (doc, side: Side, rev, callback) {
    let from
    switch (true) {
      case doc._deleted && (rev === 0):
        callback()
        break
      case this.moveFrom != null:
        from = this.moveFrom
        this.moveFrom = null
        if (from.moveTo === doc._id) {
          side.moveFolder(doc, from, err => {
            if (err) { this.moveFrom = from }
            callback(err)
          })
        } else {
          // Since a move requires 2 PouchDB writes, in rare cases the source
          // and the destination may not match anymore (race condition).
          // As a fallback, we try to add the folder that should exist, and to
          // trash the one that shouldn't.
          log.error('Invalid move')
          log.error(from)
          log.error(doc)
          side.addFolder(doc, function (err) {
            if (err) { log.error(err) }
            side.trash(from, function (err) {
              if (err) { log.error(err) }
              callback(new Error('Invalid move'))
            })
          })
        }
        break
      case doc.moveTo != null:
        this.moveFrom = doc
        callback()
        break
      case doc._deleted:
        side.destroy(doc, callback)
        break
      case rev === 0:
        side.addFolder(doc, callback)
        break
      default:
        this.pouch.getPreviousRev(doc._id, rev, (_, old) => side.updateFolder(doc, old, callback))
    }
  }

  // Wait for possibly trashed parent directory. Do nothing if any.
  // Otherwise trash the file or directory matching the given metadata on the
  // given side.
  //
  // In order to wait for upcoming events, we need not to block them, so
  // this method doesn't take a callback and returns immediately.
  trashLaterWithParentOrByItself (doc: Metadata, side: Side) {
    // TODO: Extract delayed execution logic to utils/pending
    let timeout

    this.pending.add(doc.path, {
      stopChecking: () => {
        clearTimeout(timeout)
      },

      execute: () => {
        this.pending.clear(doc.path)

        if (this.pending.hasParentPath(doc.path)) {
          log.debug(`${doc.path}: will be trashed with parent directory`)
        } else {
          log.debug(`${doc.path}: should be trashed by itself`)
          side.trash(doc, log.errorIfAny)
        }
      }
    })

    timeout = setTimeout(() => {
      this.pending.executeIfAny(doc.path)
    }, TRASHING_DELAY)
  }
}

export default Sync
<commit_msg>sync: Introduce SyncMode type<commit_after>/* @flow weak */

import async from 'async'
import Promise from 'bluebird'
import EventEmitter from 'events'

import Ignore from './ignore'
import Local from './local'
import logger from './logger'
import { extractRevNumber, inRemoteTrash } from './metadata'
import Pouch from './pouch'
import Remote from './remote'
import { REVOKED } from './remote/watcher'
import { PendingMap } from './utils/pending'

import type { Metadata } from './metadata'
import type { Side } from './side' // eslint-disable-line

const log = logger({
  prefix: 'Synchronize   ',
  date: true
})

export const TRASHING_DELAY = 1000

type Change = {
  changes: {rev: string}[],
  doc: Metadata,
  id: string,
  seq: number
};

export type SyncMode =
  | "pull"
  | "push"
  | "full";

// Sync listens to PouchDB about the metadata changes, and calls local and
// remote sides to apply the changes on the filesystem and remote CouchDB
// respectively.
class Sync {
  changes: any
  events: EventEmitter
  ignore: Ignore
  local: Local
  pending: PendingMap
  pouch: Pouch
  remote: Remote
  stopped: ?boolean
  moveFrom: any
  moveTo: ?string

  constructor (pouch, local, remote, ignore, events) {
    this.pouch = pouch
    this.local = local
    this.remote = remote
    this.ignore = ignore
    this.events = events
    this.local.other = this.remote
    this.remote.other = this.local
    this.pending = new PendingMap()

    Promise.promisifyAll(this)
  }

  // Start to synchronize the remote cozy with the local filesystem
  // First, start metadata synchronization in pouch, with the watchers
  // Then, when a stable state is reached, start applying changes from pouch
  //
  // The mode can be:
  // - pull if only changes from the remote cozy are applied to the fs
  // - push if only changes from the fs are applied to the remote cozy
  // - full for the full synchronization of the both sides
  start (mode) {
    this.stopped = false
    let promise = new Promise((resolve, reject) => {
      this.pouch.addAllViews((err) => {
        if (err) {
          reject(err)
        } else {
          resolve()
        }
      })
    })
    if (mode !== 'pull') {
      promise = promise.then(this.local.start)
    }
    let running = Promise.resolve()
    if (mode !== 'push') {
      promise = promise.then(() => {
        let res = this.remote.start()
        running = res.running
        return res.started
      })
    }
    return promise.then(() => {
      return new Promise((resolve, reject) => {
        running.catch((err) => reject(err))
        async.forever(this.sync, err => reject(err))
      }).catch((err) => {
        this.stop()
        return Promise.reject(err)
      })
    })
  }

  // Stop the synchronization
  stop () {
    this.stopped = true
    if (this.changes) {
      this.changes.cancel()
      this.changes = null
    }
    return Promise.all([this.local.stop(), this.remote.stop()])
  }

  // Start taking changes from pouch and applying them
  sync (callback) {
    this.pop((err, change) => {
      if (this.stopped) { return }
      if (err) {
        log.error(err)
        callback(err)
      } else {
        this.apply(change, (err) => {
          if (this.stopped) { err = null }
          callback(err)
        })
      }
    })
  }

  // Take the next change from pouch
  // We filter with the byPath view to reject design documents
  //
  // Note: it is difficult to pick only one change at a time because pouch can
  // emit several docs in a row, and `limit: 1` seems to be not effective!
  pop (callback) {
    this.pouch.getLocalSeq((err, seq) => {
      if (err) {
        callback(err)
        return
      }
      let opts = {
        limit: 1,
        since: seq,
        include_docs: true,
        filter: '_view',
        view: 'byPath'
      }
      this.pouch.db.changes(opts)
        .on('change', info => callback(null, info))
        .on('error', err => callback(err))
        .on('complete', info => {
          if (info.results && info.results.length) { return }
          this.events.emit('up-to-date')
          log.debug('No more metadata changes for now')
          log.lineBreak()
          opts.live = true
          opts.returnDocs = false
          this.changes = this.pouch.db.changes(opts)
            .on('change', info => {
              if (this.changes) {
                this.changes.cancel()
                this.changes = null
                callback(null, info)
              }
            })
            .on('error', err => {
              if (this.changes) {
                this.changes = null
                callback(err, null)
              }
            })
        })
    })
  }

  // Apply a change to both local and remote
  // At least one side should say it has already this change
  // In some cases, both sides have the change
  apply (change, callback) {
    let { doc } = change
    log.debug(`${doc.path}: Applying change ${change.seq}...`)
    log.inspect(change)

    if (this.ignore.isIgnored(doc)) {
      this.pouch.setLocalSeq(change.seq, _ => callback())
      return
    }

    let [side, sideName, rev] = this.selectSide(doc)
    let done = this.applied(change, sideName, callback)

    switch (true) {
      case side == null:
        this.pouch.setLocalSeq(change.seq, callback)
        break
      case (sideName === 'remote' && doc.toBeTrashed && !inRemoteTrash(doc)):
        // File or folder was just deleted locally
        // TODO: Retry on failure instead of going unsynced
        this.trashLaterWithParentOrByItself(doc, side)
        done()
        break
      case doc.docType === 'file':
        this.fileChanged(doc, side, rev, done)
        break
      case doc.docType === 'folder':
        this.folderChanged(doc, side, rev, done)
        break
      default:
        callback(new Error(`Unknown doctype: ${doc.docType}`))
    }
  }

  // Select which side will apply the change
  // It returns the side, its name, and also the last rev applied by this side
  selectSide (doc) {
    let localRev = doc.sides.local || 0
    let remoteRev = doc.sides.remote || 0
    if (localRev > remoteRev) {
      return [this.remote, 'remote', remoteRev]
    } else if (remoteRev > localRev) {
      return [this.local, 'local', localRev]
    } else {
      log.debug(`${doc.path}: Nothing to do`)
      return []
    }
  }

  // Keep track of the sequence number, save side rev, and log errors
  applied (change, side, callback) {
    return err => {
      if (err) { log.error(err) }
      if (err && err.code === 'ENOSPC') {
        callback(new Error('The disk space on your computer is full!'))
      } else if (err && err.message === REVOKED) {
        callback(err)
      } else if (err) {
        if (!change.doc.errors) { change.doc.errors = 0 }
        this.isCozyFull((err, full) => {
          if (err) {
            // TODO: v3: Ping remote on error?
            callback(err)
            /*
            this.remote.couch.ping(available => {
              if (available) {
                this.updateErrors(change, callback)
              } else {
                this.remote.couch.whenAvailable(callback)
              }
            })
            */
          } else if (full) {
            callback(new Error(
              'Your Cozy is full! ' +
              'You can delete some files to be able' +
              'to add new ones or upgrade your storage plan.'
            ))
          } else {
            this.updateErrors(change, callback)
          }
        })
      } else {
        log.debug(`${change.doc.path}: Applied change ${change.seq}`)
        this.pouch.setLocalSeq(change.seq, err => {
          if (err) { log.error(err) }
          if (change.doc._deleted) {
            callback(err)
          } else {
            this.updateRevs(change.doc, side, callback)
          }
        })
      }
    }
  }

  // Says is the Cozy has no more free disk space
  isCozyFull (callback) {
    // TODO: v3: Reimplement Sync#isCozyFull()
    callback(false)
    /*
    this.getDiskSpace(function (err, res) {
      if (err) {
        callback(err)
      } else {
        callback(null, ['', '0'].includes(res.diskSpace.freeDiskSpace))
      }
    })
    */
  }

  // Increment the counter of errors for this document
  updateErrors (change, callback) {
    let { doc } = change
    doc.errors++
    // Don't try more than 10 times for the same operation
    if (doc.errors >= 10) {
      this.pouch.setLocalSeq(change.seq, callback)
      return
    }
    this.pouch.put(doc, err => {
      // If the doc can't be saved, it's because of a new revision.
      // So, we can skip this revision
      if (err) {
        log.info(`Ignored ${change.seq}`, err)
        this.pouch.setLocalSeq(change.seq, callback)
        return
      }
      // The sync error may be due to the remote cozy being overloaded.
      // So, it's better to wait a bit before trying the next operation.
      setTimeout(callback, 3000)
    })
  }

  // Update rev numbers for both local and remote sides
  updateRevs (doc, side, callback) {
    let rev = extractRevNumber(doc) + 1
    for (let s of ['local', 'remote']) {
      doc.sides[s] = rev
    }
    delete doc.errors
    this.pouch.put(doc, err => {
      // Conflicts can happen here, for example if the data-system has
      // generated a thumbnail before apply has finished. In that case, we
      // try to reconciliate the documents.
      if (err && err.status === 409) {
        this.pouch.db.get(doc._id, (err, doc) => {
          if (err) {
            log.warn('Race condition', err)
            callback()
          } else {
            doc.sides[side] = rev
            this.pouch.put(doc, function (err) {
              if (err) { log.warn('Race condition', err) }
              callback()
            })
          }
        })
      } else {
        if (err) { log.warn('Race condition', err) }
        callback()
      }
    })
  }

  // If a file has been changed, we had to check what operation it is.
  // For a move, the first call will just keep a reference to the document,
  // and only at the second call, the move operation will be executed.
  fileChanged (doc, side: Side, rev, callback) {
    let from
    switch (true) {
      case doc._deleted && (rev === 0):
        callback()
        break
      case this.moveFrom != null:
        from = this.moveFrom
        this.moveFrom = null
        if (from.moveTo === doc._id) {
          side.moveFile(doc, from, err => {
            if (err) { this.moveFrom = from }
            callback(err)
          })
        } else {
          log.error('Invalid move')
          log.error(from)
          log.error(doc)
          side.addFile(doc, function (err) {
            if (err) { log.error(err) }
            side.destroy(from, function (err) {
              if (err) { log.error(err) }
              callback(new Error('Invalid move'))
            })
          })
        }
        break
      case doc.moveTo != null:
        this.moveFrom = doc
        callback()
        break
      case doc._deleted:
        side.destroy(doc, callback)
        break
      case rev === 0:
        side.addFile(doc, callback)
        break
      default:
        this.pouch.getPreviousRev(doc._id, rev, function (err, old) {
          if (err) {
            side.overwriteFile(doc, old, callback)
          } else if (old.checksum === doc.checksum) {
            side.updateFileMetadata(doc, old, callback)
          } else if (old.remote && !old.checksum) {
            // Photos uploaded by cozy-mobile have no checksum,
            // but it's useless to reupload the binary
            side.updateFileMetadata(doc, old, callback)
          } else {
            side.overwriteFile(doc, old, callback)
          }
        })
    }
  }

  // Same as fileChanged, but for folder
  folderChanged (doc, side: Side, rev, callback) {
    let from
    switch (true) {
      case doc._deleted && (rev === 0):
        callback()
        break
      case this.moveFrom != null:
        from = this.moveFrom
        this.moveFrom = null
        if (from.moveTo === doc._id) {
          side.moveFolder(doc, from, err => {
            if (err) { this.moveFrom = from }
            callback(err)
          })
        } else {
          // Since a move requires 2 PouchDB writes, in rare cases the source
          // and the destination may not match anymore (race condition).
          // As a fallback, we try to add the folder that should exist, and to
          // trash the one that shouldn't.
          log.error('Invalid move')
          log.error(from)
          log.error(doc)
          side.addFolder(doc, function (err) {
            if (err) { log.error(err) }
            side.trash(from, function (err) {
              if (err) { log.error(err) }
              callback(new Error('Invalid move'))
            })
          })
        }
        break
      case doc.moveTo != null:
        this.moveFrom = doc
        callback()
        break
      case doc._deleted:
        side.destroy(doc, callback)
        break
      case rev === 0:
        side.addFolder(doc, callback)
        break
      default:
        this.pouch.getPreviousRev(doc._id, rev, (_, old) => side.updateFolder(doc, old, callback))
    }
  }

  // Wait for possibly trashed parent directory. Do nothing if any.
  // Otherwise trash the file or directory matching the given metadata on the
  // given side.
  //
  // In order to wait for upcoming events, we need not to block them, so
  // this method doesn't take a callback and returns immediately.
  trashLaterWithParentOrByItself (doc: Metadata, side: Side) {
    // TODO: Extract delayed execution logic to utils/pending
    let timeout

    this.pending.add(doc.path, {
      stopChecking: () => {
        clearTimeout(timeout)
      },

      execute: () => {
        this.pending.clear(doc.path)

        if (this.pending.hasParentPath(doc.path)) {
          log.debug(`${doc.path}: will be trashed with parent directory`)
        } else {
          log.debug(`${doc.path}: should be trashed by itself`)
          side.trash(doc, log.errorIfAny)
        }
      }
    })

    timeout = setTimeout(() => {
      this.pending.executeIfAny(doc.path)
    }, TRASHING_DELAY)
  }
}

export default Sync
<|endoftext|>