From 6913d28abb25f09c1de99d9148c1fa0e0ff9fb48 Mon Sep 17 00:00:00 2001 From: Paulo Reis Date: Mon, 13 Nov 2017 12:03:28 +0000 Subject: [PATCH 01/98] Editor header styling. --- services/web/public/stylesheets/app/base.less | 2 +- .../web/public/stylesheets/app/editor.less | 4 +-- .../public/stylesheets/app/editor/chat.less | 4 +-- .../stylesheets/app/editor/history.less | 6 ++--- .../public/stylesheets/app/editor/search.less | 2 +- .../stylesheets/app/editor/toolbar.less | 17 ++++++------ .../public/stylesheets/app/translations.less | 2 +- .../stylesheets/core/_common-variables.less | 27 ++++++++++++++----- .../public/stylesheets/core/ol-variables.less | 12 +++++++++ 9 files changed, 51 insertions(+), 25 deletions(-) diff --git a/services/web/public/stylesheets/app/base.less b/services/web/public/stylesheets/app/base.less index a78e1a7cfa..7f2d9a55ed 100644 --- a/services/web/public/stylesheets/app/base.less +++ b/services/web/public/stylesheets/app/base.less @@ -2,7 +2,7 @@ padding: (@line-height-computed / 4) (@line-height-computed / 2); background-color: @state-warning-bg; color: #333; - border-bottom: 1px solid @toolbar-border-color; + border-bottom: 1px solid @common-border-color; } .clickable { diff --git a/services/web/public/stylesheets/app/editor.less b/services/web/public/stylesheets/app/editor.less index 75b7121094..017ea2ecc8 100644 --- a/services/web/public/stylesheets/app/editor.less +++ b/services/web/public/stylesheets/app/editor.less @@ -250,8 +250,8 @@ .ui-layout-resizer { width: 6px; background-color: #f4f4f4; - border-left: 1px solid @toolbar-border-color; - border-right: 1px solid @toolbar-border-color; + border-left: 1px solid @editor-border-color; + border-right: 1px solid @editor-border-color; .ui-layout-toggler { color: #999; font-family: FontAwesome; diff --git a/services/web/public/stylesheets/app/editor/chat.less b/services/web/public/stylesheets/app/editor/chat.less index d0ad76fe9c..c782384cb7 100644 --- a/services/web/public/stylesheets/app/editor/chat.less +++ b/services/web/public/stylesheets/app/editor/chat.less @@ -126,12 +126,12 @@ height: @new-message-height; background-color: @gray-lightest; padding: @line-height-computed / 4; - border-top: 1px solid @toolbar-border-color; + border-top: 1px solid @editor-border-color; textarea { overflow: auto; resize: none; border-radius: @border-radius-base; - border: 1px solid @toolbar-border-color; + border: 1px solid @editor-border-color; height: 100%; width: 100%; color: @gray-dark; diff --git a/services/web/public/stylesheets/app/editor/history.less b/services/web/public/stylesheets/app/editor/history.less index 81af8f989f..b6dca4b7cc 100644 --- a/services/web/public/stylesheets/app/editor/history.less +++ b/services/web/public/stylesheets/app/editor/history.less @@ -70,7 +70,7 @@ } aside.change-list { - border-left: 1px solid @toolbar-border-color; + border-left: 1px solid @editor-border-color; height: 100%; width: @changesListWidth; position: absolute; @@ -91,7 +91,7 @@ .day { background-color: #fafafa; - border-bottom: 1px solid @toolbar-border-color; + border-bottom: 1px solid @editor-border-color; padding: 4px; font-weight: bold; text-align: center; @@ -132,7 +132,7 @@ padding: (@line-height-computed / 4); padding-left: 38px; min-height: 38px; - border-bottom: 1px solid @toolbar-border-color; + border-bottom: 1px solid @editor-border-color; cursor: pointer; &:hover { background-color: @gray-lightest; diff --git a/services/web/public/stylesheets/app/editor/search.less b/services/web/public/stylesheets/app/editor/search.less index 93e7e507d6..f31c59dd6f 100644 --- a/services/web/public/stylesheets/app/editor/search.less +++ b/services/web/public/stylesheets/app/editor/search.less @@ -1,6 +1,6 @@ .ace_search { background-color: @gray-lightest; - border: 1px solid @toolbar-border-color; + border: 1px solid @editor-border-color; border-top: 0 none; width: 350px; overflow: hidden; diff --git a/services/web/public/stylesheets/app/editor/toolbar.less b/services/web/public/stylesheets/app/editor/toolbar.less index 1bb5f62bdc..cb20c1354f 100644 --- a/services/web/public/stylesheets/app/editor/toolbar.less +++ b/services/web/public/stylesheets/app/editor/toolbar.less @@ -48,18 +48,18 @@ border: none; border-radius: 0; border-right: 1px solid @toolbar-border-color; - color: @link-color; + color: @toolbar-btn-color; padding: 3px 10px 5px; font-size: 20px; &:hover { - text-shadow: 0 1px 0 rgba(0, 0, 0, 0.15); - background-color: darken(white, 10%); - color: @link-hover-color; + text-shadow: @toolbar-btn-hover-text-shadow; + background-color: @toolbar-btn-hover-bg-color; + color: @toolbar-btn-hover-color; } &.active, &:active { - color: white; - background-color: @link-color; - .box-shadow(inset 0 3px 5px rgba(0, 0, 0, 0.225)); + color: @toolbar-btn-active-color; + background-color: @toolbar-btn-active-bg-color; + box-shadow: @toolbar-btn-active-shadow; } .label { top: 4px; @@ -96,7 +96,8 @@ } &.toolbar-header { - box-shadow: 0 0 2px #ccc; + background-color: @toolbar-header-bg-color; + box-shadow: @toolbar-header-shadow; position: absolute; top: 0; left: 0; diff --git a/services/web/public/stylesheets/app/translations.less b/services/web/public/stylesheets/app/translations.less index 3b79b94aff..7ae7532b6c 100644 --- a/services/web/public/stylesheets/app/translations.less +++ b/services/web/public/stylesheets/app/translations.less @@ -3,7 +3,7 @@ padding: (@line-height-computed / 4) (@line-height-computed / 2); background-color: @state-warning-bg; color: #333; - border-bottom: 1px solid @toolbar-border-color; + border-bottom: 1px solid @common-border-color; text-align:center; img { diff --git a/services/web/public/stylesheets/core/_common-variables.less b/services/web/public/stylesheets/core/_common-variables.less index 0b5d3bf744..1809597f37 100644 --- a/services/web/public/stylesheets/core/_common-variables.less +++ b/services/web/public/stylesheets/core/_common-variables.less @@ -794,6 +794,8 @@ @left-menu-animation-duration: 0.35s; @toolbar-border-color: @gray-lighter; +@common-border-color: @gray-lighter; +@editor-border-color: @gray-lighter; @file-tree-droppable-background-color: rgb(252, 231, 199); @editor-dark-background-color: #333; @@ -876,15 +878,26 @@ @folders-untagged-line-height : 1.7; // Progress bars -@progress-border-radius : @border-radius-base; -@progress-border-width : 1px; -@progress-bar-shadow : inset 0 -1px 0 rgba(0,0,0,.15); +@progress-border-radius : @border-radius-base; +@progress-border-width : 1px; +@progress-bar-shadow : inset 0 -1px 0 rgba(0,0,0,.15); // Footer -@footer-link-color : @link-color; -@footer-link-hover-color : @link-hover-color; -@footer-bg-color : transparent; -@footer-padding : 2em; +@footer-link-color : @link-color; +@footer-link-hover-color : @link-hover-color; +@footer-bg-color : transparent; +@footer-padding : 2em; + +// Editor header +@toolbar-header-bg-color : transparent; +@toolbar-header-shadow : 0 0 2px #ccc; +@toolbar-btn-color : @link-color; +@toolbar-btn-hover-color : @link-hover-color; +@toolbar-btn-hover-bg-color : @link-hover-color; +@toolbar-btn-hover-text-shadow : 0 1px 0 rgba(0, 0, 0, 0.15); +@toolbar-btn-active-color : white; +@toolbar-btn-active-bg-color : @link-color; +@toolbar-btn-active-shadow : inset 0 3px 5px rgba(0, 0, 0, 0.225); // Tags @tag-border-radius : 0.25em; diff --git a/services/web/public/stylesheets/core/ol-variables.less b/services/web/public/stylesheets/core/ol-variables.less index 43646ec542..4443de13ae 100644 --- a/services/web/public/stylesheets/core/ol-variables.less +++ b/services/web/public/stylesheets/core/ol-variables.less @@ -156,6 +156,18 @@ @footer-link-hover-color : @ol-dark-green; @footer-padding : 2em 0; +// Editor header +@toolbar-header-bg-color : @ol-blue-gray-6; +@toolbar-header-shadow : none; +@toolbar-btn-color : #FFF; +@toolbar-btn-hover-color : #FFF; +@toolbar-btn-hover-bg-color : @ol-blue-gray-5; +@toolbar-btn-hover-text-shadow : none; +@toolbar-btn-active-color : #FFF; +@toolbar-btn-active-bg-color : @ol-green; +@toolbar-btn-active-shadow : none; +@toolbar-border-color : @ol-blue-gray-5; + //== Colors // //## Gray and brand colors for use across Bootstrap. From 269975560f37a3a9ecff93b778971ef1140ba3ee Mon Sep 17 00:00:00 2001 From: Paulo Reis Date: Mon, 20 Nov 2017 12:24:00 +0000 Subject: [PATCH 02/98] Basic filetree skinning, still WIP. --- .../stylesheets/app/editor/file-tree.less | 28 +++++++++++++++---- .../stylesheets/app/editor/toolbar.less | 2 +- .../stylesheets/core/_common-variables.less | 7 +++++ .../public/stylesheets/core/ol-variables.less | 9 +++++- 4 files changed, 38 insertions(+), 8 deletions(-) diff --git a/services/web/public/stylesheets/app/editor/file-tree.less b/services/web/public/stylesheets/app/editor/file-tree.less index 7847822bf9..ef481ce083 100644 --- a/services/web/public/stylesheets/app/editor/file-tree.less +++ b/services/web/public/stylesheets/app/editor/file-tree.less @@ -7,6 +7,7 @@ aside#file-tree { left: 0; right: 0; overflow-y: auto; + background-color: @file-tree-bg; &.no-toolbar { top: 0; @@ -20,10 +21,13 @@ aside#file-tree { margin: (@line-height-computed / 2); } - ul.file-tree-list { + ul.file-tree-list when (@is-overleaf = false) { font-size: 0.8rem; - margin: 0; padding: (@line-height-computed / 4) 0; + } + + ul.file-tree-list { + margin: 0; overflow-x: hidden; height: 100%; @@ -40,13 +44,13 @@ aside#file-tree { } .entity-name { - color: @gray-darker; + color: @file-tree-item-color; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; &:hover { - background-color: @gray-lightest; + background-color: @file-tree-item-hover-bg; } input { line-height: 1.6; @@ -57,7 +61,7 @@ aside#file-tree { } i.fa { - color: @gray-light; + color: @file-tree-item-color; font-size: 14px; } @@ -111,7 +115,7 @@ aside#file-tree { &:not(.multi-selected) { ul.file-tree-list li.selected { - > .entity > .entity-name { + > .entity > .entity-name when (@is-overleaf = false) { color: @link-color; border-right: 4px solid @link-color; font-weight: bold; @@ -123,6 +127,18 @@ aside#file-tree { display: inline; } } + + > .entity when (@is-overleaf = true) { + background-color: @file-tree-item-selected-bg; + > .entity-name { + font-weight: bold; + padding-right: 32px; + + .entity-menu-toggle { + display: inline; + } + } + } } } diff --git a/services/web/public/stylesheets/app/editor/toolbar.less b/services/web/public/stylesheets/app/editor/toolbar.less index cb20c1354f..fa08126027 100644 --- a/services/web/public/stylesheets/app/editor/toolbar.less +++ b/services/web/public/stylesheets/app/editor/toolbar.less @@ -139,7 +139,7 @@ } &.toolbar-alt { - background-color: #fafafa; + background-color: @toolbar-alt-bg-color; } } diff --git a/services/web/public/stylesheets/core/_common-variables.less b/services/web/public/stylesheets/core/_common-variables.less index 1809597f37..487a45e5de 100644 --- a/services/web/public/stylesheets/core/_common-variables.less +++ b/services/web/public/stylesheets/core/_common-variables.less @@ -898,7 +898,14 @@ @toolbar-btn-active-color : white; @toolbar-btn-active-bg-color : @link-color; @toolbar-btn-active-shadow : inset 0 3px 5px rgba(0, 0, 0, 0.225); +@toolbar-alt-bg-color : #fafafa; +// Editor file-tree +@file-tree-bg : transparent; +@file-tree-item-color : @gray-darker; +@file-tree-item-icon-color : @gray-light; +@file-tree-item-hover-bg : @gray-lightest; +@file-tree-item-selected-bg : transparent; // Tags @tag-border-radius : 0.25em; @tag-bg-color : @label-default-bg; diff --git a/services/web/public/stylesheets/core/ol-variables.less b/services/web/public/stylesheets/core/ol-variables.less index 4443de13ae..eb379e8d64 100644 --- a/services/web/public/stylesheets/core/ol-variables.less +++ b/services/web/public/stylesheets/core/ol-variables.less @@ -8,7 +8,7 @@ @ol-blue-gray-1 : #E4E8EE; @ol-blue-gray-2 : #9DA7B7; @ol-blue-gray-3 : #5D6879; -@ol-blue-gray-4 : #485973; +@ol-blue-gray-4 : #455265; @ol-blue-gray-5 : #2C3645; @ol-blue-gray-6 : #1E2530; @@ -167,7 +167,14 @@ @toolbar-btn-active-bg-color : @ol-green; @toolbar-btn-active-shadow : none; @toolbar-border-color : @ol-blue-gray-5; +@toolbar-alt-bg-color : @ol-blue-gray-5; +// Editor file-tree +@file-tree-bg : @ol-blue-gray-4; +@file-tree-item-color : white; +@file-tree-item-icon-color : @file-tree-item-color +@file-tree-item-hover-bg : @ol-blue-gray-5; +@file-tree-item-selected-bg : @ol-green; //== Colors // //## Gray and brand colors for use across Bootstrap. From 0de57885b19901dc9582fd6d3c2b2f7e4c0bc40c Mon Sep 17 00:00:00 2001 From: Paulo Reis Date: Tue, 21 Nov 2017 11:20:39 +0000 Subject: [PATCH 03/98] Make full-width backgrounds, even with indented filetree items. --- .../stylesheets/app/editor/file-tree.less | 33 ++++++++++++++----- .../stylesheets/app/editor/toolbar.less | 6 ++-- .../stylesheets/core/_common-variables.less | 17 +++++++--- .../public/stylesheets/core/ol-variables.less | 17 +++++++--- 4 files changed, 52 insertions(+), 21 deletions(-) diff --git a/services/web/public/stylesheets/app/editor/file-tree.less b/services/web/public/stylesheets/app/editor/file-tree.less index ef481ce083..9ceb415ec3 100644 --- a/services/web/public/stylesheets/app/editor/file-tree.less +++ b/services/web/public/stylesheets/app/editor/file-tree.less @@ -31,7 +31,11 @@ aside#file-tree { overflow-x: hidden; height: 100%; - ul { + ul when (@is-overleaf = false) { + margin-left: (@line-height-computed / 2); + } + + ul when (@is-overleaf = true) { margin-left: (@line-height-computed / 2); } @@ -52,6 +56,13 @@ aside#file-tree { &:hover { background-color: @file-tree-item-hover-bg; } + &:hover when (@is-overleaf = true) { + // When the entity is a subfolder, the DOM element is "indented" via margin-left. This makes the + // element not fill the entire file-tree width (as it's spaced from the left-hand side via margin) + // and, in consequence, the background gets clipped. The negative (in the x axis) box-shadow is a + // hack to paint the unfilled left-hand region. + box-shadow: -200px 0 0 @file-tree-item-hover-bg; + } input { line-height: 1.6; } @@ -61,12 +72,12 @@ aside#file-tree { } i.fa { - color: @file-tree-item-color; + color: @file-tree-item-icon-color; font-size: 14px; } i.fa-folder-open, i.fa-folder { - color: lighten(desaturate(@link-color, 10%), 5%); + color: @file-tree-item-folder-color; font-size: 14px; } @@ -74,7 +85,7 @@ aside#file-tree { width: 24px; padding: 6px; font-size: 0.7rem; - color: @gray + color: @file-tree-item-toggle-color; } &.multi-selected { @@ -129,8 +140,16 @@ aside#file-tree { } > .entity when (@is-overleaf = true) { - background-color: @file-tree-item-selected-bg; + i.fa { + color: #FFF; + } > .entity-name { + background-color: @file-tree-item-selected-bg; + // When the entity is a subfolder, the DOM element is "indented" via margin-left. This makes the + // element not fill the entire file-tree width (as it's spaced from the left-hand side via margin) + // and, in consequence, the background gets clipped. The negative (in the x axis) box-shadow is a + // hack to paint the unfilled left-hand region. + box-shadow: -200px 0 0 @file-tree-item-selected-bg; font-weight: bold; padding-right: 32px; @@ -147,7 +166,7 @@ aside#file-tree { } } -.editor-dark { +.editor-dark when (@is-overleaf = false) { aside#file-tree { // background-color: lighten(@editor-dark-background-color, 10%); @@ -175,6 +194,4 @@ aside#file-tree { } } } - - } diff --git a/services/web/public/stylesheets/app/editor/toolbar.less b/services/web/public/stylesheets/app/editor/toolbar.less index fa08126027..6eaede0d37 100644 --- a/services/web/public/stylesheets/app/editor/toolbar.less +++ b/services/web/public/stylesheets/app/editor/toolbar.less @@ -22,13 +22,13 @@ .toolbar-left > a:not(.btn), .toolbar-right > a:not(.btn) { display: inline-block; - color: @gray-light; + color: @toolbar-icon-btn-color; padding: 4px 10px 5px; margin: 1px 2px; border-radius: @border-radius-small; &:hover { - text-shadow: 0 1px 0 rgba(0, 0, 0, 0.25); - color: @gray-dark; + text-shadow: @toolbar-icon-btn-hover-shadow; + color: @toolbar-icon-btn-hover-color; text-decoration: none; } &.active, &:active { diff --git a/services/web/public/stylesheets/core/_common-variables.less b/services/web/public/stylesheets/core/_common-variables.less index 487a45e5de..20bf83a34b 100644 --- a/services/web/public/stylesheets/core/_common-variables.less +++ b/services/web/public/stylesheets/core/_common-variables.less @@ -899,13 +899,20 @@ @toolbar-btn-active-bg-color : @link-color; @toolbar-btn-active-shadow : inset 0 3px 5px rgba(0, 0, 0, 0.225); @toolbar-alt-bg-color : #fafafa; +@toolbar-icon-btn-color : @gray-light; +@toolbar-icon-btn-hover-color : @gray-dark; +@toolbar-icon-btn-hover-shadow : 0 1px 0 rgba(0, 0, 0, 0.25); // Editor file-tree -@file-tree-bg : transparent; -@file-tree-item-color : @gray-darker; -@file-tree-item-icon-color : @gray-light; -@file-tree-item-hover-bg : @gray-lightest; -@file-tree-item-selected-bg : transparent; +@file-tree-bg : transparent; +@file-tree-item-color : @gray-darker; +@file-tree-item-toggle-color : @gray; +@file-tree-item-icon-color : @gray-light; +@file-tree-item-folder-color : lighten(desaturate(@link-color, 10%), 5%); +@file-tree-item-hover-bg : @gray-lightest; +@file-tree-item-selected-bg : transparent; +@file-tree-multiselect-bg : lighten(@brand-info, 40%); +@file-tree-multiselect-hover-bg : lighten(@brand-info, 30%); // Tags @tag-border-radius : 0.25em; @tag-bg-color : @label-default-bg; diff --git a/services/web/public/stylesheets/core/ol-variables.less b/services/web/public/stylesheets/core/ol-variables.less index eb379e8d64..30d11f7a83 100644 --- a/services/web/public/stylesheets/core/ol-variables.less +++ b/services/web/public/stylesheets/core/ol-variables.less @@ -168,13 +168,20 @@ @toolbar-btn-active-shadow : none; @toolbar-border-color : @ol-blue-gray-5; @toolbar-alt-bg-color : @ol-blue-gray-5; +@toolbar-icon-btn-color : #FFF; +@toolbar-icon-btn-hover-color : #FFF; +@toolbar-icon-btn-hover-shadow : none; // Editor file-tree -@file-tree-bg : @ol-blue-gray-4; -@file-tree-item-color : white; -@file-tree-item-icon-color : @file-tree-item-color -@file-tree-item-hover-bg : @ol-blue-gray-5; -@file-tree-item-selected-bg : @ol-green; +@file-tree-bg : @ol-blue-gray-4; +@file-tree-item-color : #FFF; +@file-tree-item-toggle-color : #FFF; +@file-tree-item-icon-color : @ol-blue-gray-2; +@file-tree-item-folder-color : @ol-blue-gray-2; +@file-tree-item-hover-bg : @ol-blue-gray-5; +@file-tree-item-selected-bg : @ol-green; +@file-tree-multiselect-bg : lighten(@brand-warning, 10%); +@file-tree-multiselect-hover-bg : lighten(@brand-warning, 20%); //== Colors // //## Gray and brand colors for use across Bootstrap. From 1fd29253e653b11dc1e4792e02fc2bb4a00b013a Mon Sep 17 00:00:00 2001 From: Paulo Reis Date: Tue, 21 Nov 2017 11:45:06 +0000 Subject: [PATCH 04/98] Handle filetree multiselection. --- .../stylesheets/app/editor/file-tree.less | 21 ++++++++++++++++--- .../public/stylesheets/core/ol-variables.less | 4 ++-- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/services/web/public/stylesheets/app/editor/file-tree.less b/services/web/public/stylesheets/app/editor/file-tree.less index 9ceb415ec3..ea485d590e 100644 --- a/services/web/public/stylesheets/app/editor/file-tree.less +++ b/services/web/public/stylesheets/app/editor/file-tree.less @@ -14,6 +14,7 @@ aside#file-tree { } } + // TODO; Consolidate with "Project files" in Overleaf h3 { font-size: 1rem; border-bottom: 1px solid @gray; @@ -67,6 +68,7 @@ aside#file-tree { line-height: 1.6; } &.droppable-hover { + // TODO background-color: fade(@file-tree-droppable-background-color, 60%); } } @@ -89,10 +91,23 @@ aside#file-tree { } &.multi-selected { - > .entity > .entity-name { - background-color: lighten(@brand-info, 40%); + > .entity > .entity-name when (@is-overleaf = false) { + background-color: @file-tree-multiselect-bg; &:hover { - background-color: lighten(@brand-info, 30%); + background-color: @file-tree-multiselect-hover-bg; + } + } + > .entity when (@is-overleaf = true) { + i.fa { + color: #FFF; + } + > .entity-name { + background-color: @file-tree-multiselect-bg; + box-shadow: -200px 0 0 @file-tree-multiselect-bg; + &:hover { + background-color: @file-tree-multiselect-hover-bg; + box-shadow: -200px 0 0 @file-tree-multiselect-hover-bg; + } } } } diff --git a/services/web/public/stylesheets/core/ol-variables.less b/services/web/public/stylesheets/core/ol-variables.less index 30d11f7a83..caee24c450 100644 --- a/services/web/public/stylesheets/core/ol-variables.less +++ b/services/web/public/stylesheets/core/ol-variables.less @@ -180,8 +180,8 @@ @file-tree-item-folder-color : @ol-blue-gray-2; @file-tree-item-hover-bg : @ol-blue-gray-5; @file-tree-item-selected-bg : @ol-green; -@file-tree-multiselect-bg : lighten(@brand-warning, 10%); -@file-tree-multiselect-hover-bg : lighten(@brand-warning, 20%); +@file-tree-multiselect-bg : @ol-blue; +@file-tree-multiselect-hover-bg : @ol-dark-blue; //== Colors // //## Gray and brand colors for use across Bootstrap. From 8758119e62eb7099c60951ce1ed55409c2ea6189 Mon Sep 17 00:00:00 2001 From: Paulo Reis Date: Tue, 21 Nov 2017 16:10:07 +0000 Subject: [PATCH 05/98] Handle multi-selection. --- .../stylesheets/app/editor/file-tree.less | 26 ++++++++++++------- .../public/stylesheets/core/ol-variables.less | 2 +- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/services/web/public/stylesheets/app/editor/file-tree.less b/services/web/public/stylesheets/app/editor/file-tree.less index ea485d590e..a8fdf35ce2 100644 --- a/services/web/public/stylesheets/app/editor/file-tree.less +++ b/services/web/public/stylesheets/app/editor/file-tree.less @@ -68,7 +68,7 @@ aside#file-tree { line-height: 1.6; } &.droppable-hover { - // TODO + // TODO pick right color for the OL Beta theme. background-color: fade(@file-tree-droppable-background-color, 60%); } } @@ -98,10 +98,14 @@ aside#file-tree { } } > .entity when (@is-overleaf = true) { - i.fa { - color: #FFF; - } > .entity-name { + > div > i.fa, + > i.fa, + .entity-menu-toggle i.fa { + color: #FFF; + } + color: #FFF; + font-weight: bold; background-color: @file-tree-multiselect-bg; box-shadow: -200px 0 0 @file-tree-multiselect-bg; &:hover { @@ -155,18 +159,20 @@ aside#file-tree { } > .entity when (@is-overleaf = true) { - i.fa { - color: #FFF; - } > .entity-name { + > div > i.fa, + > i.fa, + .entity-menu-toggle i.fa { + color: #FFF; + } background-color: @file-tree-item-selected-bg; + font-weight: bold; + padding-right: 32px; // When the entity is a subfolder, the DOM element is "indented" via margin-left. This makes the // element not fill the entire file-tree width (as it's spaced from the left-hand side via margin) // and, in consequence, the background gets clipped. The negative (in the x axis) box-shadow is a // hack to paint the unfilled left-hand region. box-shadow: -200px 0 0 @file-tree-item-selected-bg; - font-weight: bold; - padding-right: 32px; .entity-menu-toggle { display: inline; @@ -176,11 +182,13 @@ aside#file-tree { } } + // TODO pick right color for the OL Beta theme. ul.droppable-hover { background-color: fade(@file-tree-droppable-background-color, 60%); } } +// TODO check if the OL Beta theme is OK with darker themes. .editor-dark when (@is-overleaf = false) { aside#file-tree { // background-color: lighten(@editor-dark-background-color, 10%); diff --git a/services/web/public/stylesheets/core/ol-variables.less b/services/web/public/stylesheets/core/ol-variables.less index caee24c450..fd4a41fe97 100644 --- a/services/web/public/stylesheets/core/ol-variables.less +++ b/services/web/public/stylesheets/core/ol-variables.less @@ -175,7 +175,7 @@ // Editor file-tree @file-tree-bg : @ol-blue-gray-4; @file-tree-item-color : #FFF; -@file-tree-item-toggle-color : #FFF; +@file-tree-item-toggle-color : @ol-blue-gray-2; @file-tree-item-icon-color : @ol-blue-gray-2; @file-tree-item-folder-color : @ol-blue-gray-2; @file-tree-item-hover-bg : @ol-blue-gray-5; From 2f03bb60153dfdfb35f707ead70c08cca924a545 Mon Sep 17 00:00:00 2001 From: Nate Stemen Date: Tue, 21 Nov 2017 11:26:44 -0500 Subject: [PATCH 06/98] adding file completer --- .../ide/editor/directives/aceEditor.coffee | 33 ++++++++++--------- .../auto-complete/AutoCompleteManager.coffee | 28 ++++++++++++++-- .../ide/file-tree/FileTreeManager.coffee | 32 +++++++++--------- .../coffee/ide/files/services/files.coffee | 17 ++++++++++ 4 files changed, 76 insertions(+), 34 deletions(-) create mode 100644 services/web/public/coffee/ide/files/services/files.coffee diff --git a/services/web/public/coffee/ide/editor/directives/aceEditor.coffee b/services/web/public/coffee/ide/editor/directives/aceEditor.coffee index 577a3a0e75..d57fec5f3b 100644 --- a/services/web/public/coffee/ide/editor/directives/aceEditor.coffee +++ b/services/web/public/coffee/ide/editor/directives/aceEditor.coffee @@ -13,6 +13,7 @@ define [ "ide/labels/services/labels" "ide/graphics/services/graphics" "ide/preamble/services/preamble" + "ide/files/services/files" ], (App, Ace, SearchBox, ModeList, UndoManager, AutoCompleteManager, SpellCheckManager, HighlightsManager, CursorPositionManager, TrackChangesManager, LabelsManager) -> EditSession = ace.require('ace/edit_session').EditSession ModeList = ace.require('ace/ext/modelist') @@ -35,7 +36,7 @@ define [ url = ace.config._moduleUrl(args...) + "?fingerprint=#{window.aceFingerprint}" return url - App.directive "aceEditor", ($timeout, $compile, $rootScope, event_tracking, localStorage, $cacheFactory, labels, graphics, preamble, $http) -> + App.directive "aceEditor", ($timeout, $compile, $rootScope, event_tracking, localStorage, $cacheFactory, labels, graphics, preamble, files, $http) -> monkeyPatchSearch($rootScope, $compile) return { @@ -103,7 +104,7 @@ define [ cursorPositionManager = new CursorPositionManager(scope, editor, element, localStorage) trackChangesManager = new TrackChangesManager(scope, editor, element) labelsManager = new LabelsManager(scope, editor, element, labels) - autoCompleteManager = new AutoCompleteManager(scope, editor, element, labelsManager, graphics, preamble) + autoCompleteManager = new AutoCompleteManager(scope, editor, element, labelsManager, graphics, preamble, files) # Prevert Ctrl|Cmd-S from triggering save dialog @@ -115,16 +116,16 @@ define [ editor.commands.removeCommand "transposeletters" editor.commands.removeCommand "showSettingsMenu" editor.commands.removeCommand "foldall" - + # For European keyboards, the / is above 7 so needs Shift pressing. - # This comes through as Command-Shift-/ on OS X, which is mapped to + # This comes through as Command-Shift-/ on OS X, which is mapped to # toggleBlockComment. # This doesn't do anything for LaTeX, so remap this to togglecomment to # work for European keyboards as normal. # On Windows, the key combo comes as Ctrl-Shift-7. editor.commands.removeCommand "toggleBlockComment" editor.commands.removeCommand "togglecomment" - + editor.commands.addCommand { name: "togglecomment", bindKey: { win: "Ctrl-/|Ctrl-Shift-7", mac: "Command-/|Command-Shift-/" }, @@ -140,7 +141,7 @@ define [ exec: (editor) -> ace.require("ace/ext/searchbox").Search(editor, true) readOnly: true - + # Bold text on CMD+B editor.commands.addCommand name: "bold", @@ -154,7 +155,7 @@ define [ text = editor.getCopyText() editor.insert("\\textbf{" + text + "}") readOnly: false - + # Italicise text on CMD+I editor.commands.addCommand name: "italics", @@ -171,7 +172,7 @@ define [ scope.$watch "onCtrlEnter", (callback) -> if callback? - editor.commands.addCommand + editor.commands.addCommand name: "compile", bindKey: win: "Ctrl-Enter", mac: "Command-Enter" exec: (editor) => @@ -180,7 +181,7 @@ define [ scope.$watch "onCtrlJ", (callback) -> if callback? - editor.commands.addCommand + editor.commands.addCommand name: "toggle-review-panel", bindKey: win: "Ctrl-J", mac: "Command-J" exec: (editor) => @@ -189,7 +190,7 @@ define [ scope.$watch "onCtrlShiftC", (callback) -> if callback? - editor.commands.addCommand + editor.commands.addCommand name: "add-new-comment", bindKey: win: "Ctrl-Shift-C", mac: "Command-Shift-C" exec: (editor) => @@ -198,7 +199,7 @@ define [ scope.$watch "onCtrlShiftA", (callback) -> if callback? - editor.commands.addCommand + editor.commands.addCommand name: "toggle-track-changes", bindKey: win: "Ctrl-Shift-A", mac: "Command-Shift-A" exec: (editor) => @@ -302,7 +303,7 @@ define [ if updateCount == 100 event_tracking.send 'editor-interaction', 'multi-doc-update' scope.$emit "#{scope.name}:change" - + onScroll = (scrollTop) -> return if !scope.eventsBridge? height = editor.renderer.layerConfig.maxHeight @@ -311,7 +312,7 @@ define [ onScrollbarVisibilityChanged = (event, vRenderer) -> return if !scope.eventsBridge? scope.eventsBridge.emit "aceScrollbarVisibilityChanged", vRenderer.scrollBarV.isVisible, vRenderer.scrollBarV.width - + if scope.eventsBridge? editor.renderer.on "scrollbarVisibilityChanged", onScrollbarVisibilityChanged @@ -401,14 +402,14 @@ define [ session = editor.getSession() session.off "changeScrollTop" - + doc = session.getDocument() doc.off "change", onChange - + editor.renderer.on "changeCharacterSize", () -> scope.$apply () -> scope.rendererData.lineHeight = editor.renderer.lineHeight - + scope.$watch "rendererData", (rendererData) -> if rendererData? rendererData.lineHeight = editor.renderer.lineHeight diff --git a/services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/AutoCompleteManager.coffee b/services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/AutoCompleteManager.coffee index 170d75ce60..0e3ffe44d1 100644 --- a/services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/AutoCompleteManager.coffee +++ b/services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/AutoCompleteManager.coffee @@ -9,7 +9,7 @@ define [ aceSnippetManager = ace.require('ace/snippets').snippetManager class AutoCompleteManager - constructor: (@$scope, @editor, @element, @labelsManager, @graphics, @preamble) -> + constructor: (@$scope, @editor, @element, @labelsManager, @graphics, @preamble, @files) -> @suggestionManager = new CommandManager() @monkeyPatchAutocomplete() @@ -38,6 +38,8 @@ define [ Graphics = @graphics Preamble = @preamble + Files = @files + GraphicsCompleter = getCompletions: (editor, session, pos, prefix, callback) -> context = Helpers.getContext(editor, pos) @@ -63,6 +65,27 @@ define [ } callback null, result + FilesCompleter = + getCompletions: (editor, session, pos, prefix, callback) => + context = Helpers.getContext(editor, pos) + {lineUpToCursor, commandFragment, lineBeyondCursor, needsClosingBrace} = context + if commandFragment + match = commandFragment.match(/^\\(input|include){(\w*)/) + if match + commandName = match[1] + currentArg = match[2] + result = [] + for file in Files.getTeXFiles() + if file.id != @$scope.docId + path = file.path + result.push { + caption: "\\#{commandName}{#{path}#{if needsClosingBrace then '}' else ''}", + value: "\\#{commandName}{#{path}#{if needsClosingBrace then '}' else ''}", + meta: "file", + score: 50 + } + callback null, result + labelsManager = @labelsManager LabelsCompleter = getCompletions: (editor, session, pos, prefix, callback) -> @@ -130,7 +153,8 @@ define [ SnippetCompleter, ReferencesCompleter, LabelsCompleter, - GraphicsCompleter + GraphicsCompleter, + FilesCompleter ] disable: () -> diff --git a/services/web/public/coffee/ide/file-tree/FileTreeManager.coffee b/services/web/public/coffee/ide/file-tree/FileTreeManager.coffee index 0881e75cc8..a4583a0efd 100644 --- a/services/web/public/coffee/ide/file-tree/FileTreeManager.coffee +++ b/services/web/public/coffee/ide/file-tree/FileTreeManager.coffee @@ -13,15 +13,15 @@ define [ @loadRootFolder() @loadDeletedDocs() @$scope.$emit "file-tree:initialized" - + @$scope.$watch "rootFolder", (rootFolder) => if rootFolder? @recalculateDocList() @_bindToSocketEvents() - + @$scope.multiSelectedCount = 0 - + $(document).on "click", => @clearMultiSelectedEntities() $scope.$digest() @@ -46,7 +46,7 @@ define [ type: "file" } @recalculateDocList() - + @ide.socket.on "reciveNewFolder", (parent_folder_id, folder) => parent_folder = @findEntityById(parent_folder_id) or @$scope.rootFolder @$scope.$apply () => @@ -85,25 +85,25 @@ define [ @ide.fileTreeManager.forEachEntity (entity) -> entity.selected = false entity.selected = true - + toggleMultiSelectEntity: (entity) -> entity.multiSelected = !entity.multiSelected @$scope.multiSelectedCount = @multiSelectedCount() - + multiSelectedCount: () -> count = 0 @forEachEntity (entity) -> if entity.multiSelected count++ return count - + getMultiSelectedEntities: () -> entities = [] @forEachEntity (e) -> if e.multiSelected entities.push e return entities - + getMultiSelectedEntityChildNodes: () -> entities = @getMultiSelectedEntities() paths = {} @@ -125,13 +125,13 @@ define [ if !prefixes[path]? child_entities.push entity return child_entities - + clearMultiSelectedEntities: () -> return if @$scope.multiSelectedCount == 0 # Be efficient, this is called a lot on 'click' @forEachEntity (entity) -> entity.multiSelected = false @$scope.multiSelectedCount = 0 - + multiSelectSelectedEntity: () -> @findSelectedEntity()?.multiSelected = true @@ -140,7 +140,7 @@ define [ return false if !folder? entity = @_findEntityByPathInFolder(folder, name) return entity? - + findSelectedEntity: () -> selected = null @forEachEntity (entity) -> @@ -178,7 +178,7 @@ define [ parts = path.split("/") name = parts.shift() rest = parts.join("/") - + if name == "." return @_findEntityByPathInFolder(folder, rest) @@ -268,7 +268,7 @@ define [ type: "doc" deleted: true } - + recalculateDocList: () -> @$scope.docs = [] @forEachEntity (entity, parentFolder, path) => @@ -287,7 +287,7 @@ define [ return -1 else return 1 - + getEntityPath: (entity) -> @_getEntityPathInFolder @$scope.rootFolder, entity @@ -349,7 +349,7 @@ define [ } deleteEntity: (entity, callback = (error) ->) -> - # We'll wait for the socket.io notification to + # We'll wait for the socket.io notification to # delete from scope. return @ide.queuedHttp { method: "DELETE" @@ -367,7 +367,7 @@ define [ folder_id: parent_folder.id _csrf: window.csrfToken } - + _isChildFolder: (parent_folder, child_folder) -> parent_path = @getEntityPath(parent_folder) or "" # null if root folder child_path = @getEntityPath(child_folder) or "" # null if root folder diff --git a/services/web/public/coffee/ide/files/services/files.coffee b/services/web/public/coffee/ide/files/services/files.coffee new file mode 100644 index 0000000000..a6b64f9f50 --- /dev/null +++ b/services/web/public/coffee/ide/files/services/files.coffee @@ -0,0 +1,17 @@ +define [ + "base" +], (App) -> + + App.factory 'files', (ide) -> + + Files = + getTeXFiles: () -> + texFiles = [] + ide.fileTreeManager.forEachEntity (entity, folder, path) -> + if entity.type == 'doc' && entity?.name?.match?(/.*\.(tex|sty|cls|dtx|ltx|def)/) + cloned = _.clone(entity) + cloned.path = path + texFiles.push cloned + return texFiles + + return Files From ead190bf23f7fad58659e9d7495683e24099e7e5 Mon Sep 17 00:00:00 2001 From: Paulo Reis Date: Wed, 22 Nov 2017 11:54:57 +0000 Subject: [PATCH 07/98] Style drag and drop, also renaming and inputs. --- .../stylesheets/app/editor/file-tree.less | 21 +++++++++++++------ .../stylesheets/core/_common-variables.less | 3 ++- .../public/stylesheets/core/ol-variables.less | 20 ++++++++++-------- 3 files changed, 28 insertions(+), 16 deletions(-) diff --git a/services/web/public/stylesheets/app/editor/file-tree.less b/services/web/public/stylesheets/app/editor/file-tree.less index a8fdf35ce2..51c0197e59 100644 --- a/services/web/public/stylesheets/app/editor/file-tree.less +++ b/services/web/public/stylesheets/app/editor/file-tree.less @@ -67,10 +67,15 @@ aside#file-tree { input { line-height: 1.6; } - &.droppable-hover { - // TODO pick right color for the OL Beta theme. - background-color: fade(@file-tree-droppable-background-color, 60%); + &.droppable-hover when (@is-overleaf = false) { + background-color: fade(@file-tree-droppable-bg-color, 60%); } + + &.droppable-hover when (@is-overleaf = true) { + background-color: @file-tree-droppable-bg-color; + box-shadow: -200px 0 0 @file-tree-droppable-bg-color; + } + } i.fa { @@ -130,6 +135,7 @@ aside#file-tree { top: 1px; left: 44px; right: 32px; + color: @file-tree-item-input-color; input { width: 100%; } @@ -182,9 +188,12 @@ aside#file-tree { } } - // TODO pick right color for the OL Beta theme. - ul.droppable-hover { - background-color: fade(@file-tree-droppable-background-color, 60%); + ul.droppable-hover when (@is-overleaf = false) { + background-color: fade(@file-tree-droppable-bg-color, 60%); + } + ul.droppable-hover when (@is-overleaf = true) { + background-color: @file-tree-droppable-bg-color; + box-shadow: -200px 0 0 @file-tree-droppable-bg-color; } } diff --git a/services/web/public/stylesheets/core/_common-variables.less b/services/web/public/stylesheets/core/_common-variables.less index 20bf83a34b..6e22383496 100644 --- a/services/web/public/stylesheets/core/_common-variables.less +++ b/services/web/public/stylesheets/core/_common-variables.less @@ -796,7 +796,7 @@ @toolbar-border-color: @gray-lighter; @common-border-color: @gray-lighter; @editor-border-color: @gray-lighter; -@file-tree-droppable-background-color: rgb(252, 231, 199); +@file-tree-droppable-bg-color: rgb(252, 231, 199); @editor-dark-background-color: #333; @editor-dark-toolbar-border-color: #222; @@ -908,6 +908,7 @@ @file-tree-item-color : @gray-darker; @file-tree-item-toggle-color : @gray; @file-tree-item-icon-color : @gray-light; +@file-tree-item-input-color : inherit; @file-tree-item-folder-color : lighten(desaturate(@link-color, 10%), 5%); @file-tree-item-hover-bg : @gray-lightest; @file-tree-item-selected-bg : transparent; diff --git a/services/web/public/stylesheets/core/ol-variables.less b/services/web/public/stylesheets/core/ol-variables.less index fd4a41fe97..325fbd28d5 100644 --- a/services/web/public/stylesheets/core/ol-variables.less +++ b/services/web/public/stylesheets/core/ol-variables.less @@ -173,15 +173,17 @@ @toolbar-icon-btn-hover-shadow : none; // Editor file-tree -@file-tree-bg : @ol-blue-gray-4; -@file-tree-item-color : #FFF; -@file-tree-item-toggle-color : @ol-blue-gray-2; -@file-tree-item-icon-color : @ol-blue-gray-2; -@file-tree-item-folder-color : @ol-blue-gray-2; -@file-tree-item-hover-bg : @ol-blue-gray-5; -@file-tree-item-selected-bg : @ol-green; -@file-tree-multiselect-bg : @ol-blue; -@file-tree-multiselect-hover-bg : @ol-dark-blue; +@file-tree-bg : @ol-blue-gray-4; +@file-tree-item-color : #FFF; +@file-tree-item-input-color : @ol-blue-gray-5; +@file-tree-item-toggle-color : @ol-blue-gray-2; +@file-tree-item-icon-color : @ol-blue-gray-2; +@file-tree-item-folder-color : @ol-blue-gray-2; +@file-tree-item-hover-bg : @ol-blue-gray-5; +@file-tree-item-selected-bg : @ol-green; +@file-tree-multiselect-bg : @ol-blue; +@file-tree-multiselect-hover-bg : @ol-dark-blue; +@file-tree-droppable-bg-color : tint(@ol-green, 5%); //== Colors // //## Gray and brand colors for use across Bootstrap. From 8cf82178e12170d1c0125d0307acaf1cfb099301 Mon Sep 17 00:00:00 2001 From: Paulo Reis Date: Wed, 22 Nov 2017 12:10:33 +0000 Subject: [PATCH 08/98] Only highlight hovered folder when dragging. --- services/web/public/stylesheets/app/editor/file-tree.less | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/web/public/stylesheets/app/editor/file-tree.less b/services/web/public/stylesheets/app/editor/file-tree.less index 51c0197e59..8d69d8f76b 100644 --- a/services/web/public/stylesheets/app/editor/file-tree.less +++ b/services/web/public/stylesheets/app/editor/file-tree.less @@ -188,10 +188,10 @@ aside#file-tree { } } - ul.droppable-hover when (@is-overleaf = false) { + ul.droppable-hover.file-tree-list when (@is-overleaf = false) { background-color: fade(@file-tree-droppable-bg-color, 60%); } - ul.droppable-hover when (@is-overleaf = true) { + ul.droppable-hover.file-tree-list when (@is-overleaf = true) { background-color: @file-tree-droppable-bg-color; box-shadow: -200px 0 0 @file-tree-droppable-bg-color; } From 9e109ccaaa2583d711c91c424124b8f0cf780ddd Mon Sep 17 00:00:00 2001 From: Paulo Reis Date: Wed, 22 Nov 2017 12:14:02 +0000 Subject: [PATCH 09/98] Fix wrong hover color in SL theme. --- services/web/public/stylesheets/core/_common-variables.less | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/web/public/stylesheets/core/_common-variables.less b/services/web/public/stylesheets/core/_common-variables.less index 6e22383496..8739ab48be 100644 --- a/services/web/public/stylesheets/core/_common-variables.less +++ b/services/web/public/stylesheets/core/_common-variables.less @@ -893,7 +893,7 @@ @toolbar-header-shadow : 0 0 2px #ccc; @toolbar-btn-color : @link-color; @toolbar-btn-hover-color : @link-hover-color; -@toolbar-btn-hover-bg-color : @link-hover-color; +@toolbar-btn-hover-bg-color : darken(white, 10%); @toolbar-btn-hover-text-shadow : 0 1px 0 rgba(0, 0, 0, 0.15); @toolbar-btn-active-color : white; @toolbar-btn-active-bg-color : @link-color; From ac9e27713d023e2284615e204f0a5f117cc3dead Mon Sep 17 00:00:00 2001 From: Nate Stemen Date: Wed, 22 Nov 2017 11:25:02 -0500 Subject: [PATCH 10/98] changing allowed file types --- services/web/public/coffee/ide/files/services/files.coffee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/web/public/coffee/ide/files/services/files.coffee b/services/web/public/coffee/ide/files/services/files.coffee index a6b64f9f50..58e0598a3f 100644 --- a/services/web/public/coffee/ide/files/services/files.coffee +++ b/services/web/public/coffee/ide/files/services/files.coffee @@ -8,7 +8,7 @@ define [ getTeXFiles: () -> texFiles = [] ide.fileTreeManager.forEachEntity (entity, folder, path) -> - if entity.type == 'doc' && entity?.name?.match?(/.*\.(tex|sty|cls|dtx|ltx|def)/) + if entity.type == 'doc' && entity?.name?.match?(/.*\.(tex|txt|md)/) cloned = _.clone(entity) cloned.path = path texFiles.push cloned From f55ffff9609afaf4a41a9c096183368698a6db15 Mon Sep 17 00:00:00 2001 From: Paulo Reis Date: Thu, 23 Nov 2017 15:21:06 +0000 Subject: [PATCH 11/98] Refactor toolbar variants as mixins. --- .../app/views/project/editor/file-tree.pug | 2 +- services/web/app/views/project/editor/pdf.pug | 2 +- .../stylesheets/app/editor/file-tree.less | 6 +- .../public/stylesheets/app/editor/pdf.less | 9 +++ .../stylesheets/app/editor/toolbar.less | 68 +++++++++++-------- 5 files changed, 56 insertions(+), 31 deletions(-) diff --git a/services/web/app/views/project/editor/file-tree.pug b/services/web/app/views/project/editor/file-tree.pug index 03c2bd79b7..1e5eaa73aa 100644 --- a/services/web/app/views/project/editor/file-tree.pug +++ b/services/web/app/views/project/editor/file-tree.pug @@ -1,5 +1,5 @@ aside#file-tree(ng-controller="FileTreeController", ng-class="{ 'multi-selected': multiSelectedCount > 0 }").full-size - .toolbar.toolbar-small.toolbar-alt(ng-if="permissions.write") + .toolbar.toolbar-filetree(ng-if="permissions.write") a( href, ng-click="openNewDocModal()", diff --git a/services/web/app/views/project/editor/pdf.pug b/services/web/app/views/project/editor/pdf.pug index eb2b4bfefd..057cacb222 100644 --- a/services/web/app/views/project/editor/pdf.pug +++ b/services/web/app/views/project/editor/pdf.pug @@ -1,5 +1,5 @@ div.full-size.pdf(ng-controller="PdfController") - .toolbar.toolbar-tall + .toolbar.toolbar-pdf .btn-group#recompile( dropdown, tooltip-html="'"+translate('recompile_pdf')+" ({{modifierKey}} + Enter)'" diff --git a/services/web/public/stylesheets/app/editor/file-tree.less b/services/web/public/stylesheets/app/editor/file-tree.less index 8d69d8f76b..a79775a057 100644 --- a/services/web/public/stylesheets/app/editor/file-tree.less +++ b/services/web/public/stylesheets/app/editor/file-tree.less @@ -1,5 +1,9 @@ +.toolbar.toolbar-filetree { + .toolbar-small-mixin; + .toolbar-alt-mixin; +} -aside#file-tree { +#file-tree { .file-tree-inner { position: absolute; top: 32px; diff --git a/services/web/public/stylesheets/app/editor/pdf.less b/services/web/public/stylesheets/app/editor/pdf.less index 0c98050242..0351a64e03 100644 --- a/services/web/public/stylesheets/app/editor/pdf.less +++ b/services/web/public/stylesheets/app/editor/pdf.less @@ -1,3 +1,12 @@ +.toolbar.toolbar-pdf when (@is-overleaf = true) { + .toolbar-small-mixin; + .toolbar-alt-mixin; +} + +.toolbar.toolbar-pdf when (@is-overleaf = false) { + .toolbar-tall-mixin; +} + .pdf-viewer, .pdf-logs, .pdf-errors, .pdf-uncompiled { .full-size; top: 58px; diff --git a/services/web/public/stylesheets/app/editor/toolbar.less b/services/web/public/stylesheets/app/editor/toolbar.less index 6eaede0d37..bca14e15da 100644 --- a/services/web/public/stylesheets/app/editor/toolbar.less +++ b/services/web/public/stylesheets/app/editor/toolbar.less @@ -106,43 +106,55 @@ } &.toolbar-small { - height: 32px; - > a, .toolbar-right > a { - padding: 2px 4px 1px 4px; - margin: 0; - margin-top: 2px; - } - > a { - margin-left: 2px; - } - .toolbar-right > a { - margin-left: 0; - margin-right: 2px; - } + .toolbar-small-mixin; } &.toolbar-tall { - height: 58px; - padding-top: 10px; - > a, .toolbar-right > a { - padding: 4px 10px 5px; - } - > a.btn, .toolbar-right > a.btn { - margin: 0 (@line-height-computed / 2); - } - .btn-group { - margin: 0 (@line-height-computed / 2); - > .btn-group { - margin: 0; - } - } + .toolbar-small-mixin; } &.toolbar-alt { - background-color: @toolbar-alt-bg-color; + .toolbar-alt-mixin; } } + +.toolbar-small-mixin() { + height: 32px; + > a, .toolbar-right > a { + padding: 2px 4px 1px 4px; + margin: 0; + margin-top: 2px; + } + > a { + margin-left: 2px; + } + .toolbar-right > a { + margin-left: 0; + margin-right: 2px; + } +} + +.toolbar-tall-mixin() { + height: 58px; + padding-top: 10px; + > a, .toolbar-right > a { + padding: 4px 10px 5px; + } + > a.btn, .toolbar-right > a.btn { + margin: 0 (@line-height-computed / 2); + } + .btn-group { + margin: 0 (@line-height-computed / 2); + > .btn-group { + margin: 0; + } + } +} +.toolbar-alt-mixin() { + background-color: @toolbar-alt-bg-color; +} + .toolbar-label { display: none; margin: 0 4px; From 2464bec280ca1a7a307b6710261a0228c50f28f0 Mon Sep 17 00:00:00 2001 From: Paulo Reis Date: Thu, 23 Nov 2017 16:42:38 +0000 Subject: [PATCH 12/98] Style the recompile button; use flexbox for the toolbar. --- services/web/app/views/project/editor/pdf.pug | 6 +-- .../stylesheets/app/editor/file-tree.less | 9 +++-- .../public/stylesheets/app/editor/pdf.less | 24 ++++++++++-- .../stylesheets/app/editor/toolbar.less | 37 +++++-------------- .../stylesheets/core/_common-variables.less | 1 + .../public/stylesheets/core/ol-variables.less | 2 +- 6 files changed, 40 insertions(+), 39 deletions(-) diff --git a/services/web/app/views/project/editor/pdf.pug b/services/web/app/views/project/editor/pdf.pug index 057cacb222..5bb2d5173a 100644 --- a/services/web/app/views/project/editor/pdf.pug +++ b/services/web/app/views/project/editor/pdf.pug @@ -1,6 +1,6 @@ div.full-size.pdf(ng-controller="PdfController") .toolbar.toolbar-pdf - .btn-group#recompile( + .btn-group.btn-recompile-group#recompile( dropdown, tooltip-html="'"+translate('recompile_pdf')+" ({{modifierKey}} + Enter)'" tooltip-class="keyboard-tooltip" @@ -8,7 +8,7 @@ div.full-size.pdf(ng-controller="PdfController") tooltip-append-to-body="true" tooltip-placement="bottom" ) - a.btn.btn-info( + a.btn.btn-recompile( href, ng-disabled="pdf.compiling", ng-click="recompile()" @@ -19,7 +19,7 @@ div.full-size.pdf(ng-controller="PdfController") |    span(ng-show="!pdf.compiling") #{translate("recompile")} span(ng-show="pdf.compiling") #{translate("compiling")}... - a.btn.btn-info.dropdown-toggle( + a.btn.btn-recompile.dropdown-toggle( href, ng-disabled="pdf.compiling", dropdown-toggle diff --git a/services/web/public/stylesheets/app/editor/file-tree.less b/services/web/public/stylesheets/app/editor/file-tree.less index a79775a057..b27a492406 100644 --- a/services/web/public/stylesheets/app/editor/file-tree.less +++ b/services/web/public/stylesheets/app/editor/file-tree.less @@ -1,9 +1,10 @@ -.toolbar.toolbar-filetree { - .toolbar-small-mixin; - .toolbar-alt-mixin; -} #file-tree { + .toolbar.toolbar-filetree { + .toolbar-small-mixin; + .toolbar-alt-mixin; + } + .file-tree-inner { position: absolute; top: 32px; diff --git a/services/web/public/stylesheets/app/editor/pdf.less b/services/web/public/stylesheets/app/editor/pdf.less index 0351a64e03..edf0846bee 100644 --- a/services/web/public/stylesheets/app/editor/pdf.less +++ b/services/web/public/stylesheets/app/editor/pdf.less @@ -1,9 +1,9 @@ -.toolbar.toolbar-pdf when (@is-overleaf = true) { +.pdf .toolbar.toolbar-pdf when (@is-overleaf = true) { .toolbar-small-mixin; .toolbar-alt-mixin; } -.toolbar.toolbar-pdf when (@is-overleaf = false) { +.pdf .toolbar.toolbar-pdf when (@is-overleaf = false) { .toolbar-tall-mixin; } @@ -22,6 +22,25 @@ } } +.btn-recompile-group when (@is-overleaf = true) { + height: 100%; +} + +.btn-recompile when (@is-overleaf = true) { + height: 100%; + .btn-primary; + padding-top: 3px; + padding-bottom: 3px; + &:first-child { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } +} + +.btn-recompile when (@is-overleaf = false) { + .btn-info; +} + .pdf-viewer { iframe { width: 100%; @@ -91,7 +110,6 @@ .pdf .toolbar { .toolbar-right { - margin-right: @line-height-computed / 2; a { &:hover { i { diff --git a/services/web/public/stylesheets/app/editor/toolbar.less b/services/web/public/stylesheets/app/editor/toolbar.less index bca14e15da..8883b3bb2c 100644 --- a/services/web/public/stylesheets/app/editor/toolbar.less +++ b/services/web/public/stylesheets/app/editor/toolbar.less @@ -1,6 +1,8 @@ .toolbar { + display: flex; + align-items: center; height: 40px; - border-bottom: 1px solid @toolbar-border-color; + border-bottom: @toolbar-border-bottom; > a, .toolbar-right > a { position: relative; @@ -23,8 +25,8 @@ .toolbar-right > a:not(.btn) { display: inline-block; color: @toolbar-icon-btn-color; - padding: 4px 10px 5px; - margin: 1px 2px; + padding: 0 5px; + // margin: 1px 2px; border-radius: @border-radius-small; &:hover { text-shadow: @toolbar-icon-btn-hover-shadow; @@ -72,12 +74,15 @@ } .toolbar-left { + display: flex; float: left; text-align: center; } .toolbar-right { - float: right; + display: flex; + flex-grow: 1; + justify-content: flex-end; .btn-full-height { border-right: 0; border-left: 1px solid @toolbar-border-color; @@ -121,35 +126,11 @@ .toolbar-small-mixin() { height: 32px; - > a, .toolbar-right > a { - padding: 2px 4px 1px 4px; - margin: 0; - margin-top: 2px; - } - > a { - margin-left: 2px; - } - .toolbar-right > a { - margin-left: 0; - margin-right: 2px; - } } .toolbar-tall-mixin() { height: 58px; padding-top: 10px; - > a, .toolbar-right > a { - padding: 4px 10px 5px; - } - > a.btn, .toolbar-right > a.btn { - margin: 0 (@line-height-computed / 2); - } - .btn-group { - margin: 0 (@line-height-computed / 2); - > .btn-group { - margin: 0; - } - } } .toolbar-alt-mixin() { background-color: @toolbar-alt-bg-color; diff --git a/services/web/public/stylesheets/core/_common-variables.less b/services/web/public/stylesheets/core/_common-variables.less index 8739ab48be..47030d08ff 100644 --- a/services/web/public/stylesheets/core/_common-variables.less +++ b/services/web/public/stylesheets/core/_common-variables.less @@ -902,6 +902,7 @@ @toolbar-icon-btn-color : @gray-light; @toolbar-icon-btn-hover-color : @gray-dark; @toolbar-icon-btn-hover-shadow : 0 1px 0 rgba(0, 0, 0, 0.25); +@toolbar-border-bottom : 1px solid @toolbar-border-color; // Editor file-tree @file-tree-bg : transparent; diff --git a/services/web/public/stylesheets/core/ol-variables.less b/services/web/public/stylesheets/core/ol-variables.less index 325fbd28d5..d6d925a8ca 100644 --- a/services/web/public/stylesheets/core/ol-variables.less +++ b/services/web/public/stylesheets/core/ol-variables.less @@ -171,7 +171,7 @@ @toolbar-icon-btn-color : #FFF; @toolbar-icon-btn-hover-color : #FFF; @toolbar-icon-btn-hover-shadow : none; - +@toolbar-border-bottom : 0; // Editor file-tree @file-tree-bg : @ol-blue-gray-4; @file-tree-item-color : #FFF; From f4f2fa4e19b3f35ab738ee1030f17028a1438e9c Mon Sep 17 00:00:00 2001 From: Paulo Reis Date: Fri, 24 Nov 2017 15:33:48 +0000 Subject: [PATCH 13/98] Use Overleaf logo as the main menu icon. --- .../web/app/views/project/editor/header.pug | 2 +- .../public/img/ol-brand/overleaf-o-white.svg | 18 ++++++++++++++++++ .../web/public/stylesheets/app/editor.less | 10 ++++++++++ .../public/stylesheets/core/ol-variables.less | 2 +- 4 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 services/web/public/img/ol-brand/overleaf-o-white.svg diff --git a/services/web/app/views/project/editor/header.pug b/services/web/app/views/project/editor/header.pug index 8bd37f707c..4eaae86373 100644 --- a/services/web/app/views/project/editor/header.pug +++ b/services/web/app/views/project/editor/header.pug @@ -7,7 +7,7 @@ header.toolbar.toolbar-header.toolbar-with-labels( href, ng-click="ui.leftMenuShown = true;", ) - i.fa.fa-fw.fa-bars + i.fa.fa-fw.fa-bars.editor-menu-icon p.toolbar-label #{translate("menu")} a( href="/project" diff --git a/services/web/public/img/ol-brand/overleaf-o-white.svg b/services/web/public/img/ol-brand/overleaf-o-white.svg new file mode 100644 index 0000000000..be896abcc3 --- /dev/null +++ b/services/web/public/img/ol-brand/overleaf-o-white.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + diff --git a/services/web/public/stylesheets/app/editor.less b/services/web/public/stylesheets/app/editor.less index 017ea2ecc8..46b85fde26 100644 --- a/services/web/public/stylesheets/app/editor.less +++ b/services/web/public/stylesheets/app/editor.less @@ -26,6 +26,16 @@ } } +.editor-menu-icon when (@is-overleaf = true) { + &.fa::before { + // Disable the font-awesome icon when in Overleaf by replacing it with a + // non-breakable space instead (otherwise the browser would render a + // zero-width element). + content: "\00a0"; + } + background: url(/img/ol-brand/overleaf-o-white.svg) center / contain no-repeat; +} + .full-size { position: absolute; top: 0; diff --git a/services/web/public/stylesheets/core/ol-variables.less b/services/web/public/stylesheets/core/ol-variables.less index d6d925a8ca..4911a9d2c4 100644 --- a/services/web/public/stylesheets/core/ol-variables.less +++ b/services/web/public/stylesheets/core/ol-variables.less @@ -171,7 +171,7 @@ @toolbar-icon-btn-color : #FFF; @toolbar-icon-btn-hover-color : #FFF; @toolbar-icon-btn-hover-shadow : none; -@toolbar-border-bottom : 0; +@toolbar-border-bottom : 1px solid @toolbar-border-color; // Editor file-tree @file-tree-bg : @ol-blue-gray-4; @file-tree-item-color : #FFF; From 39602d78ede43612e196be1cac6dedbe635074ab Mon Sep 17 00:00:00 2001 From: Paulo Reis Date: Fri, 24 Nov 2017 15:36:23 +0000 Subject: [PATCH 14/98] Make sure all toolbar icons and buttons are vertically centered. --- services/web/public/stylesheets/app/editor/toolbar.less | 2 ++ 1 file changed, 2 insertions(+) diff --git a/services/web/public/stylesheets/app/editor/toolbar.less b/services/web/public/stylesheets/app/editor/toolbar.less index 8883b3bb2c..4ea41c0b95 100644 --- a/services/web/public/stylesheets/app/editor/toolbar.less +++ b/services/web/public/stylesheets/app/editor/toolbar.less @@ -77,10 +77,12 @@ display: flex; float: left; text-align: center; + align-items: center; } .toolbar-right { display: flex; + align-items: center; flex-grow: 1; justify-content: flex-end; .btn-full-height { From 926fd69bb536d85360721943d675bd2cd8a47c57 Mon Sep 17 00:00:00 2001 From: Paulo Reis Date: Fri, 24 Nov 2017 15:53:49 +0000 Subject: [PATCH 15/98] Use resizer icons in Overleaf, to switch the PDF layout. --- services/web/app/views/project/editor/pdf.pug | 2 ++ .../public/stylesheets/app/editor/pdf.less | 28 +++++++++++++++++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/services/web/app/views/project/editor/pdf.pug b/services/web/app/views/project/editor/pdf.pug index 5bb2d5173a..a3b3a2e2f9 100644 --- a/services/web/app/views/project/editor/pdf.pug +++ b/services/web/app/views/project/editor/pdf.pug @@ -102,6 +102,7 @@ div.full-size.pdf(ng-controller="PdfController") tooltip-placement="bottom" tooltip-append-to-body="true" ) + i.fa.fa-expand i.full-screen a( href, @@ -111,6 +112,7 @@ div.full-size.pdf(ng-controller="PdfController") tooltip-placement="bottom" tooltip-append-to-body="true" ) + i.fa.fa-compress i.split-screen i.split-screen // end of toolbar diff --git a/services/web/public/stylesheets/app/editor/pdf.less b/services/web/public/stylesheets/app/editor/pdf.less index edf0846bee..58d8a9427b 100644 --- a/services/web/public/stylesheets/app/editor/pdf.less +++ b/services/web/public/stylesheets/app/editor/pdf.less @@ -41,6 +41,17 @@ .btn-info; } +.btn-split-screen when (@is-overleaf = false) { + .fa { + display: none; + } +} +.btn-split-screen when (@is-overleaf = true) { + .fa { + display: none; + } +} + .pdf-viewer { iframe { width: 100%; @@ -112,28 +123,41 @@ .toolbar-right { a { &:hover { - i { + i when (@is-overleaf = false) { box-shadow: 1px 1px 0 rgba(0, 0, 0, 0.25); border-color: @gray-dark; } } - i { + i when (@is-overleaf = false) { display: inline-block; width: 16px; height: 16px; border: 1px solid @gray-light; margin-top: 5px; } + i.full-screen { border-top-width: 3px; border-radius: 2px; } + + i.full-screen when (@is-overleaf = true) { + display: none; + } i.split-screen { width: 7px; border-top-width: 3px; border-radius: 2px; margin-left: 2px; } + + i.split-screen when (@is-overleaf = true) { + display: none; + } + + i.fa when (@is-overleaf = false) { + display: none; + } } } } From 860399d9de5b9dc4937f698de38a445d42631cc4 Mon Sep 17 00:00:00 2001 From: Paulo Reis Date: Fri, 24 Nov 2017 16:03:30 +0000 Subject: [PATCH 16/98] Adjust toolbar paddings; adjust Overleaf menu icon width. --- services/web/public/stylesheets/app/editor.less | 16 ++++++++++------ .../public/stylesheets/app/editor/file-tree.less | 1 + .../web/public/stylesheets/app/editor/pdf.less | 4 +++- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/services/web/public/stylesheets/app/editor.less b/services/web/public/stylesheets/app/editor.less index 46b85fde26..e37d829710 100644 --- a/services/web/public/stylesheets/app/editor.less +++ b/services/web/public/stylesheets/app/editor.less @@ -27,13 +27,17 @@ } .editor-menu-icon when (@is-overleaf = true) { - &.fa::before { - // Disable the font-awesome icon when in Overleaf by replacing it with a - // non-breakable space instead (otherwise the browser would render a - // zero-width element). - content: "\00a0"; + &.fa { + width: 1em; + background: url(/img/ol-brand/overleaf-o-white.svg) center / contain no-repeat; + + &::before { + // Disable the font-awesome icon when in Overleaf by replacing it with a + // non-breakable space instead (otherwise the browser would render a + // zero-width element). + content: "\00a0"; + } } - background: url(/img/ol-brand/overleaf-o-white.svg) center / contain no-repeat; } .full-size { diff --git a/services/web/public/stylesheets/app/editor/file-tree.less b/services/web/public/stylesheets/app/editor/file-tree.less index b27a492406..328d484d06 100644 --- a/services/web/public/stylesheets/app/editor/file-tree.less +++ b/services/web/public/stylesheets/app/editor/file-tree.less @@ -3,6 +3,7 @@ .toolbar.toolbar-filetree { .toolbar-small-mixin; .toolbar-alt-mixin; + padding: 0 5px; } .file-tree-inner { diff --git a/services/web/public/stylesheets/app/editor/pdf.less b/services/web/public/stylesheets/app/editor/pdf.less index 58d8a9427b..843617387b 100644 --- a/services/web/public/stylesheets/app/editor/pdf.less +++ b/services/web/public/stylesheets/app/editor/pdf.less @@ -1,6 +1,8 @@ .pdf .toolbar.toolbar-pdf when (@is-overleaf = true) { .toolbar-small-mixin; .toolbar-alt-mixin; + border-bottom: 0; + padding-right: 5px; } .pdf .toolbar.toolbar-pdf when (@is-overleaf = false) { @@ -23,7 +25,7 @@ } .btn-recompile-group when (@is-overleaf = true) { - height: 100%; + margin-right: 5px; } .btn-recompile when (@is-overleaf = true) { From 1177f3412584d7fd003e9703ec99ff68d6e40c45 Mon Sep 17 00:00:00 2001 From: Paulo Reis Date: Fri, 24 Nov 2017 16:43:02 +0000 Subject: [PATCH 17/98] Adjustments to keep the current SL look and feel. --- services/web/public/stylesheets/app/editor/pdf.less | 7 +++++++ services/web/public/stylesheets/app/editor/toolbar.less | 1 - 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/services/web/public/stylesheets/app/editor/pdf.less b/services/web/public/stylesheets/app/editor/pdf.less index 843617387b..a873cd5e8f 100644 --- a/services/web/public/stylesheets/app/editor/pdf.less +++ b/services/web/public/stylesheets/app/editor/pdf.less @@ -7,6 +7,7 @@ .pdf .toolbar.toolbar-pdf when (@is-overleaf = false) { .toolbar-tall-mixin; + padding: 0 (@line-height-computed / 2); } .pdf-viewer, .pdf-logs, .pdf-errors, .pdf-uncompiled { @@ -25,9 +26,15 @@ } .btn-recompile-group when (@is-overleaf = true) { + align-self: stretch; margin-right: 5px; } +.btn-recompile-group when (@is-overleaf = false) { + margin-right: (@line-height-computed / 2); +} + + .btn-recompile when (@is-overleaf = true) { height: 100%; .btn-primary; diff --git a/services/web/public/stylesheets/app/editor/toolbar.less b/services/web/public/stylesheets/app/editor/toolbar.less index 4ea41c0b95..ca9464b30a 100644 --- a/services/web/public/stylesheets/app/editor/toolbar.less +++ b/services/web/public/stylesheets/app/editor/toolbar.less @@ -26,7 +26,6 @@ display: inline-block; color: @toolbar-icon-btn-color; padding: 0 5px; - // margin: 1px 2px; border-radius: @border-radius-small; &:hover { text-shadow: @toolbar-icon-btn-hover-shadow; From fbf8cc2d0303bf46dc664a2ec8f9fd4cf411d4f9 Mon Sep 17 00:00:00 2001 From: James Allen Date: Thu, 23 Nov 2017 16:01:32 +0000 Subject: [PATCH 18/98] Run acceptance tests via docker compose --- services/web/Jenkinsfile | 11 ++-- services/web/config/settings.defaults.coffee | 15 +++-- services/web/docker-compose.ci.yml | 12 ++++ services/web/docker-compose.yml | 42 ++++++++++++ services/web/makefile | 64 +++++++++++++++++++ services/web/package.json | 11 ++++ .../coffee/helpers/MockDocUpdaterApi.coffee | 2 +- .../coffee/helpers/MockDocstoreApi.coffee | 2 +- .../acceptance/coffee/helpers/redis.coffee | 1 - .../acceptance/coffee/helpers/request.coffee | 2 +- 10 files changed, 144 insertions(+), 18 deletions(-) create mode 100644 services/web/docker-compose.ci.yml create mode 100644 services/web/docker-compose.yml create mode 100644 services/web/makefile diff --git a/services/web/Jenkinsfile b/services/web/Jenkinsfile index 33aa807fdd..85ecafef58 100644 --- a/services/web/Jenkinsfile +++ b/services/web/Jenkinsfile @@ -117,19 +117,16 @@ pipeline { } } steps { + sh 'make install' + sh 'make test_unit MOCHA_ARGS="--reporter=tap"' sh 'env NODE_ENV=development ./node_modules/.bin/grunt mochaTest:unit --reporter=tap' } } stage('Acceptance Tests') { steps { - // This tagged relase of the acceptance test runner is a temporary fix - // to get the acceptance tests working before we move to a - // docker-compose workflow. See: - // https://github.com/sharelatex/web-sharelatex-internal/pull/148 - - sh 'docker pull sharelatex/sl-acceptance-test-runner:node-6.9-mongo-3.4' - sh 'docker run --rm -v $(pwd):/app --env SHARELATEX_ALLOW_PUBLIC_ACCESS=true sharelatex/sl-acceptance-test-runner:node-6.9-mongo-3.4 || (cat forever/app.log && false)' + sh 'make install' + sh "make test_acceptance MOCHA_ARGS="--reporter=tap"' } } diff --git a/services/web/config/settings.defaults.coffee b/services/web/config/settings.defaults.coffee index 69bf0a3b7c..fccd197a83 100644 --- a/services/web/config/settings.defaults.coffee +++ b/services/web/config/settings.defaults.coffee @@ -35,12 +35,12 @@ module.exports = settings = # Databases # --------- mongo: - url : 'mongodb://127.0.0.1/sharelatex' + url : "mongodb://#{process.env['MONGO_HOST'] || '127.0.0.1'}/sharelatex" redis: web: - host: "localhost" - port: "6379" + host: process.env['REDIS_HOST'] || "localhost" + port: process.env['REDIS_PORT'] || "6379" password: "" # websessions: @@ -74,8 +74,8 @@ module.exports = settings = # ] api: - host: "localhost" - port: "6379" + host: process.env['REDIS_HOST'] || "localhost" + port: process.env['REDIS_PORT'] || "6379" password: "" # Service locations @@ -87,6 +87,7 @@ module.exports = settings = internal: web: port: webPort = 3000 + host: process.env['LISTEN_ADDRESS'] or 'localhost' documentupdater: port: docUpdaterPort = 3003 @@ -99,7 +100,7 @@ module.exports = settings = user: httpAuthUser pass: httpAuthPass documentupdater: - url : "http://localhost:#{docUpdaterPort}" + url : "http://#{process.env['DOCUPDATER_HOST'] or 'localhost'}:#{docUpdaterPort}" thirdPartyDataStore: url : "http://localhost:3002" emptyProjectFlushDelayMiliseconds: 5 * seconds @@ -113,7 +114,7 @@ module.exports = settings = enabled: false url : "http://localhost:3054" docstore: - url : "http://localhost:3016" + url : "http://#{process.env['DOCSTORE_HOST'] or 'localhost'}:3016" pubUrl: "http://localhost:3016" chat: url: "http://localhost:3010" diff --git a/services/web/docker-compose.ci.yml b/services/web/docker-compose.ci.yml new file mode 100644 index 0000000000..0bb7af5f74 --- /dev/null +++ b/services/web/docker-compose.ci.yml @@ -0,0 +1,12 @@ +version: "2" + +services: + test_unit: + image: quay.io/sharelatex/$PROJECT_NAME:$BRANCH_NAME-$BUILD_NUMBER + user: root + volumes: [] + + test_acceptance: + image: quay.io/sharelatex/$PROJECT_NAME:$BRANCH_NAME-$BUILD_NUMBER + user: root + volumes: [] diff --git a/services/web/docker-compose.yml b/services/web/docker-compose.yml new file mode 100644 index 0000000000..fe30bb8f57 --- /dev/null +++ b/services/web/docker-compose.yml @@ -0,0 +1,42 @@ +version: "2" + +volumes: + node_modules: + +services: + npm: + image: node:6.9.5 + volumes: + - .:/app + - node_modules:/app/node_modules + working_dir: /app + + test_unit: + image: node:6.9.5 + volumes: + - .:/app + - node_modules:/app/node_modules + working_dir: /app + command: npm run test:unit + + test_acceptance: + image: node:6.9.5 + volumes: + - .:/app + - node_modules:/app/node_modules + environment: + REDIS_HOST: redis + MONGO_HOST: mongo + SHARELATEX_ALLOW_PUBLIC_ACCESS: 'true' + LISTEN_ADDRESS: 0.0.0.0 + depends_on: + - redis + - mongo + working_dir: /app + command: npm run start + + redis: + image: redis + + mongo: + image: mongo:3.4.6 diff --git a/services/web/makefile b/services/web/makefile new file mode 100644 index 0000000000..c6e1516598 --- /dev/null +++ b/services/web/makefile @@ -0,0 +1,64 @@ +NPM := docker-compose -f docker-compose.yml ${DOCKER_COMPOSE_FLAGS} run --rm npm npm +BUILD_NUMBER ?= local +BRANCH_NAME ?= $(shell git rev-parse --abbrev-ref HEAD) +PROJECT_NAME = web + +all: install test + @echo "Run:" + @echo " make install to set up the project dependencies (in docker)" + @echo " make test to run all the tests for the project (in docker)" + @echo " make run to run the app (natively)" + +add: + $(NPM) install --save ${P} + +add_dev: + $(NPM) install --save-dev ${P} + +install: + $(NPM) install + +clean: + rm app.js + rm -r app/js + rm -r test/unit/js + rm -r test/acceptance/js + +test: test_unit test_acceptance + +test_unit: + docker-compose -f docker-compose.yml ${DOCKER_COMPOSE_FLAGS} run --rm test_unit npm run test:unit -- ${MOCHA_ARGS} + +test_acceptance: test_acceptance_start_service test_acceptance_run test_acceptance_stop_service + +test_acceptance_start_service: + docker-compose -f docker-compose.yml ${DOCKER_COMPOSE_FLAGS} up -d test_acceptance + +test_acceptance_stop_service: + docker-compose -f docker-compose.yml ${DOCKER_COMPOSE_FLAGS} stop test_acceptance + +test_acceptance_run: + docker-compose -f docker-compose.yml ${DOCKER_COMPOSE_FLAGS} exec -T test_acceptance npm run test:acceptance -- ${MOCHA_ARGS} + +build: + docker build --pull --tag quay.io/sharelatex/$(PROJECT_NAME):$(BRANCH_NAME)-$(BUILD_NUMBER) . + +publish: + docker push quay.io/sharelatex/$(PROJECT_NAME):$(BRANCH_NAME)-$(BUILD_NUMBER) + +ci: + # When we run the tests locally we mount the local directory as a volumne + # and use a persistent node_modules folder (see docker-compose.yml). + # However, on the CI server, we want to run our tests in the image that we + # have built for deployment, which is what the docker-compose.ci.yml + # override does. + PROJECT_NAME=$(PROJECT_NAME) \ + BRANCH_NAME=$(BRANCH_NAME) \ + BUILD_NUMBER=$(BUILD_NUMBER) \ + DOCKER_COMPOSE_FLAGS="-f docker-compose.ci.yml" \ + $(MAKE) build test publish + +.PHONY: + add install update test test_unit test_acceptance \ + test_acceptance_start_service test_acceptance_stop_service \ + test_acceptance_run build publish ci diff --git a/services/web/package.json b/services/web/package.json index e831d929e1..f4ea19a49d 100644 --- a/services/web/package.json +++ b/services/web/package.json @@ -9,6 +9,17 @@ "directories": { "public": "./public" }, + "scripts": { + "test:acceptance:wait_for_app": "echo 'Waiting for app to be accessible' && while (! curl -s -o /dev/null localhost:3000/status) do sleep 1; done", + "test:acceptance:run": "mocha --recursive --reporter spec --timeout 15000 $@ test/acceptance/js", + "test:acceptance": "npm run compile:acceptance_tests && npm run test:acceptance:wait_for_app && npm run test:acceptance:run -- $@", + "test:unit": "npm run compile:app && npm run compile:unit_tests && mocha --recursive --reporter spec $@ test/unit/js", + "compile:unit_tests": "coffee -o test/unit/js -c test/unit/coffee", + "compile:acceptance_tests": "coffee -o test/acceptance/js -c test/acceptance/coffee", + "compile:app": "coffee -o app/js -c app/coffee && coffee -c app.coffee", + "start": "npm run compile:app && node app.js", + "echo": "echo $@" + }, "dependencies": { "archiver": "0.9.0", "async": "0.6.2", diff --git a/services/web/test/acceptance/coffee/helpers/MockDocUpdaterApi.coffee b/services/web/test/acceptance/coffee/helpers/MockDocUpdaterApi.coffee index aefcd4513a..6f307b0810 100644 --- a/services/web/test/acceptance/coffee/helpers/MockDocUpdaterApi.coffee +++ b/services/web/test/acceptance/coffee/helpers/MockDocUpdaterApi.coffee @@ -6,7 +6,7 @@ module.exports = MockDocUpdaterApi = app.post "/project/:project_id/flush", (req, res, next) => res.sendStatus 200 - app.listen 3003, (error) -> + app.listen 3003, '0.0.0.0', (error) -> throw error if error? .on "error", (error) -> console.error "error starting MockDocUpdaterApi:", error.message diff --git a/services/web/test/acceptance/coffee/helpers/MockDocstoreApi.coffee b/services/web/test/acceptance/coffee/helpers/MockDocstoreApi.coffee index 2133d40b9f..7c8f5dbe50 100644 --- a/services/web/test/acceptance/coffee/helpers/MockDocstoreApi.coffee +++ b/services/web/test/acceptance/coffee/helpers/MockDocstoreApi.coffee @@ -22,7 +22,7 @@ module.exports = MockDocStoreApi = docs = (doc for doc_id, doc of @docs[req.params.project_id]) res.send JSON.stringify docs - app.listen 3016, (error) -> + app.listen 3016, '0.0.0.0', (error) -> throw error if error? .on "error", (error) -> console.error "error starting MockDocStoreApi:", error.message diff --git a/services/web/test/acceptance/coffee/helpers/redis.coffee b/services/web/test/acceptance/coffee/helpers/redis.coffee index 9aecf6b387..7c48f97d2e 100644 --- a/services/web/test/acceptance/coffee/helpers/redis.coffee +++ b/services/web/test/acceptance/coffee/helpers/redis.coffee @@ -1,5 +1,4 @@ Settings = require('settings-sharelatex') -redis = require('redis-sharelatex') logger = require("logger-sharelatex") Async = require('async') diff --git a/services/web/test/acceptance/coffee/helpers/request.coffee b/services/web/test/acceptance/coffee/helpers/request.coffee index 879acd843a..1c7120d141 100644 --- a/services/web/test/acceptance/coffee/helpers/request.coffee +++ b/services/web/test/acceptance/coffee/helpers/request.coffee @@ -1,4 +1,4 @@ -BASE_URL = "http://localhost:3000" +BASE_URL = "http://#{process.env["HTTP_TEST_HOST"] or "localhost"}:3000" module.exports = require("request").defaults({ baseUrl: BASE_URL, followRedirect: false From d9d7c9695887c0d1ad72bb644f8602246f5771ab Mon Sep 17 00:00:00 2001 From: James Allen Date: Thu, 23 Nov 2017 16:45:46 +0000 Subject: [PATCH 19/98] Get module unit tests running inside Docker as well as main tests --- services/web/Jenkinsfile | 12 +----- services/web/README.md | 63 ++++++++++++++++++++++++++++++ services/web/docker-compose.ci.yml | 12 ------ services/web/makefile | 21 +--------- services/web/package.json | 10 ++--- 5 files changed, 71 insertions(+), 47 deletions(-) delete mode 100644 services/web/docker-compose.ci.yml diff --git a/services/web/Jenkinsfile b/services/web/Jenkinsfile index 85ecafef58..59347a266c 100644 --- a/services/web/Jenkinsfile +++ b/services/web/Jenkinsfile @@ -109,23 +109,15 @@ pipeline { } } - stage('Unit Test') { - agent { - docker { - image 'node:6.9.5' - reuseNode true - } - } + stage('Unit Tests') { steps { sh 'make install' - sh 'make test_unit MOCHA_ARGS="--reporter=tap"' - sh 'env NODE_ENV=development ./node_modules/.bin/grunt mochaTest:unit --reporter=tap' + sh "make test_unit MOCHA_ARGS="--reporter=tap"' } } stage('Acceptance Tests') { steps { - sh 'make install' sh "make test_acceptance MOCHA_ARGS="--reporter=tap"' } } diff --git a/services/web/README.md b/services/web/README.md index f777e7e5f5..51f73d02ec 100644 --- a/services/web/README.md +++ b/services/web/README.md @@ -17,6 +17,69 @@ web-sharelatex uses [Grunt](http://gruntjs.com/) to build its front-end related Image processing tasks are commented out in the gruntfile and the needed packages aren't presently in the project's `package.json`. If the images need to be processed again (minified and sprited), start by fetching the packages (`npm install grunt-contrib-imagemin grunt-sprity`), then *decomment* the tasks in `Gruntfile.coffee`. After this, the tasks can be called (explicitly, via `grunt imagemin` and `grunt sprity`). +New Docker-based build process +------------------------------ + +Note that the Grunt workflow from above should still work, but we are transitioning to a +Docker based testing workflow, which is documented below: + +### Running the app + +The app runs natively using npm and Node on the local system: + +``` +$ npm install +$ npm run start +``` + +*Ideally the app would run in Docker like the tests below, but with host networking not supported in OS X, we need to run it natively until all services are Dockerised.* + +### Unit Tests + +The test suites run in Docker. + +Unit tests can be run in the `test_unit` container defined in `docker-compose.tests.yml`. + +The makefile contains a short cut to run these: + +``` +make install # Only needs running once, or when npm packages are updated +make unit_test +``` + +During development it is often useful to only run a subset of tests, which can be configured with arguments to the mocha CLI: + +``` +make unit_test MOCHA_ARGS='--grep=AuthorizationManager' +``` + +### Acceptance Tests + +Acceptance tests are run against a live service, which runs in the `acceptance_test` container defined in `docker-compose.tests.yml`. + +To run the tests out-of-the-box, the makefile defines: + +``` +make install # Only needs running once, or when npm packages are updated +make acceptance_test +``` + +However, during development it is often useful to leave the service running for rapid iteration on the acceptance tests. This can be done with: + +``` +make acceptance_test_start_service +make acceptance_test_run # Run as many times as needed during development +make acceptance_test_stop_service +``` + +`make acceptance_test` just runs these three commands in sequence. + +During development it is often useful to only run a subset of tests, which can be configured with arguments to the mocha CLI: + +``` +make acceptance_test_run MOCHA_ARGS='--grep=AuthorizationManager' +``` + Unit test status ---------------- diff --git a/services/web/docker-compose.ci.yml b/services/web/docker-compose.ci.yml deleted file mode 100644 index 0bb7af5f74..0000000000 --- a/services/web/docker-compose.ci.yml +++ /dev/null @@ -1,12 +0,0 @@ -version: "2" - -services: - test_unit: - image: quay.io/sharelatex/$PROJECT_NAME:$BRANCH_NAME-$BUILD_NUMBER - user: root - volumes: [] - - test_acceptance: - image: quay.io/sharelatex/$PROJECT_NAME:$BRANCH_NAME-$BUILD_NUMBER - user: root - volumes: [] diff --git a/services/web/makefile b/services/web/makefile index c6e1516598..8d6ebf7c2e 100644 --- a/services/web/makefile +++ b/services/web/makefile @@ -7,7 +7,6 @@ all: install test @echo "Run:" @echo " make install to set up the project dependencies (in docker)" @echo " make test to run all the tests for the project (in docker)" - @echo " make run to run the app (natively)" add: $(NPM) install --save ${P} @@ -40,25 +39,7 @@ test_acceptance_stop_service: test_acceptance_run: docker-compose -f docker-compose.yml ${DOCKER_COMPOSE_FLAGS} exec -T test_acceptance npm run test:acceptance -- ${MOCHA_ARGS} -build: - docker build --pull --tag quay.io/sharelatex/$(PROJECT_NAME):$(BRANCH_NAME)-$(BUILD_NUMBER) . - -publish: - docker push quay.io/sharelatex/$(PROJECT_NAME):$(BRANCH_NAME)-$(BUILD_NUMBER) - -ci: - # When we run the tests locally we mount the local directory as a volumne - # and use a persistent node_modules folder (see docker-compose.yml). - # However, on the CI server, we want to run our tests in the image that we - # have built for deployment, which is what the docker-compose.ci.yml - # override does. - PROJECT_NAME=$(PROJECT_NAME) \ - BRANCH_NAME=$(BRANCH_NAME) \ - BUILD_NUMBER=$(BUILD_NUMBER) \ - DOCKER_COMPOSE_FLAGS="-f docker-compose.ci.yml" \ - $(MAKE) build test publish - .PHONY: add install update test test_unit test_acceptance \ test_acceptance_start_service test_acceptance_stop_service \ - test_acceptance_run build publish ci + test_acceptance_run diff --git a/services/web/package.json b/services/web/package.json index f4ea19a49d..a795df999e 100644 --- a/services/web/package.json +++ b/services/web/package.json @@ -11,12 +11,12 @@ }, "scripts": { "test:acceptance:wait_for_app": "echo 'Waiting for app to be accessible' && while (! curl -s -o /dev/null localhost:3000/status) do sleep 1; done", - "test:acceptance:run": "mocha --recursive --reporter spec --timeout 15000 $@ test/acceptance/js", + "test:acceptance:run": "bin/acceptance_test $@", "test:acceptance": "npm run compile:acceptance_tests && npm run test:acceptance:wait_for_app && npm run test:acceptance:run -- $@", - "test:unit": "npm run compile:app && npm run compile:unit_tests && mocha --recursive --reporter spec $@ test/unit/js", - "compile:unit_tests": "coffee -o test/unit/js -c test/unit/coffee", - "compile:acceptance_tests": "coffee -o test/acceptance/js -c test/acceptance/coffee", - "compile:app": "coffee -o app/js -c app/coffee && coffee -c app.coffee", + "test:unit": "npm run compile:app && npm run compile:unit_tests && bin/unit_test $@", + "compile:unit_tests": "bin/compile_unit_tests", + "compile:acceptance_tests": "bin/compile_acceptance_tests", + "compile:app": "bin/compile_app", "start": "npm run compile:app && node app.js", "echo": "echo $@" }, From 7efef129814052733c4b2360a51627cf9e5be19c Mon Sep 17 00:00:00 2001 From: James Allen Date: Thu, 23 Nov 2017 16:46:49 +0000 Subject: [PATCH 20/98] Fix Jenkinsfile syntax --- services/web/Jenkinsfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/web/Jenkinsfile b/services/web/Jenkinsfile index 59347a266c..0650eb4edb 100644 --- a/services/web/Jenkinsfile +++ b/services/web/Jenkinsfile @@ -112,13 +112,13 @@ pipeline { stage('Unit Tests') { steps { sh 'make install' - sh "make test_unit MOCHA_ARGS="--reporter=tap"' + sh 'make test_unit MOCHA_ARGS="--reporter=tap"' } } stage('Acceptance Tests') { steps { - sh "make test_acceptance MOCHA_ARGS="--reporter=tap"' + sh 'make test_acceptance MOCHA_ARGS="--reporter=tap"' } } From 492b37aa6e040f5e250d908311805b471b456880 Mon Sep 17 00:00:00 2001 From: James Allen Date: Thu, 23 Nov 2017 16:52:43 +0000 Subject: [PATCH 21/98] Add missing bin/ files --- services/web/bin/acceptance_test | 18 ++++++++++++++++++ services/web/bin/compile_acceptance_tests | 16 ++++++++++++++++ services/web/bin/compile_app | 23 +++++++++++++++++++++++ services/web/bin/compile_unit_tests | 15 +++++++++++++++ services/web/bin/unit_test | 14 ++++++++++++++ 5 files changed, 86 insertions(+) create mode 100755 services/web/bin/acceptance_test create mode 100755 services/web/bin/compile_acceptance_tests create mode 100755 services/web/bin/compile_app create mode 100755 services/web/bin/compile_unit_tests create mode 100755 services/web/bin/unit_test diff --git a/services/web/bin/acceptance_test b/services/web/bin/acceptance_test new file mode 100755 index 0000000000..717e8542ad --- /dev/null +++ b/services/web/bin/acceptance_test @@ -0,0 +1,18 @@ +#!/bin/bash +set -e; + +MOCHA="node_modules/.bin/mocha --recursive --reporter spec --timeout 15000" + +$MOCHA "$@" test/acceptance/js + +# TODO: Module acceptance tests are hard to get working, +# because they typically require the server to be instantiated +# with a different config. + +# for dir in modules/*; +# do +# if [ -d $dir/test/acceptance/js ]; then +# $MOCHA "$@" $dir/test/acceptance/js +# fi +# done + diff --git a/services/web/bin/compile_acceptance_tests b/services/web/bin/compile_acceptance_tests new file mode 100755 index 0000000000..d60ba0cc46 --- /dev/null +++ b/services/web/bin/compile_acceptance_tests @@ -0,0 +1,16 @@ +#!/bin/bash +set -e; + +COFFEE=node_modules/.bin/coffee + +echo Compiling test/acceptance/coffee; +$COFFEE -o test/acceptance/js -c test/acceptance/coffee; + +for dir in modules/*; +do + + if [ -d $dir/test/acceptance ]; then + echo Compiling $dir/test/acceptance/coffee; + $COFFEE -o $dir/test/acceptance/js -c $dir/test/acceptance/coffee; + fi +done \ No newline at end of file diff --git a/services/web/bin/compile_app b/services/web/bin/compile_app new file mode 100755 index 0000000000..b218b01a5a --- /dev/null +++ b/services/web/bin/compile_app @@ -0,0 +1,23 @@ +#!/bin/bash +set -e; + +COFFEE=node_modules/.bin/coffee + +echo Compiling app.coffee; +$COFFEE -c app.coffee; + +echo Compiling app/coffee; +$COFFEE -o app/js -c app/coffee; + +for dir in modules/*; +do + if [ -d $dir/app/coffee ]; then + echo Compiling $dir/app/coffee; + $COFFEE -o $dir/app/js -c $dir/app/coffee; + fi + + if [ -e $dir/index.coffee ]; then + echo Compiling $dir/index.coffee; + $COFFEE -c $dir/index.coffee; + fi +done \ No newline at end of file diff --git a/services/web/bin/compile_unit_tests b/services/web/bin/compile_unit_tests new file mode 100755 index 0000000000..780a189bda --- /dev/null +++ b/services/web/bin/compile_unit_tests @@ -0,0 +1,15 @@ +#!/bin/bash +set -e; + +COFFEE=node_modules/.bin/coffee + +echo Compiling test/unit/coffee; +$COFFEE -o test/unit/js -c test/unit/coffee; + +for dir in modules/*; +do + if [ -d $dir/test/unit ]; then + echo Compiling $dir/test/unit/coffee; + $COFFEE -o $dir/test/unit/js -c $dir/test/unit/coffee; + fi +done \ No newline at end of file diff --git a/services/web/bin/unit_test b/services/web/bin/unit_test new file mode 100755 index 0000000000..da13a441fa --- /dev/null +++ b/services/web/bin/unit_test @@ -0,0 +1,14 @@ +#!/bin/bash +set -e; + +MOCHA="node_modules/.bin/mocha --recursive --reporter spec" + +$MOCHA "$@" test/unit/js + +for dir in modules/*; +do + if [ -d $dir/test/unit/js ]; then + $MOCHA "$@" $dir/test/unit/js + fi +done + From 49057a5ab7e59d2ca7cded79248ce33087817cd9 Mon Sep 17 00:00:00 2001 From: James Allen Date: Fri, 24 Nov 2017 16:28:24 +0000 Subject: [PATCH 22/98] Only mount coffee and needed files into Docker so js isn't written back to local system --- services/web/.gitignore | 1 + services/web/{makefile => Makefile} | 36 +++++++++++++++---------- services/web/bin/generate_volumes_file | 26 ++++++++++++++++++ services/web/docker-compose.yml | 25 +++++++---------- services/web/docker-shared.template.yml | 21 +++++++++++++++ 5 files changed, 80 insertions(+), 29 deletions(-) rename services/web/{makefile => Makefile} (61%) create mode 100755 services/web/bin/generate_volumes_file create mode 100644 services/web/docker-shared.template.yml diff --git a/services/web/.gitignore b/services/web/.gitignore index 2a29f414d1..a48481690a 100644 --- a/services/web/.gitignore +++ b/services/web/.gitignore @@ -73,3 +73,4 @@ Gemfile.lock app/views/external /modules/ +docker-shared.yml diff --git a/services/web/makefile b/services/web/Makefile similarity index 61% rename from services/web/makefile rename to services/web/Makefile index 8d6ebf7c2e..25c0eb2cca 100644 --- a/services/web/makefile +++ b/services/web/Makefile @@ -8,38 +8,46 @@ all: install test @echo " make install to set up the project dependencies (in docker)" @echo " make test to run all the tests for the project (in docker)" -add: +add: docker-shared.yml $(NPM) install --save ${P} -add_dev: +add_dev: docker-shared.yml $(NPM) install --save-dev ${P} -install: +install: docker-shared.yml $(NPM) install -clean: - rm app.js - rm -r app/js - rm -r test/unit/js - rm -r test/acceptance/js +clean: docker-shared.yml + rm -f app.js + rm -rf app/js + rm -rf test/unit/js + rm -rf test/acceptance/js + # Deletes node_modules volume + docker-compose down --volumes + # Delete after docker-compose command + rm -f docker-shared.yml + +# Need regenerating if you change the web modules you have installed +docker-shared.yml: + bin/generate_volumes_file test: test_unit test_acceptance -test_unit: +test_unit: docker-shared.yml docker-compose -f docker-compose.yml ${DOCKER_COMPOSE_FLAGS} run --rm test_unit npm run test:unit -- ${MOCHA_ARGS} test_acceptance: test_acceptance_start_service test_acceptance_run test_acceptance_stop_service -test_acceptance_start_service: +test_acceptance_start_service: docker-shared.yml docker-compose -f docker-compose.yml ${DOCKER_COMPOSE_FLAGS} up -d test_acceptance -test_acceptance_stop_service: - docker-compose -f docker-compose.yml ${DOCKER_COMPOSE_FLAGS} stop test_acceptance +test_acceptance_stop_service: docker-shared.yml + docker-compose -f docker-compose.yml ${DOCKER_COMPOSE_FLAGS} stop test_acceptance redis mongo -test_acceptance_run: +test_acceptance_run: docker-shared.yml docker-compose -f docker-compose.yml ${DOCKER_COMPOSE_FLAGS} exec -T test_acceptance npm run test:acceptance -- ${MOCHA_ARGS} .PHONY: - add install update test test_unit test_acceptance \ + all add install update test test_unit test_acceptance \ test_acceptance_start_service test_acceptance_stop_service \ test_acceptance_run diff --git a/services/web/bin/generate_volumes_file b/services/web/bin/generate_volumes_file new file mode 100755 index 0000000000..2117ff5342 --- /dev/null +++ b/services/web/bin/generate_volumes_file @@ -0,0 +1,26 @@ +#!/usr/bin/env python2 + +from os import listdir +from os.path import isfile, isdir, join + +volumes = [] + +for module in listdir("modules/"): + if module[0] != '.': + if isfile(join("modules", module, 'index.coffee')): + volumes.append(join("modules", module, 'index.coffee')) + for directory in ['app/coffee', 'app/views', 'public/coffee', 'test/unit/coffee', 'test/acceptance/coffee']: + if isdir(join("modules", module, directory)): + volumes.append(join("modules", module, directory)) + +volumes_string = map(lambda vol: "- ./" + vol + ":/app/" + vol + ":ro", volumes) +volumes_string = "\n ".join(volumes_string) + +with open("docker-shared.template.yml", "r") as f: + docker_shared_file = f.read() + +docker_shared_file = docker_shared_file.replace("MODULE_VOLUMES", volumes_string) + +with open("docker-shared.yml", "w") as f: + f.write(docker_shared_file) + diff --git a/services/web/docker-compose.yml b/services/web/docker-compose.yml index fe30bb8f57..3658a87b7f 100644 --- a/services/web/docker-compose.yml +++ b/services/web/docker-compose.yml @@ -5,25 +5,21 @@ volumes: services: npm: - image: node:6.9.5 - volumes: - - .:/app - - node_modules:/app/node_modules - working_dir: /app + extends: + file: docker-shared.yml + service: app + command: npm install test_unit: - image: node:6.9.5 - volumes: - - .:/app - - node_modules:/app/node_modules - working_dir: /app + extends: + file: docker-shared.yml + service: app command: npm run test:unit test_acceptance: - image: node:6.9.5 - volumes: - - .:/app - - node_modules:/app/node_modules + extends: + file: docker-shared.yml + service: app environment: REDIS_HOST: redis MONGO_HOST: mongo @@ -32,7 +28,6 @@ services: depends_on: - redis - mongo - working_dir: /app command: npm run start redis: diff --git a/services/web/docker-shared.template.yml b/services/web/docker-shared.template.yml new file mode 100644 index 0000000000..dca6b5a224 --- /dev/null +++ b/services/web/docker-shared.template.yml @@ -0,0 +1,21 @@ +version: "2" + +services: + app: + image: node:6.9.5 + volumes: + - ./package.json:/app/package.json + - ./npm-shrinkwrap.json:/app/npm-shrinkwrap.json + - node_modules:/app/node_modules + - ./bin:/app/bin + - ./public:/app/public + - ./app.coffee:/app/app.coffee:ro + - ./app/coffee:/app/app/coffee:ro + - ./app/templates:/app/app/templates:ro + - ./app/views:/app/app/views:ro + - ./config:/app/config + - ./test/unit/coffee:/app/test/unit/coffee:ro + - ./test/acceptance/coffee:/app/test/acceptance/coffee:ro + - ./test/smoke/coffee:/app/test/smoke/coffee:ro + MODULE_VOLUMES + working_dir: /app \ No newline at end of file From 2bc0f666bac9c48aa5ecaa7cdb19c3159d68e28e Mon Sep 17 00:00:00 2001 From: James Allen Date: Fri, 24 Nov 2017 17:04:22 +0000 Subject: [PATCH 23/98] Add some documentation --- services/web/docker-shared.template.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/services/web/docker-shared.template.yml b/services/web/docker-shared.template.yml index dca6b5a224..48adb7a036 100644 --- a/services/web/docker-shared.template.yml +++ b/services/web/docker-shared.template.yml @@ -1,5 +1,10 @@ version: "2" +# We mount all the directories explicitly so that we are only mounting +# the coffee directories, so that the compiled js is only written inside +# the container, and not back to the local filesystem, where it would be +# root owned, and conflict with working outside of the container. + services: app: image: node:6.9.5 @@ -8,6 +13,10 @@ services: - ./npm-shrinkwrap.json:/app/npm-shrinkwrap.json - node_modules:/app/node_modules - ./bin:/app/bin + # Copying the whole public dir is fine for now, and needed for + # some unit tests to pass, but we will want to isolate the coffee + # and vendor js files, so that the compiled js files are not written + # back to the local filesystem. - ./public:/app/public - ./app.coffee:/app/app.coffee:ro - ./app/coffee:/app/app/coffee:ro From 5e0fc24c1a014bd5852debf198fbbdf345969147 Mon Sep 17 00:00:00 2001 From: James Allen Date: Fri, 24 Nov 2017 17:40:24 +0000 Subject: [PATCH 24/98] Allow modules to specific their own acceptance tests --- services/web/Makefile | 21 +++++++++++++++++---- services/web/bin/acceptance_test | 16 +--------------- services/web/bin/generate_volumes_file | 2 +- services/web/package.json | 3 ++- 4 files changed, 21 insertions(+), 21 deletions(-) diff --git a/services/web/Makefile b/services/web/Makefile index 25c0eb2cca..3955fe1d52 100644 --- a/services/web/Makefile +++ b/services/web/Makefile @@ -36,17 +36,30 @@ test: test_unit test_acceptance test_unit: docker-shared.yml docker-compose -f docker-compose.yml ${DOCKER_COMPOSE_FLAGS} run --rm test_unit npm run test:unit -- ${MOCHA_ARGS} -test_acceptance: test_acceptance_start_service test_acceptance_run test_acceptance_stop_service +test_acceptance: test_acceptance_app test_acceptance_modules -test_acceptance_start_service: docker-shared.yml +test_acceptance_app: test_acceptance_app_start_service test_acceptance_app_run test_acceptance_app_stop_service + +test_acceptance_app_start_service: docker-shared.yml docker-compose -f docker-compose.yml ${DOCKER_COMPOSE_FLAGS} up -d test_acceptance -test_acceptance_stop_service: docker-shared.yml +test_acceptance_app_stop_service: docker-shared.yml docker-compose -f docker-compose.yml ${DOCKER_COMPOSE_FLAGS} stop test_acceptance redis mongo -test_acceptance_run: docker-shared.yml +test_acceptance_app_run: docker-shared.yml docker-compose -f docker-compose.yml ${DOCKER_COMPOSE_FLAGS} exec -T test_acceptance npm run test:acceptance -- ${MOCHA_ARGS} +test_acceptance_modules: docker-shared.yml + for dir in modules/*; \ + do \ + if [ -e $$dir/makefile ]; then \ + (make test_acceptance_module MODULE=$$dir) \ + fi \ + done + +test_acceptance_module: docker-shared.yml + cd $(MODULE) && make test_acceptance + .PHONY: all add install update test test_unit test_acceptance \ test_acceptance_start_service test_acceptance_stop_service \ diff --git a/services/web/bin/acceptance_test b/services/web/bin/acceptance_test index 717e8542ad..fd2e5137b5 100755 --- a/services/web/bin/acceptance_test +++ b/services/web/bin/acceptance_test @@ -1,18 +1,4 @@ #!/bin/bash set -e; - MOCHA="node_modules/.bin/mocha --recursive --reporter spec --timeout 15000" - -$MOCHA "$@" test/acceptance/js - -# TODO: Module acceptance tests are hard to get working, -# because they typically require the server to be instantiated -# with a different config. - -# for dir in modules/*; -# do -# if [ -d $dir/test/acceptance/js ]; then -# $MOCHA "$@" $dir/test/acceptance/js -# fi -# done - +$MOCHA "$@" diff --git a/services/web/bin/generate_volumes_file b/services/web/bin/generate_volumes_file index 2117ff5342..d70ac11c3d 100755 --- a/services/web/bin/generate_volumes_file +++ b/services/web/bin/generate_volumes_file @@ -9,7 +9,7 @@ for module in listdir("modules/"): if module[0] != '.': if isfile(join("modules", module, 'index.coffee')): volumes.append(join("modules", module, 'index.coffee')) - for directory in ['app/coffee', 'app/views', 'public/coffee', 'test/unit/coffee', 'test/acceptance/coffee']: + for directory in ['app/coffee', 'app/views', 'public/coffee', 'test/unit/coffee', 'test/acceptance/coffee', 'test/acceptance/config']: if isdir(join("modules", module, directory)): volumes.append(join("modules", module, directory)) diff --git a/services/web/package.json b/services/web/package.json index a795df999e..b14cdf8b20 100644 --- a/services/web/package.json +++ b/services/web/package.json @@ -12,7 +12,8 @@ "scripts": { "test:acceptance:wait_for_app": "echo 'Waiting for app to be accessible' && while (! curl -s -o /dev/null localhost:3000/status) do sleep 1; done", "test:acceptance:run": "bin/acceptance_test $@", - "test:acceptance": "npm run compile:acceptance_tests && npm run test:acceptance:wait_for_app && npm run test:acceptance:run -- $@", + "test:acceptance:dir": "npm run compile:acceptance_tests && npm run test:acceptance:wait_for_app && npm run test:acceptance:run -- $@", + "test:acceptance": "npm run test:acceptance:dir test/acceptance/js", "test:unit": "npm run compile:app && npm run compile:unit_tests && bin/unit_test $@", "compile:unit_tests": "bin/compile_unit_tests", "compile:acceptance_tests": "bin/compile_acceptance_tests", From 4c504ad8ebcbfc25aab78b6492694396ea5b65a6 Mon Sep 17 00:00:00 2001 From: James Allen Date: Mon, 27 Nov 2017 16:51:20 +0000 Subject: [PATCH 25/98] Remove debugging command --- services/web/package.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/services/web/package.json b/services/web/package.json index b14cdf8b20..3e6789920e 100644 --- a/services/web/package.json +++ b/services/web/package.json @@ -18,8 +18,7 @@ "compile:unit_tests": "bin/compile_unit_tests", "compile:acceptance_tests": "bin/compile_acceptance_tests", "compile:app": "bin/compile_app", - "start": "npm run compile:app && node app.js", - "echo": "echo $@" + "start": "npm run compile:app && node app.js" }, "dependencies": { "archiver": "0.9.0", From 054964dd8551851b8ca779073c0358101ef9bd82 Mon Sep 17 00:00:00 2001 From: James Allen Date: Mon, 27 Nov 2017 16:55:11 +0000 Subject: [PATCH 26/98] Clean out module js on make clean --- services/web/Makefile | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/services/web/Makefile b/services/web/Makefile index 3955fe1d52..88b09ce609 100644 --- a/services/web/Makefile +++ b/services/web/Makefile @@ -22,6 +22,13 @@ clean: docker-shared.yml rm -rf app/js rm -rf test/unit/js rm -rf test/acceptance/js + for dir in modules/*; \ + do \ + rm -f $$dir/index.js; \ + rm -rf $$dir/app/js; \ + rm -rf $$dir/test/unit/js; \ + rm -rf $$dir/test/acceptance/js; \ + done # Deletes node_modules volume docker-compose down --volumes # Delete after docker-compose command From 3e90103d9cb3523fdc34a24e2bc856196e38895a Mon Sep 17 00:00:00 2001 From: James Allen Date: Mon, 27 Nov 2017 17:04:54 +0000 Subject: [PATCH 27/98] No need to bind to 0.0.0.0 when running in same container --- .../web/test/acceptance/coffee/helpers/MockDocUpdaterApi.coffee | 2 +- .../web/test/acceptance/coffee/helpers/MockDocstoreApi.coffee | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/services/web/test/acceptance/coffee/helpers/MockDocUpdaterApi.coffee b/services/web/test/acceptance/coffee/helpers/MockDocUpdaterApi.coffee index 6f307b0810..aefcd4513a 100644 --- a/services/web/test/acceptance/coffee/helpers/MockDocUpdaterApi.coffee +++ b/services/web/test/acceptance/coffee/helpers/MockDocUpdaterApi.coffee @@ -6,7 +6,7 @@ module.exports = MockDocUpdaterApi = app.post "/project/:project_id/flush", (req, res, next) => res.sendStatus 200 - app.listen 3003, '0.0.0.0', (error) -> + app.listen 3003, (error) -> throw error if error? .on "error", (error) -> console.error "error starting MockDocUpdaterApi:", error.message diff --git a/services/web/test/acceptance/coffee/helpers/MockDocstoreApi.coffee b/services/web/test/acceptance/coffee/helpers/MockDocstoreApi.coffee index 7c8f5dbe50..2133d40b9f 100644 --- a/services/web/test/acceptance/coffee/helpers/MockDocstoreApi.coffee +++ b/services/web/test/acceptance/coffee/helpers/MockDocstoreApi.coffee @@ -22,7 +22,7 @@ module.exports = MockDocStoreApi = docs = (doc for doc_id, doc of @docs[req.params.project_id]) res.send JSON.stringify docs - app.listen 3016, '0.0.0.0', (error) -> + app.listen 3016, (error) -> throw error if error? .on "error", (error) -> console.error "error starting MockDocStoreApi:", error.message From e94cef12b73e0f0761a938c940a41879a2de3551 Mon Sep 17 00:00:00 2001 From: Paulo Reis Date: Mon, 27 Nov 2017 17:11:32 +0000 Subject: [PATCH 28/98] Use ::before to fill empty space in indented filetree items. --- .../stylesheets/app/editor/file-tree.less | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/services/web/public/stylesheets/app/editor/file-tree.less b/services/web/public/stylesheets/app/editor/file-tree.less index 328d484d06..5f63e7c1a4 100644 --- a/services/web/public/stylesheets/app/editor/file-tree.less +++ b/services/web/public/stylesheets/app/editor/file-tree.less @@ -1,3 +1,13 @@ +.fake-full-width-bg(@bg-color) { + &::before { + content: ''; + position: absolute; + width: 100%; + height: 100%; + right: 100%; + background-color: @bg-color; + } +} #file-tree { .toolbar.toolbar-filetree { @@ -66,9 +76,9 @@ &:hover when (@is-overleaf = true) { // When the entity is a subfolder, the DOM element is "indented" via margin-left. This makes the // element not fill the entire file-tree width (as it's spaced from the left-hand side via margin) - // and, in consequence, the background gets clipped. The negative (in the x axis) box-shadow is a - // hack to paint the unfilled left-hand region. - box-shadow: -200px 0 0 @file-tree-item-hover-bg; + // and, in consequence, the background gets clipped. The ::before pseudo-selector is used to fill + // the empty space. + .fake-full-width-bg(@file-tree-item-hover-bg); } input { line-height: 1.6; @@ -79,7 +89,7 @@ &.droppable-hover when (@is-overleaf = true) { background-color: @file-tree-droppable-bg-color; - box-shadow: -200px 0 0 @file-tree-droppable-bg-color; + .fake-full-width-bg(@file-tree-droppable-bg-color); } } @@ -118,10 +128,10 @@ color: #FFF; font-weight: bold; background-color: @file-tree-multiselect-bg; - box-shadow: -200px 0 0 @file-tree-multiselect-bg; + .fake-full-width-bg(@file-tree-multiselect-bg); &:hover { background-color: @file-tree-multiselect-hover-bg; - box-shadow: -200px 0 0 @file-tree-multiselect-hover-bg; + .fake-full-width-bg(@file-tree-multiselect-hover-bg); } } } @@ -180,11 +190,7 @@ background-color: @file-tree-item-selected-bg; font-weight: bold; padding-right: 32px; - // When the entity is a subfolder, the DOM element is "indented" via margin-left. This makes the - // element not fill the entire file-tree width (as it's spaced from the left-hand side via margin) - // and, in consequence, the background gets clipped. The negative (in the x axis) box-shadow is a - // hack to paint the unfilled left-hand region. - box-shadow: -200px 0 0 @file-tree-item-selected-bg; + .fake-full-width-bg(@file-tree-item-selected-bg); .entity-menu-toggle { display: inline; @@ -199,7 +205,7 @@ } ul.droppable-hover.file-tree-list when (@is-overleaf = true) { background-color: @file-tree-droppable-bg-color; - box-shadow: -200px 0 0 @file-tree-droppable-bg-color; + .fake-full-width-bg(@file-tree-droppable-bg-color); } } From b66e28df19d6122cf59389e4bf4508cb3c9115d2 Mon Sep 17 00:00:00 2001 From: Paulo Reis Date: Mon, 27 Nov 2017 17:12:14 +0000 Subject: [PATCH 29/98] Use a non-breakable space to give height. --- services/web/public/stylesheets/app/editor/file-tree.less | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/services/web/public/stylesheets/app/editor/file-tree.less b/services/web/public/stylesheets/app/editor/file-tree.less index 5f63e7c1a4..b15c6f17a3 100644 --- a/services/web/public/stylesheets/app/editor/file-tree.less +++ b/services/web/public/stylesheets/app/editor/file-tree.less @@ -1,9 +1,8 @@ .fake-full-width-bg(@bg-color) { &::before { - content: ''; + content: '\00a0'; position: absolute; width: 100%; - height: 100%; right: 100%; background-color: @bg-color; } From 0091d639bf903fae8b2f0bcda096bca59e409d9c Mon Sep 17 00:00:00 2001 From: Paulo Reis Date: Tue, 28 Nov 2017 10:49:04 +0000 Subject: [PATCH 30/98] Use white review icon in OL. --- .../web/public/stylesheets/app/editor/review-panel.less | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/services/web/public/stylesheets/app/editor/review-panel.less b/services/web/public/stylesheets/app/editor/review-panel.less index 15ed39f6a7..5e846be008 100644 --- a/services/web/public/stylesheets/app/editor/review-panel.less +++ b/services/web/public/stylesheets/app/editor/review-panel.less @@ -902,6 +902,13 @@ } } +.review-icon when (@is-overleaf) { + background-position-y: -60px; + .toolbar .btn-full-height:hover & { + background-position-y: -60px; + } +} + .resolved-comments-toggle { font-size: 14px; color: lighten(@rp-type-blue, 25%); From bbaacb4db4dbf1861c30408187ba8a8a086cd5fd Mon Sep 17 00:00:00 2001 From: Alasdair Smith Date: Tue, 28 Nov 2017 10:52:46 +0000 Subject: [PATCH 31/98] Increase autocompile rollout to 20% --- .../web/app/coffee/Features/Project/ProjectController.coffee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/web/app/coffee/Features/Project/ProjectController.coffee b/services/web/app/coffee/Features/Project/ProjectController.coffee index 58893060ff..9d9b7d9a13 100644 --- a/services/web/app/coffee/Features/Project/ProjectController.coffee +++ b/services/web/app/coffee/Features/Project/ProjectController.coffee @@ -251,7 +251,7 @@ module.exports = ProjectController = # Extract data from user's ObjectId timestamp = parseInt(user_id.toString().substring(0, 8), 16) - rolloutPercentage = 10 # Percentage of users to roll out to + rolloutPercentage = 20 # Percentage of users to roll out to if !ProjectController._isInPercentageRollout('autocompile', user_id, rolloutPercentage) # Don't show if user is not part of roll out return cb(null, { enabled: false, showOnboarding: false }) From 2c0fcb51e0499ce3b1af80952a761013124f76db Mon Sep 17 00:00:00 2001 From: Paulo Reis Date: Tue, 28 Nov 2017 11:27:45 +0000 Subject: [PATCH 32/98] Unneeded LESS guard. --- services/web/public/stylesheets/app/editor/file-tree.less | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/services/web/public/stylesheets/app/editor/file-tree.less b/services/web/public/stylesheets/app/editor/file-tree.less index b15c6f17a3..1ccbc6859c 100644 --- a/services/web/public/stylesheets/app/editor/file-tree.less +++ b/services/web/public/stylesheets/app/editor/file-tree.less @@ -47,11 +47,7 @@ overflow-x: hidden; height: 100%; - ul when (@is-overleaf = false) { - margin-left: (@line-height-computed / 2); - } - - ul when (@is-overleaf = true) { + ul { margin-left: (@line-height-computed / 2); } From 50b30455482d0c1a56052f1cc339d256d1def206 Mon Sep 17 00:00:00 2001 From: James Allen Date: Wed, 29 Nov 2017 13:49:36 +0000 Subject: [PATCH 33/98] Tidy up docker-compose and makefile --- services/web/Makefile | 13 +++++++------ services/web/config/settings.defaults.coffee | 2 +- services/web/docker-compose.yml | 3 +-- services/web/package.json | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/services/web/Makefile b/services/web/Makefile index 88b09ce609..cd724038e6 100644 --- a/services/web/Makefile +++ b/services/web/Makefile @@ -1,4 +1,5 @@ -NPM := docker-compose -f docker-compose.yml ${DOCKER_COMPOSE_FLAGS} run --rm npm npm +DOCKER_COMPOSE_FLAGS ?= -f docker-compose.yml +NPM := docker-compose ${DOCKER_COMPOSE_FLAGS} run --rm npm npm BUILD_NUMBER ?= local BRANCH_NAME ?= $(shell git rev-parse --abbrev-ref HEAD) PROJECT_NAME = web @@ -41,20 +42,20 @@ docker-shared.yml: test: test_unit test_acceptance test_unit: docker-shared.yml - docker-compose -f docker-compose.yml ${DOCKER_COMPOSE_FLAGS} run --rm test_unit npm run test:unit -- ${MOCHA_ARGS} + docker-compose ${DOCKER_COMPOSE_FLAGS} run --rm test_unit npm run test:unit -- ${MOCHA_ARGS} test_acceptance: test_acceptance_app test_acceptance_modules test_acceptance_app: test_acceptance_app_start_service test_acceptance_app_run test_acceptance_app_stop_service -test_acceptance_app_start_service: docker-shared.yml - docker-compose -f docker-compose.yml ${DOCKER_COMPOSE_FLAGS} up -d test_acceptance +test_acceptance_app_start_service: test_acceptance_app_stop_service docker-shared.yml + docker-compose ${DOCKER_COMPOSE_FLAGS} up -d test_acceptance test_acceptance_app_stop_service: docker-shared.yml - docker-compose -f docker-compose.yml ${DOCKER_COMPOSE_FLAGS} stop test_acceptance redis mongo + docker-compose ${DOCKER_COMPOSE_FLAGS} stop test_acceptance redis mongo test_acceptance_app_run: docker-shared.yml - docker-compose -f docker-compose.yml ${DOCKER_COMPOSE_FLAGS} exec -T test_acceptance npm run test:acceptance -- ${MOCHA_ARGS} + docker-compose ${DOCKER_COMPOSE_FLAGS} exec -T test_acceptance npm run test:acceptance -- ${MOCHA_ARGS} test_acceptance_modules: docker-shared.yml for dir in modules/*; \ diff --git a/services/web/config/settings.defaults.coffee b/services/web/config/settings.defaults.coffee index fccd197a83..98d1c9e031 100644 --- a/services/web/config/settings.defaults.coffee +++ b/services/web/config/settings.defaults.coffee @@ -35,7 +35,7 @@ module.exports = settings = # Databases # --------- mongo: - url : "mongodb://#{process.env['MONGO_HOST'] || '127.0.0.1'}/sharelatex" + url : process.env['MONGO_URL'] || "mongodb://127.0.0.1/sharelatex" redis: web: diff --git a/services/web/docker-compose.yml b/services/web/docker-compose.yml index 3658a87b7f..aaa8666a16 100644 --- a/services/web/docker-compose.yml +++ b/services/web/docker-compose.yml @@ -22,9 +22,8 @@ services: service: app environment: REDIS_HOST: redis - MONGO_HOST: mongo + MONGO_URL: "mongodb://mongo/sharelatex" SHARELATEX_ALLOW_PUBLIC_ACCESS: 'true' - LISTEN_ADDRESS: 0.0.0.0 depends_on: - redis - mongo diff --git a/services/web/package.json b/services/web/package.json index 3e6789920e..c221dc1052 100644 --- a/services/web/package.json +++ b/services/web/package.json @@ -13,7 +13,7 @@ "test:acceptance:wait_for_app": "echo 'Waiting for app to be accessible' && while (! curl -s -o /dev/null localhost:3000/status) do sleep 1; done", "test:acceptance:run": "bin/acceptance_test $@", "test:acceptance:dir": "npm run compile:acceptance_tests && npm run test:acceptance:wait_for_app && npm run test:acceptance:run -- $@", - "test:acceptance": "npm run test:acceptance:dir test/acceptance/js", + "test:acceptance": "npm run test:acceptance:dir -- $@ test/acceptance/js", "test:unit": "npm run compile:app && npm run compile:unit_tests && bin/unit_test $@", "compile:unit_tests": "bin/compile_unit_tests", "compile:acceptance_tests": "bin/compile_acceptance_tests", From bb74f8318ab9bbc7603722b39fd094b69b72e7d6 Mon Sep 17 00:00:00 2001 From: James Allen Date: Wed, 29 Nov 2017 14:16:29 +0000 Subject: [PATCH 34/98] Support `make clean install` usage --- services/web/Makefile | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/services/web/Makefile b/services/web/Makefile index cd724038e6..7ab8b89781 100644 --- a/services/web/Makefile +++ b/services/web/Makefile @@ -18,7 +18,7 @@ add_dev: docker-shared.yml install: docker-shared.yml $(NPM) install -clean: docker-shared.yml +clean: rm -f app.js rm -rf app/js rm -rf test/unit/js @@ -32,8 +32,9 @@ clean: docker-shared.yml done # Deletes node_modules volume docker-compose down --volumes - # Delete after docker-compose command - rm -f docker-shared.yml + # Regenerate docker-shared.yml - not stictly a 'clean', + # but lets `make clean install` work nicely + bin/generate_volumes_file # Need regenerating if you change the web modules you have installed docker-shared.yml: From c2bb8b9f8917f7f6c5c8f9fffc0bcbb9b226586b Mon Sep 17 00:00:00 2001 From: Nate Stemen Date: Wed, 29 Nov 2017 13:30:53 -0500 Subject: [PATCH 35/98] removing calls --- .../aceEditor/auto-complete/PackageManager.coffee | 2 +- .../directives/aceEditor/metadata/MetadataManager.coffee | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/PackageManager.coffee b/services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/PackageManager.coffee index d43824b12c..08de22d846 100644 --- a/services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/PackageManager.coffee +++ b/services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/PackageManager.coffee @@ -29,7 +29,7 @@ define () -> packageSnippets.push { caption: "\\usepackage{}" - snippet: "\\usepackage{}" + snippet: "\\usepackage{$1}" meta: "pkg" score: 70 } diff --git a/services/web/public/coffee/ide/editor/directives/aceEditor/metadata/MetadataManager.coffee b/services/web/public/coffee/ide/editor/directives/aceEditor/metadata/MetadataManager.coffee index 362d163196..c9308626bc 100644 --- a/services/web/public/coffee/ide/editor/directives/aceEditor/metadata/MetadataManager.coffee +++ b/services/web/public/coffee/ide/editor/directives/aceEditor/metadata/MetadataManager.coffee @@ -22,7 +22,7 @@ define [ end = change.end range = new Range(end.row, 0, end.row, end.column) lineUpToCursor = @editor.getSession().getTextRange range - if lineUpToCursor.trim() == '%' or lineUpToCursor.startsWith '\\' + if lineUpToCursor.trim() == '%' or lineUpToCursor.slice(0, 1) == '\\' range = new Range(end.row, 0, end.row, end.column + 80) lineUpToCursor = @editor.getSession().getTextRange range commandFragment = getLastCommandFragment lineUpToCursor @@ -44,9 +44,9 @@ define [ linesContainLabel or linesContainReqPackage - lastCommandFragmentIsLabel = commandFragment?.startsWith '\\label{' - lastCommandFragmentIsPackage = commandFragment?.startsWith '\\usepackage' - lastCommandFragmentIsReqPack = commandFragment?.startsWith '\\RequirePackage' + lastCommandFragmentIsLabel = commandFragment?.slice(0, 7) == '\\label{' + lastCommandFragmentIsPackage = commandFragment?.slice(0, 11) == '\\usepackage' + lastCommandFragmentIsReqPack = commandFragment?.slice(0, 15) == '\\RequirePackage' lastCommandFragmentIsMeta = lastCommandFragmentIsPackage or lastCommandFragmentIsLabel or From cbba7c38dfc2c88a230e3c8afbc848d3fd5282bc Mon Sep 17 00:00:00 2001 From: Alasdair Smith Date: Mon, 27 Nov 2017 13:46:08 +0000 Subject: [PATCH 36/98] Add basic v1 import modal template & controller --- .../web/app/views/project/list/modals.pug | 23 +++++++++++++++++++ .../project-list/modal-controllers.coffee | 3 +++ 2 files changed, 26 insertions(+) diff --git a/services/web/app/views/project/list/modals.pug b/services/web/app/views/project/list/modals.pug index 37638e958f..9ae5ba0ea7 100644 --- a/services/web/app/views/project/list/modals.pug +++ b/services/web/app/views/project/list/modals.pug @@ -313,3 +313,26 @@ script(type="text/ng-template", id="userProfileModalTemplate") .modal-footer button.btn.btn-info(ng-click="done()") #{translate("done")} + +script(type="text/ng-template", id="v1ImportModalTemplate") + .modal-header + button.close(ng-click="dismiss()") × + h3 #{translate("import_project_to_v2")} + + .modal-body + img( + src="/img/v1-import/v2-editor.png" + alt="The new V2 Editor." + ) + + h2 Try importing your project to V2! + p Some exciting copy about the new features: + ul + li Some stuff + li Some more stuff + li Yet more stuff + + .modal-footer + button.btn.btn-primary(ng-click="openV1()") #{translate("open_in_v1")} + button.btn.btn-primary(ng-click="import()") #{translate("import_to_v2")} + diff --git a/services/web/public/coffee/main/project-list/modal-controllers.coffee b/services/web/public/coffee/main/project-list/modal-controllers.coffee index 179e0d91ce..190b960395 100644 --- a/services/web/public/coffee/main/project-list/modal-controllers.coffee +++ b/services/web/public/coffee/main/project-list/modal-controllers.coffee @@ -123,3 +123,6 @@ define [ $scope.onComplete = (error, name, response) -> if response.project_id? window.location = '/project/' + response.project_id + + App.controller 'V1ImportModalController', ($scope, $modalInstance, project) -> + console.log('V1ImportModalController', project) From 0e11668a6134853c9eb7d1b60010d3d7286cc293 Mon Sep 17 00:00:00 2001 From: Alasdair Smith Date: Mon, 27 Nov 2017 13:46:30 +0000 Subject: [PATCH 37/98] Change v1 project link to button which opens import modal --- services/web/app/views/project/list/v1-item.pug | 9 ++++----- .../public/coffee/main/project-list/project-list.coffee | 8 ++++++++ services/web/public/stylesheets/app/project-list.less | 4 ++++ 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/services/web/app/views/project/list/v1-item.pug b/services/web/app/views/project/list/v1-item.pug index ace8a80b9a..61815068f6 100644 --- a/services/web/app/views/project/list/v1-item.pug +++ b/services/web/app/views/project/list/v1-item.pug @@ -5,11 +5,10 @@ tooltip-append-to-body="true" ) span - if settings.overleaf && settings.overleaf.host - a.projectName( - href=settings.overleaf.host + "/{{project.id}}" - stop-propagation="click" - ) {{project.name}} + button.btn.btn-link.v1ProjectName( + ng-click="openV1ImportModal(project)" + stop-propagation="click" + ) {{project.name}} .col-xs-2 span.owner {{ownerName()}} diff --git a/services/web/public/coffee/main/project-list/project-list.coffee b/services/web/public/coffee/main/project-list/project-list.coffee index 1d81504c74..bb1d1acc56 100644 --- a/services/web/public/coffee/main/project-list/project-list.coffee +++ b/services/web/public/coffee/main/project-list/project-list.coffee @@ -442,6 +442,14 @@ define [ path = "/project/#{selected_project_ids[0]}/download/zip" window.location = path + + $scope.openV1ImportModal = (project) -> + $modal.open( + templateUrl: 'v1ImportModalTemplate' + controller: 'UploadProjectModalController' + resolve: + project: () -> project + ) if storedUIOpts?.filter? if storedUIOpts.filter == "tag" and storedUIOpts.selectedTagId? diff --git a/services/web/public/stylesheets/app/project-list.less b/services/web/public/stylesheets/app/project-list.less index 1ca170ff89..e541afd5cc 100644 --- a/services/web/public/stylesheets/app/project-list.less +++ b/services/web/public/stylesheets/app/project-list.less @@ -332,6 +332,10 @@ ul.project-list { .projectName { margin-right: @line-height-computed / 4; } + .v1ProjectName { + margin-right: @line-height-computed / 4; + padding: 0; + } .tag-label { margin-left: @line-height-computed / 4; From b68d6411ee3826f211b51476df3c2f9de2c54cd1 Mon Sep 17 00:00:00 2001 From: Alasdair Smith Date: Mon, 27 Nov 2017 14:25:15 +0000 Subject: [PATCH 38/98] Basic styles for import modal --- services/web/app/views/project/list/modals.pug | 8 ++++---- .../coffee/main/project-list/project-list.coffee | 2 ++ .../stylesheets/app/list/v1-import-modal.less | 13 +++++++++++++ .../web/public/stylesheets/app/project-list.less | 2 ++ 4 files changed, 21 insertions(+), 4 deletions(-) create mode 100644 services/web/public/stylesheets/app/list/v1-import-modal.less diff --git a/services/web/app/views/project/list/modals.pug b/services/web/app/views/project/list/modals.pug index 9ae5ba0ea7..b57290cf44 100644 --- a/services/web/app/views/project/list/modals.pug +++ b/services/web/app/views/project/list/modals.pug @@ -325,14 +325,14 @@ script(type="text/ng-template", id="v1ImportModalTemplate") alt="The new V2 Editor." ) - h2 Try importing your project to V2! + h2.v1-import-title Try importing your project to V2! p Some exciting copy about the new features: ul li Some stuff li Some more stuff li Yet more stuff - .modal-footer - button.btn.btn-primary(ng-click="openV1()") #{translate("open_in_v1")} - button.btn.btn-primary(ng-click="import()") #{translate("import_to_v2")} + .modal-footer.v1-import-footer + button.btn.btn-primary.v1-import-btn(ng-click="openV1()") #{translate("open_in_v1")} + button.btn.btn-primary.v1-import-btn(ng-click="import()") #{translate("import_to_v2")} diff --git a/services/web/public/coffee/main/project-list/project-list.coffee b/services/web/public/coffee/main/project-list/project-list.coffee index bb1d1acc56..1a7384fa01 100644 --- a/services/web/public/coffee/main/project-list/project-list.coffee +++ b/services/web/public/coffee/main/project-list/project-list.coffee @@ -447,6 +447,8 @@ define [ $modal.open( templateUrl: 'v1ImportModalTemplate' controller: 'UploadProjectModalController' + size: 'lg' + windowClass: 'v1-import-modal' resolve: project: () -> project ) diff --git a/services/web/public/stylesheets/app/list/v1-import-modal.less b/services/web/public/stylesheets/app/list/v1-import-modal.less new file mode 100644 index 0000000000..9e7282be9f --- /dev/null +++ b/services/web/public/stylesheets/app/list/v1-import-modal.less @@ -0,0 +1,13 @@ +.v1-import-title { + text-align: center; +} + +.v1-import-footer { + display: flex; + justify-content: space-evenly; + text-align: left; +} + + .v1-import-btn { + width: 20rem; + } \ No newline at end of file diff --git a/services/web/public/stylesheets/app/project-list.less b/services/web/public/stylesheets/app/project-list.less index e541afd5cc..732b2e7903 100644 --- a/services/web/public/stylesheets/app/project-list.less +++ b/services/web/public/stylesheets/app/project-list.less @@ -1,3 +1,5 @@ +@import "./list/v1-import-modal.less"; + @announcements-shadow: 0 2px 20px rgba(0, 0, 0, 0.5); @keyframes pulse { From 958e4cad9b68e5129abfaa15fa90cd06d166e7f8 Mon Sep 17 00:00:00 2001 From: Alasdair Smith Date: Tue, 28 Nov 2017 11:31:00 +0000 Subject: [PATCH 39/98] Fix wrong controller --- .../web/public/coffee/main/project-list/project-list.coffee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/web/public/coffee/main/project-list/project-list.coffee b/services/web/public/coffee/main/project-list/project-list.coffee index 1a7384fa01..f4a6951e4a 100644 --- a/services/web/public/coffee/main/project-list/project-list.coffee +++ b/services/web/public/coffee/main/project-list/project-list.coffee @@ -446,7 +446,7 @@ define [ $scope.openV1ImportModal = (project) -> $modal.open( templateUrl: 'v1ImportModalTemplate' - controller: 'UploadProjectModalController' + controller: 'V1ImportModalController' size: 'lg' windowClass: 'v1-import-modal' resolve: From 265e6599992540cca7ea1a232fdb658424e6a4f3 Mon Sep 17 00:00:00 2001 From: Alasdair Smith Date: Tue, 28 Nov 2017 11:31:31 +0000 Subject: [PATCH 40/98] Add multiple steps --- .../web/app/views/project/list/modals.pug | 40 ++++++++++++------- .../project-list/modal-controllers.coffee | 12 +++++- 2 files changed, 37 insertions(+), 15 deletions(-) diff --git a/services/web/app/views/project/list/modals.pug b/services/web/app/views/project/list/modals.pug index b57290cf44..38fdb66955 100644 --- a/services/web/app/views/project/list/modals.pug +++ b/services/web/app/views/project/list/modals.pug @@ -319,20 +319,32 @@ script(type="text/ng-template", id="v1ImportModalTemplate") button.close(ng-click="dismiss()") × h3 #{translate("import_project_to_v2")} - .modal-body - img( - src="/img/v1-import/v2-editor.png" - alt="The new V2 Editor." - ) + .modal-body.v1-import-wrapper + .v1-import-step-1(ng-show="step === 1") + img( + src="/img/v1-import/v2-editor.png" + alt="The new V2 Editor." + ) + h2.v1-import-title Try importing your project to V2! + p Some exciting copy about the new features: + ul + li Some stuff + li Some more stuff + li Yet more stuff - h2.v1-import-title Try importing your project to V2! - p Some exciting copy about the new features: - ul - li Some stuff - li Some more stuff - li Yet more stuff + .v1-import-step-2(ng-show="step === 2") + img( + src="/img/v1-import/v2-editor.png" + alt="The new V2 Editor." + ) + h2.v1-import-title #[strong Warning:] Overleaf V2 is in beta + p Once importing your project you will lose access to the some of the features of Overleaf V1. This includes the git bridge, journal integrations, WYSIWYG and linked files. We’re working on bringing these features to V2! + p Once you have imported a project to V2 you #[strong cannot go back to V1]. .modal-footer.v1-import-footer - button.btn.btn-primary.v1-import-btn(ng-click="openV1()") #{translate("open_in_v1")} - button.btn.btn-primary.v1-import-btn(ng-click="import()") #{translate("import_to_v2")} - + div(ng-show="step === 1") + a.btn.btn-primary.v1-import-btn(ng-href=settings.overleaf.host + "/{{project.id}}") #{translate("open_in_v1")} + button.btn.btn-primary.v1-import-btn(ng-click="moveToConfirmation()") #{translate("import_to_v2")} + div(ng-show="step === 2") + a.btn.btn-primary.v1-import-btn(ng-href=settings.overleaf.host + "/{{project.id}}") #{translate("never_mind_open_in_v1")} + button.btn.btn-primary.v1-import-btn(ng-click="import()") #{translate("yes_im_sure")} diff --git a/services/web/public/coffee/main/project-list/modal-controllers.coffee b/services/web/public/coffee/main/project-list/modal-controllers.coffee index 190b960395..5fd0a4707d 100644 --- a/services/web/public/coffee/main/project-list/modal-controllers.coffee +++ b/services/web/public/coffee/main/project-list/modal-controllers.coffee @@ -125,4 +125,14 @@ define [ window.location = '/project/' + response.project_id App.controller 'V1ImportModalController', ($scope, $modalInstance, project) -> - console.log('V1ImportModalController', project) + $scope.project = project + $scope.step = 1 + + $scope.dismiss = () -> + $modalInstance.dismiss('cancel') + + $scope.moveToConfirmation = () -> + $scope.step = 2 + + $scope.import = () -> + console.log('IMPORT', project) From 3aaf6559c8cd7c52710c186733e4023452ebbb38 Mon Sep 17 00:00:00 2001 From: Alasdair Smith Date: Tue, 28 Nov 2017 11:45:20 +0000 Subject: [PATCH 41/98] Add image of v2 editor --- services/web/app/views/project/list/modals.pug | 4 ++-- .../web/public/img/v1-import/v2-editor.png | Bin 0 -> 210106 bytes .../stylesheets/app/list/v1-import-modal.less | 4 ++++ 3 files changed, 6 insertions(+), 2 deletions(-) create mode 100644 services/web/public/img/v1-import/v2-editor.png diff --git a/services/web/app/views/project/list/modals.pug b/services/web/app/views/project/list/modals.pug index 38fdb66955..828fa9fe4b 100644 --- a/services/web/app/views/project/list/modals.pug +++ b/services/web/app/views/project/list/modals.pug @@ -321,7 +321,7 @@ script(type="text/ng-template", id="v1ImportModalTemplate") .modal-body.v1-import-wrapper .v1-import-step-1(ng-show="step === 1") - img( + img.v1-import-img( src="/img/v1-import/v2-editor.png" alt="The new V2 Editor." ) @@ -333,7 +333,7 @@ script(type="text/ng-template", id="v1ImportModalTemplate") li Yet more stuff .v1-import-step-2(ng-show="step === 2") - img( + img.v1-import-img( src="/img/v1-import/v2-editor.png" alt="The new V2 Editor." ) diff --git a/services/web/public/img/v1-import/v2-editor.png b/services/web/public/img/v1-import/v2-editor.png new file mode 100644 index 0000000000000000000000000000000000000000..5079d771587d6e7e178028f427eea38c84648492 GIT binary patch literal 210106 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

pL!-CNLj|A+=5NRzFX&> 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ι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

X-~Jtz^-{z2pQ zA~4VmIr9+f;NQStxQd8UAUhmr&l5)hZI9}Oh=}p98WT0EE(*8(19X5$c2OO{!C2nf z1|?cg!8*3jn1$hf?xv*&E(o+q7~u>ZX)&Zx7)e4>jZkSH^_M{?nu3y2aeT&)GQALo z;kt==(mKqcyIHq8>L_W!VT%^Ek z(0&uK7erIdm{N%WKq#_fgTW}Iqqpqt>G_Nz0!#b zkPP-Y`jGO?r%W&r2ajD!N-7REH;fv?bSZAbC;+RPO1xhlvD!YyNr*>}<@V~;D>-?2 z^8$*VkzP15p!Q&MoZUk{1;ks***h{x$-0aQCTlT|`qa&&N+`mPFu@R1P+94LGUp1{ zc4&`CIsl3qK)I($&cyVuJ?Kkb`U2lE5>m5`#az0_t*z>g^FKM@OeE+8f#Vn$y88Nx z5p2tIFCXi;v!mnG#fvo9IgxNPz`F4A`EzG5065a{0_rdd9=e)=+K7$lU4cT7s|@1A zgWTM=#&#hjPxkutJ>-+1NCMg8if?fbD>em}DKv;^zR@YcktU6fP*G74x2n9Fnlv2V zkgAm6lt4R&G?cU0M|iE+f)FU5gL)OCC<`ho+~KZ+AGH8lJ_xvVAz}kRq1NydT5Pz} zJkb^KBmOT|z{<*snwt6`7uOB!j#oZDNN#G6TNeZ=HYX=WgX*E?I|Fq)QJoWK1lKB=Nr3rBW4Z_{Kz?p5Gz#p1fqb+Nv6EM9+Y!1VG{8(VpCA%0UNl1 z-HUgjdjB0Z-(N#0+K`en)bHKOTH6s~_GV+7U8AkW9OU zrkm6~Ot26@#IzsoCS-P`8#R+{49Jd1Gocf4x8x)KHE|8S!GLpB#s;P-r*~>^O7Nl}X%E-KW3lk0081CoJ;v=BYQ!1>Xp`wJ# zW^m9@+A{|Un3xc7+uR&{kQ>&LmZ_<%w)WeI!#q5VUbwGtBH^(ZrCW#M>yRfbAwlMx zCn45X+D;${!#41Wwm=0AunjY_@e&~g)Cv9s7g85~f3qr zfSwWxS2C5WtgK9T<_69dMpBl87N7@3@?*;T*@E#j{ESs{~INNkcv#z6TET;I*ASOX=&@TB3T6)pN2N592=>&Ah09{DP&JUaZAh%&Q#;8!u2X&V5 z-KkY#q^2Y~W7t2;|F}uVe6L)-T(G>fa2z`pO5S2^K2l@AM~8|M_dp8hE8+@54S^kr z3+?K|%riGfE4BHOA(Do#6o5q;bI_qU|RvVqBiY>8x4-dm$16Q98f<*&L^!SD3hI0 z%cGm1MGzURf2al@)aZY%sGxzY8O}K*Vd~*^kh2K+onNK9;> zKAnjbnt^!-y%9b)4$TXg%L#WU5xYaJj^=>usp8^4Wya=>j>sSfV z@$s0v`~a>_47ERl=Z8-VlmXEDCp049ia)iq?8H@oYloBx_%oFHiO9b^iKJ25+lwG{ z5H!+l)Cwp|E+faKxtVC4@vP6nn*&GroA>Xx!J~@{nv%O-e_Ff6an4{{mNKbm83A+A(AW$~|lb2-;o z`|_Hay*MyX31bQuwj8dgL!uwgoIT6qHfRa53VW;kz7WbSpC69YuAlg{ zSA>U(mI;O6hK(Bw8ydV}-$eC!Xo6X&0VE#j=HNEN?S^U(moN$xn4DI@K!XAWL>`($ zcvCzfIl$A$eS`UOU;$Y$Ru*C&*hKE*#1wA3Lyw;%0pb48kTgyuoX_mYu*BztRU3BX z)HT=Nm|6Zpeqd;WAsPsFl&vpy`TX@U6cp_$e9xFxk7+KH_=V38Cq&4jTE#*&^0!Nk z?$<_ffuk6DF^h7Z407{eS7Kq3Il3qg$fNkcfRBnVfKD3j)ku68{O<1le!cD5CWz$0 zizk;93yV7jJtHv-9}EUB?VrcjSHol^y+96{1ZDgf#7P6DhyB0<`y6BhA3KG>W`6jz zqriQExPonk7zyyhP-fi_f+$H4X0hkb=?G?y3+He6z4Q_MRb5-hu!AudGV%g#KA68D zabXt`*@Zr4L-2VC3D`?8c$WtGOC;GF=~A5-7>TizI_vMk7KjgwJdqn1TM4=VQAg~U zga~gi76uw}6eiLRC^X4POFWnHjxF)`n>T&1S$j~C>q_48L2oWJpHi-?UFDx362f*x zZWGgq#zPl5K2P~NYV%T@-~4M)I^QgorSJSU;~QDvsfZEVXaCzp~gb4%mytw(4@*Y}})Y zw<_7{`#?4PxSpzKakS_aGKMEcg-%|ngy9c{i`<2baRAJ!3_@FO0B`j9;eY1wt@EI$ z-phM?EB5sCt#*&q(&Uv~m*2#8`1-b~5qbI5E$R8s-!!&(?mGSnY$JReogfq!>MpIh zrmk4%@l0XU6H_O}@dXIz4jw$X8^9~zW7&sK{$wesJHt^Za6buC9&6X1QC~Nn=7~PS z*P@>vNWDM5l^ELip7fKCUU;<}Tc;ysxFb2pH0*rHX`hgVa~>ZIb=br=^U5S<_w`$M z9g}I6*G`KM3%gH1Su}_z?hI%NidRfEY6*;Y&Id77`Wr2(f9Ul#5;q%<@n*y(#Ku8+ z$BJg!oA%yc6#L9$@$JNo%Z=%92aBJv>dZMnYLR-k?OeH#lenf*;#K>LWw|qAvz<%uF{m`&_`vA)3A#jZWUgMrmuE<%SxdCXQBC#u@OKtrs2 z=uV!$c(I4=??4hjl}=p5;prV47q^#P(A}&`)UvJ2e&V!papj4#d1@lbrq|m75_2Dn z)JYwDCwMzCgk!}f+Kg{>#X;-Swb;*d6TU2eO%`VfAnL{8Jna5 z6*i?F-y~hjo2rg!!EF3eSAFCw>t6)lu`=t7XDeIv?%<=N{BC1+Ym>CH8NFrz2+76*9{PqB?1o6mJ<`Ums7IYOT#>(G)kQZ9B92 z;5#D`vBZ?x6t+|GKk}nfisxTlX&PU=@BP5`!_odXPNS!qJhZ)qB8@MUcYPFnL@}#t zwbjCkwWFm?k^+NstVe>?Y?9PCQl!cPf(mzX3tOPl_(0oUD4j9Y71;LZs$~xog{ybN zAyL0iW%~@sbFzJ~cC_Z)P(wp(Y^;v!`Tu@SCrB8ak`V|Q0Gmo@Q~$Q)JHQc2N?rs} z$Najow6~$uE5WeYt2ml@(){QQ+r(axu3ytOPjnUw+r92ycV38%(&AF`m+O_R(D02@GE9_?zkd((>C5Vzb?LR9IDRv? zLYMzyM5V0AgOEU<7aM{oMr%%}3{HHj5SELrvzV|;wORS0maGusP|vlUda><&kMb44 zTd7a8Y#XC=M~lv$U4PqZPkx(g%Z*Q0?K=);l$>*39W!WYFEoA_s-$;urdkVTLpJOj zGJg5+IF`aT4;{=KTq5JA|2+iOk3Ay&SbRN%R}`~aWq}p7h*=CvyZldvXIM(gQ-eA^ z^{L%&G5uuZXOUBGf>wc=o-&(Ebp7QpZk+|Sr^VL6%qGY5EI+9^?4W#p(T2$}Xl_j3 zMlh$fwUXg>^#!DHEIRkQFesr{;*9qP2=-%I1tX1}kx zDbi&dW=vl9kNtC*6N*B`sAitv<_GQ)P04c8KFDN;(E&VL*)@VF7<7!;Z8w9_hAOirpTQGqA;>%+GGOSi0p z6I?{IuQkhGy<{n9KOq>$btr8I7YzWkXD9_4UO$JN*7p5I?Me?yALHmb2`Ea>hs)PM z6`97#Tt7IuXj&m;Ue3)Uy6xKU-(R>67wYpR>$hKSjM7sr`^CB=zxch(;1m_ZualoD zpD8F6W*XOXea|{!u|I=Of0toA|LdTAb(;=4S|n&xdfXW|E%aRXvWD4xn{q?aVL_Kw zHNPE22ZTA|^mpuaRQk&+jPLpH#T!K|1*&f%_cY?VOj&&9Fla*|FJGJaJK=h(u=ytF z2lsMtBo^&yQI`H^&8AUvy|VYkA6QmKR<^xQwFq7Kd}iK5ys)sY>bZ~Oy-kiR#`aaB z)OXI&AEl?h>aS7hw9Zb@O__m#L0(cVQ#- z$A6Vq_U2sAkiUBQCUYOn=$(-@%Q@OSv|m*|i-tJJN-Q<+j=+qr2^nSwvDufxPCvAp zm*<0)j^D|0J}JKZa1VdpzSi%kjo%{1SD4) z;RC|FnMEd7QRr4I*8jf;j{GkQGt%}@|NrW@7PHdN?DNOyC)6?(E<6+FIn|ucR|MFIrY@{5v{i zb=x)G1(BTBEQfEcAh`1$*Z?H>lp-Ra_WL5 zSuU3c%@ABdK~|J#U_pAD@B5$E_5$Z|SOaX{MNKnh5B;q+OfN_WTOhPf-TJ>TJXJDa z-843l0A)?>yDLnW<|HW5Q^D3*TNT9}U+nPm@ zWL+vdyD<0f$kwp!=-In59-$v}Umt?22Q3{oFplLn7l}_Q||H_5yhL)2C0b zX5HbmYO`ddKOhqt6H}J50xtK%@AMX=lMIb;Sz#yvp_u`O3|RGgd3zVZ4DE+6A!uNc zqfzCwvY@HqFr^43YkQwYd1a+8_%g6E<>@5)6;S}GNCcicLcOPw%oVOFgHFRAGlcXv zoCvNgWzTYWtMertUW?5B;FqCq$!hql`3)1(W}#UDE}{!esP#_WMevOih(}v^Ix&e{ za0q~UvGj-vaub`_t~AvOz&S{45ErwTo!z}GhD#$z&xp?L?RKuS%d*;_}P+?*lUF$2{^8d zgjN!+e*&8JC!OkCVpF)1@G^ZZ_&4xuK4#s;M@?Ff0Re)n4SnjTT5j-OB$5eTDC~Q1 z+XF_&V<|2wS__g9)C1`HZXY$1`j@`m9~M7DSZq-!!DhPoM7Hy*po?Tved-pj#jlY7n#p zpa|CQ2J_*5!{~KAs36|O#}fiT%we8_C}&Va8UTj?paAnX#wtOumfzUeXl?{l@~@l< zjs9^Y8xWWSTZBg=1ZW?!2rnPpHuT&89nlI(Ng-o?t4o0v z=L%>*c-0ZChoKb^KyGpdJPZtrNDASvM}jNm16NnzKi7}nMYqf>u(g7FIqZ*{w1j!_)orqHb)2~LU%vCz@f94v0nhBF&qlUD^%N%S}kV z@E~=+`V~NqBu?_&0ZhB;RN^CJ@Gjm3VoY=ijIn1HL2BIy11T{QBFVOi$*G&qtssOTx&WLvP_`5!S?_|hbU4@%h`V0nLZifUkOpiEJYUK3xZ~n6 za?BCxivog3$e}cnU(MjlCn07l^yrtx#fd6N6GxuIJtiTswn}W_5Kd-FEhN4Ifu%z{ zB>AS0shq!f(cS)wtNz+Qj*s^z&3JD$8TUYBBq;4>!uCXI$f#lHvhArq7sle0fxr#| z-AzuC6c#9+Zm9-08=Z^3BCnz0aW4Fg)5#&5bT)A@fdlB+p;G&?wHT(IRRa+6p2V_Eihr{N9 zjs_9I0kXUZI1oaE-9P}nrHY(aQ4)CuFoUKce*uH7017t&%tpc)BAP$pej(H>9wrq@ z_t{CK&8gi$8ZVxV#QupYA`MZW-eN2nusPfGXG zl6d^m^TY4u#rC&2~|A;wwKfa3a+Vw*cp%LvP_x^f>|aUxfx_U;|KT0ty4Mt*LndW}N~| z40!8q(8SxeZ|@u&B+-Jm@7~STqLaZ2hSUlAA_6@_dq9CdxO;H03}0lm02Mb23kwlL zI_s<*13!!;WJq6-sB#&?d~7a&@?khnd8ke)$jf7Wvtw^y7R)$KHM*TU-2lE5B2t^L zCRI;XS`?{x@g5ntK@7>g4lUJR;F;Xc;CBj8FCZTkeE3ZaKl($0sy)0Wd~F{Bs-S+y z3Ly#_e}Am0XY4C5Yv4#DiK+QNeh_vSMHLiqK%_4#DLq7g3{@&SPC6`^TKw?x=ME@I zLjKfpKLJm~_-KB*lH+9E)aU2Es$qchstMK?14F{X8%Z|CVxBt|&PN%U|u;WNj zGzD%iSYK{pMf+$xQ7*&1$X7qt`e7qDRpR++Z*Si^d5fA=XzeddNJ0V7ivY!Za3v|< zo=HWdsH%JbvINP5y@LZ!!4dsU!QQW49l?NVEEInc8$M(io}_XqkZ0uJ;2>Fg7 zS_bY~o~fk8@R_C$YXZ}SSs;AJW$QCfwPwvIHe@Q|l@NqkbaEuS&;etRSx8-!w&$2hh|pKR54(b3nz84<1=s?U%Nb&wr6Ai!6n zFnf(J;D>Q8uOP!yU40kQ_ER@UNu4rv#F(0OHC8J0#4hK;Pe;ffwH&+>>@cmuYCEt8 z(V1=_Ep=0yQ2;3QNC@(Lso1EdC`f&}0JPU#i3UA~>3fB#z%1pd({%UYuwB&~c>8EjliahzX!AL4^-pNdbOmR@MnnaWasqSoD2; zgO!XQj-Vq*Tn|9GgIeXlrPp6b^f;24a18NcXHxE<)Pf!gO)sQ-q*j?&7?^i4;_Q6T+mIYn6z)?i3VTR&(>@w18a&u1P~4GBU*I%rW*5eeDC zJ&n{VvX3D6!geFS;N^ux@gXu}N$-o70w)?F5^+%qZ8SP_leQTAI$=%0t3uulv4I;x z5dTP|aj76;{ByXcm=we?$eYCp`#d1vX-0-Hng__v9-`#JsDTT%wkN=Lp{&HNE+HvE zL@a1#W}q|;3EJngw4$FOFGV%^38x9pEztU9h`Gl&tIZhW(wAJq8jjuu7ljJV&@hxD zAj9@zv^p~Zg(&w>>7t;O`|GGf8HjKwymKNPvF^CH4h_LhCNcYC zkYDhmlREEwL&6eGq9p%%vnB6sf|WHBlk}d;*LhVG$9Lt00M9;%DpdIa0x0*y_61w+(G$9WkJRrO=PIRcTa6yI3N6%t75Gk)9 zS|$uWk^G`pfjYE3&H*|~FvAcZufeLN+Pv!m)JJ&HVW`sZBM->5=v-ob*`a5f~3s@z90aTgoJ7O6jSa8;P64PQV2; zfs2?DZ3JYRMNb6pz^+}p$a_U77KuBHjpgLwk%(8K-?r@mq)|`^A|O>x zQ&Sd-z3O9n+6o=BPzb%n(u67rg<7Tf@)#{766Enhz=%o*tEn97=(7?Mt~&bZ;6hQ6 zaaC4UVzD*I>mntMd`?u+z+~XTL{NJ~cJ^yHj&b*VK)t|o_8TN0th)R?ecMA}s#}^5 zzY>GB3eV)Wyy)V{K7;U^)j=Bsf9ZoR)3o=a# zudJUHoYnvDnYRBI#I)heQ}IW*4*pEd#wFD5U4@pGXJa+#kg}KOhw`}d@a^fa;}Nr} z-F&x%#R{%#98_Sfs*4gN1lI21p9Z=Wn4h5CkuvAO<`2}sH{%`|Y_kepFc z9zsFCIGiO4umZ6j4@ul2z!l&vkWiBNu7H{SzNq;wAIJrowG^Pwn6 zJeCL%63`K}PgwEl-t$3X4gvT92+PD@K5y!p&Z3CM!bPC`)yOO0%F|Vg=A}?3ne}~T zhDi)3qff&}SGum5=fThaeq|s6@3D$d27KhQ!Fdc7>>b4+MmoCDM``X**rEhg7S%+w z7sSE6ee`D*JO6YTiU4|WTT!5nTo#zZTML0tu0?T)N(iSVPJ1|qr=qf`P~|}b@(@SN zdp`|z^{*(z&ftWG8(a$oBjZ<7ydxamRL-Qd!D*d0DQcTM{UJce#91Tfw1fNdprAnh zL(AJ8@3!dKu~&(NCvn|^>I#eH5w5!*C=c@LE$1=mfGFfJg#?#ZEuU;d1m6yKnLr_T zyRM!u!};_KKXaLLFcshm4un+$_0dF^@zQNS!AZu1 zBmTdQX*-p>H9nf`L(ye8oeG#sIpP;)3Lv@r5jGuAI6N(a3!t%}zzQMaC*>)7XfdnM zznZtT9A{^D#d$P)e`OZuft8y9@F6mX3HJ(7SX{r}DN$YyxB)66h>W}f0_LOgXq{vi z)Q!0_ghziyHR(=o@!D{1CF8z?UCiiRh07W@Kk1(c%>55zoYRMXlHgCGH;^Zc=Q5t+ znV673W;g(L!`gxJb^_H_U3K+nhGV6$fWVRKpVmy;<}^$Lpdo6nSdAa3l(auQ3Uow+ zY*y2al}Bno+`%NeetezkpDM*vhJa|)?#!Z3kA|ODab!c;8Fww?1%7e#_9=0XO~+2s z!a}fp#@+q_;Z3BPp)Ji=%s@ytW{p4}omO^N4EHAfX@AJdqMqB&{+7+e1k=^s?GH?k z-FN0Ie-%X%0`ipF#gtpPtD4Ohh9p`8hulXLgBySTL$!-1w*O1i#T>hMyMCC=sv(KR z0K$m89tT!N#`8Ga0j7~A)z={#7tKy#lUM5|6eH_?TCXh4*dUn)fL2q448%APE}5&8 zwBBtoCjWyNwoKwr_Tg-x7q-iLa|jsTzKyKmc_Z!ivzy~$U(I>e&p`XL8cMbK$ZW6g z>Bf+-w4PB>@n<*2m52714ad_%S~f?OmD_C}EdGS};g-X^4VCnr?&pvrEqnX+5qNtL z2wp#!2|+EnEnJ{yCu5V=vDs6Q$T3WbyQh6*A@luiVKhu)n|GqTOKzsuT&_ z?5V-8An^-Bc}lXT85v8EL3}t`jI@3zi1xvZ3)ri??3MC&F$z?TqyvYhh7P|GSQh7Sh=(OE|EOAYel~7uBE6b^)-E=_G zI`?tla^mCO@=8{#o+ELy&CjBOp0c!DFS&d0a7yJzz#%9#@>KjKqb6+3m(Az1#UC*` zsY_pU+3ai?&1|3P_(&%Y6TTMoR-7xfvftjynbe}*mpglJ6`NyGT)Z8!mb`r?h6m#} z`OmS;RI27rY;>aPfHq)m>=Yk2Z501x>Il11`9yst2g`#v!2#`W(F((Hb z+@L!G?D{Q5N4h)BSib{%g98n936!m@qP8duoN7eSs6v$SsMr4kUisbO_ZmG$co77) zL=%}iKNu_R$g1%KDM@3APD|zEcEI>ir}^GF%otOJqMKBg1knbN18pUSvF~?63GfFb z{&UC+JJxEmvMg!2O`LH&ZY2hyqVVAlhigNV_$ELm3z19-e9z}iJWng! z#5}ZW_Sb$z_wj3;oQg^*U_FXi)PJ*MC2SC#>S4e#hRC?GIW2B+hQ2Bpc$rD=oGyIs zaf1eY{(gKq)Ff&ZFU{v>;A#VaG!Y32l_^drbAdAF#pWPYnYbe$@B&_R6se%Bjl zbW|ov5u`Y;!59p0An1+eE9ffK$K@6uvEAF7XKkL3c^^p75s|uyh1ikmOEmg3oM!Xy} z9O}@!q{7a-m=thb_;XUv&vox=hGuV^zZsemmT<52X-w+Ovt}9F8fKY3H(HMNSHY#f zj2EnXJ#xP~-d-=x9+52Cy`MsHibf=3R8J&`#b0#Lgmt9BmA)u~Vw+M!k^rZ7h-c(P zV_U{;3+vr%2^pIW6_u4Wr*}OA4odQBQNvspP+fP~lTEx5lujl53aF{Oz&q|ga%41R zTvkeoD3!=mSiH=&E(NDxlUkWA5hrQ3n4gSE6}ic42GChcqhSEXeFF(mWT+*gq)Dpm z{FJ+JFK|+yLJ~m)WGBfTdRQgp^VUbW4gO&(N)nZW>Ndrs8-`6%YC`~3B) zmu$~!4%k>91-?TdSf3EgzM}wHdluT-28+Y+EqImQ8OyuO_)fOYmkEMg{71o^s6%q! zMst(;VbA9fuT3QkVcU0#8}K2$tD*YW0l2fj`Y+IcjL99IqTaBf6S4MdLvgOaSV1|8 z4N?m}4Q}u?`H#2k`*S&=13Vm{2YE2*pOB=Cg??10Y#UnX_0-fi5Q|Pe99j&b{-tMP z`U0dD$~ikfD9Ew5juGD|1!}&E`|La_nYZ^4^B&~x+89i(WB~O%{8^i=tY&m4Ixy3Q zej^(-G7G9(cQ;-e4mn{2WmSqIiHV^b=Y*f4l^g7L@tT&6rGXR%0(d z+d+xjS3#cPX)vD-ZoR0r!b}9yoejtrDB)r7LQ@S*}!CR8&O|_)Ysy2ahzdSZpjf zxAv*@i4s>kPrmmS0{4awQ?zbl_)_^*p>-F-=dPvm$(LiSfA#HzW+*r{;2m?!kmc7M zHjEzWLRT7uuip2)$ZFHGg>-qC-@5FHHU*OV5VyvX_V&D@iF?JT6m@jiK{ygCIZT4f zDZ@jNTHUyMGvQ5+uVuCFSQTtPE=hq_R2B?9J3Bj=Oj-g6fB^)CKn`UBssb?D&CR_r z=+b$RAgEx7w+AOxO{_96;VA%E;&+^iZ6K{6kekD*B>+dE)D(oooXq=N1=1+V{t~!c z82x|!$O$A#AOLSPYCjm`m3rR+5 zHbclGMg;|hn}WUP2-JG}c4n8Jmqub<#{~0^ykCSC9Y8ma`x^bO#B$~zS>~byhA=%= zGWVfH1BZXBJZarB@wm42DJa%Yx+I-`X3`WdR&J?q_;5A3Lk13@4Q1#zoH|`q}AhoSfEHl z+_r7AbI-nNExw7o2KJ=+CO$!GnLPK{G&((QZtj~n^RP$jBHX6#tS@=`wDd4%cl3yl z%ZH<<27mrqU(FQCr>1AzSIf{K$;;I#bCE~QL&s(Hwo^@^#ga!n`?l9{@BKDUQPjMW zmvi=*N4ooOlf>lHrtgP~8U-HM;Lj$_oIKt5nZ5h=&7tP-19l3)Nf##@CYP|PskKO- zSdQ@3K#>qxRAe5o`uW9~$Csk^VKx|&dj#KRBjLw>X{rg3d1rTbAr4K_%b*+b@g52c z=7!_tpV$V~UYnz6OV1--MmY1B?pG$9W`%&ZpP|PibNmw(i_{%OFp@{Av52HnOYiFTntQkq8xF<1;)e3R(g3 z2;Hj10a?WnEN-~k4;vIUY$WDNP!v$AgSta63$5R3DBxq4KoSFhgoBaNz0e0`(*+bW z_lmXpKHPg4*%@%r$k>>dlg{jTB@Yl=5``x1;3)fXettgvSo;r@!XTatYm$#rD8ZHm z%7jO>{P2|!Yzpa?RY0-ktukcM5TSzs<*jDx#?HY}2QV(Sv3SMi$3rzuo*SPfO;@{A znkc6qIV~x7p*x#h(_^MXIKwlI;RM&Y|FGWcGxyQbbqqdz6i&(e<9lqg8JFwU!sDm? zL{*Nf-`Mn$LYRlv(yiNP`gcG?15|0r;yXK9{LSW#x{m*Ln0^)K6!OQ2@dAbOwX|DM z>&_gxGayI&V&E+Efkk4R^B_A5nrC$=&oMH3f^B)BeMO@-M@L|XBa?@(55;ug!p_kJA|#B(-|pPwJb74e0wkxE3b5J-eIpvuMoZNME+ z2x7`ovWnXE%)Ludw;Jik?Qw!vE{J5SR>k`Sd*?-o`POp(fuqkX3jT*4?sbOmO)0|@ zz^k3IY-v&bp%kl0p_9UClbYXH1bp@gh5$hqc^nWmyot!p{$luT0>Jw*z;a;P3BtE_ zBZC~yFBGu^76WWb0b?IZOL(G)Egv30H5wlGXU}@bD2cVgl{9?Uq9ggZxwuHJ2;-0F z$n8ey1~7b}9C$4F2Jf68#yDgK96FnX2`5FU7nlVNw;85=m+x~O0z1yYp@zJR zeuT9ozi;zSufmWY#Af;I3Bd-@!&Tdg*b?R6VisnuQ77#9ZPKUi3-Ji1deKi#nf16- zRZ?Bu{L96naj^4-FAn_>oTRbF4trgipWnT&5aDh?snjT(H1U~}LY>8^R7}C6)TpoH zr@gMpr8PwnQxA7l=dw)PWxs5CgyOlTMp5VM;@~i0*R@Ggvyz6Ewo1FCZd#5fTig$% zj-5Di)8uYDT$$IVU%NA=U~B=PaS$aWgdBndjYfMX;G|?sI#LftfmUhWaY7RdQ*i<6 z&T}w(q0l8s3?TZ!*nNrQ-II0qw-nBLJp8<&T?Z7nVi~#iV*q` z56o-2W==N_x*Ub%a{yWBg!R)xD-aa}mrtn9R0Cn#U-<|f{&EzEh1mSSJ}`nCXleV2 zj1$n#sqW{Mm|2MNiez9Awo;5@;%i`@gvfyw4OUeXGp1f#D)$~eT89fF=k0`kKzIto zD8Q4CVpPEY!`OR(_1wRI|JnPpv)5%rgj8mOtc#G5q>_=Sj8v$UvUgUpl_*JCgs5aS zC{#v9gQgi3Dy{$H%=P`B%>Y@F#U6BM zd-DTWl8FXexJW#)NJENA?Td$Y1UHDPEKU@o3h7s5z|Y&&J3(_rU`>LYQuO`%B6uI- zi*{OjX_O3nS=K>6@)B$8AqZ_~fmU(*8->`%lk` zjQ78Ik^D97McBfMup_DV7p_DZO%9?{T&6W@%RXjx8q^BR{rHKw75FRQtadspY{ z{eWkkK9t@)c6rLECy6(HUMajAFtYS?yT^r*wEbx69Aj5;cW2NE<&~n97MYHb_ElN& z2{YoDC)@e@Fmp9$S*O%{9WvY)zDu=20}Iz^x4ZkbB&*I)V^H9r0D|~IP_yZ@XQLkh zO2m$c8nU_h%ZhU!()T77?~?>U5${V$!Sq+%?Eb9tgGbfSpKoPmBBV2c@w1!e#;o%x zcrU}IHmC&?pQ=68tf%hkgb0cC?z!}dH(Yjqf9w1Kl2#8sfT6u_wb$=joOf4@V(ca# z^T#aoGj;no`B&XFxp$tR2eUl$C1isagbrH%#82xihMyuV-HDCOi@IM5rXP0W2BPSP z*3w7!^LRL{Pq`_r2|^LD(WfTsDaokjI3H%7B_Wt;Aej2%^0Qv7NVkdAj}70c3>gxi zlG2*3!Qx|pu+L`xxC-5$Xf&m)#*1z4TL#RcdILYaoWC>bWU-x3Zu1B0Z-rMqZMQW@ zYvh;(W~01aikpac0`ber)oYm=T>kKNR+-oFH*_3jlI7vOGX1gs&SzaB7Y@(JU$*Pl z&+PLRg`r>Og$Vimec>yYl!MSs)4zNjdD!HRUgFEJ;uDRAeAMK*jW<7_700;^`LqrJ z`F-+ai?1;+2|HMr-CDi(^pT@Sfh6oQU&OL-5&8(EiNY%-t1T_xVPi@agZOrEc|?g; zOE;Uy-wPHGsaP?~`=#agd%6UU*+a_*mcw{@hIafxX%S}@xIwGQg6x^f z7LJk9HwFLof2W;Vzq~mve_K1<%8wTfE+uyV6)>~^;L#4Jjvbq~_w5jM?~{|x-&~~7 zM()?8hH57jV*v3mZ>%DB4iw`Fcln}gYr zbCMCW$S&DPpBL!b&Gp;Cf`RWH&iJ2?FxvDYE30Nt35@h>Kh8yUEpYB$J?rZRl~28E z+rw&85SDGN?mdFJxNGD7;J#Pwz>kRc=PJs}%1mE)9{G2= z;imupB)9T{**3j+2Yf8O@wJbN{Nkp7pH(m8?G)cM$(zhVY*e+FgR&5C%J^D$>;Lve z4cOuWD+NtEYDrKRfK7wWwTrT{^%E8iJ&WG?)Z;Y2D5zNAL{BOj{Oa*l_a{b6;L&g; zf=xC;Ya4y0FzVYI1&Wz7GaMG+Xj-;H*t^-65Y1B(%T6L<=cxuYBBzCDi2z-;2GLu# z-^41bFeq(_t_)($nIa#}YKdr=gFOka6W|-o?|14c5J5wQCwAQ?>j{^ zrlo2AvwYZzk!<`EFBhOLKH0)!_SJ+q@qov`;FWjb26!iD96I-+y<=ccUAly^2HZfi zW2622{TW4fXOR=b+LcFP8a(BF%)h?!L|(LM<6f?M;`bQ1y0WZD2G3v{C4@RPkYOTJ zomVk#Iu!g%agJiDKMYt4&!i^mf1OzUrX^#?)Z!&ZRL2mjNac&ULYg&|C9JZm6T0+a z8@=yq@GTTn4VRfZGH=7w9im;7MUA~XGGW*TvRR4V>jdBl_)DXA46DWHMJ@=qjXe#V z;NUm!NK;^`K|L?NiF@zrz{;iYqhO29uB2BJyQo@QA#mucmMMSJX)tBAR6m*cD2DMn z1Wf9=pzCQbUd%Ewa?CVy09s-0miNnZVEY>Im$A#;K?omZL#a+pc}tA9q|R&9-EkBBfN}IIP^)cF zw%2?Py1}%ncZd1%%mr;>FDy4xfyzxo4>nRPgK{zInKK8EtR85^IUe>Ol6zgy_s-1L zrmdG@8w=vFj$Ylq>k2XZKoq<8ZU{xJSZKgdSaEG5&KIyH%cAb@ga|}E@4}$un2YxG zbyc_sJir!mKB{~ylgIi$E27d7lM0rjiIE%Jpp}@cWM?lC&l7oR3mJCKpTAq;LH`f1 zwSXpsZcD+VG;(CbYi;-^7@vnb7DC2h%9@;NqRM>C=63lG#PGbQ$Ypoa`$6@gxqtWm zeae!IuV234patVa(Xx3&(_Rm3WZkpvTsQ|l%yc-7;`D>fE4b9_VwE3^*u>p&$e1k1 zg{CRC9q>7Ihf~EcJ$&V7hP8m#N3*aA*liftpmR)})xZ1wq2Gi|2uTyzkRC#MuG_c0 zaM%@XJFZHb;>->SV<0^I`4xQ}JGe-v>DjI%uEpyUM?^{=K)OwFhX%t8Cbu8Nd%!SI z$cIdoyl$i|nX}Qj>80|DiX3cH+n*gYXb|k=Mr_5zjFbW(H=(sSA2BJ#rU8N~C)mo; zQsEts9ySWFa$q%BsqQ)o>*%M~D+nFvjguG@iE$fJb9p4OwjgS>lmN$`xGB@8QxXpb zArO}zA&HUYaMV?Q-_X2n}P6~QjB{&3dLwjue(V8f}%66obH%N1jnm@_= z`gJ? z-7fF8wNja_*Or`rf$IhB1AOuhP9MtC{hz_!*%=ubnYVovLqa&=bto+^Z*gJU=#5GT z3k$!zk(=01Xp$F{VhMtP3kNi-Apya1PAt_A<5I(%N@a*C51nVT+L5{;@c(|97N9*Z zyj{VpAis;qx|!X>fuV-u1jBGyE;Gsac!-DS>EZFDTSu%%T0ykrke-DZ?}4H-o1Bn! zd!tIlH4Ls8c9RnBQ+$dii5#Y3)JWj7*@&q9a8#iEf`jHQG13?>mrsK_`o}U@YyLtZg?`*FUJ^Q!)w%t zrXBPKG9mu&ts&_^Du#D%OXx zG8V0klqfiB)u8|&>A|^r&vBL3r#Wsk&{kSQb|TQhhV0Gu?pUvDBnh+&59a1^`e?x& z32lS!ug26w_&^|@!v3dI5*Hg7+Y^ZLv=0ijWkUSAyXYNg^>PT@Vid56MT}vjuu1zm z-lem<3(sK5&s8^(T8QSKL*`tn^oZnz+n{QRhmAf4z(=U zpu%pCDbyjk@^N2b_rj1Mto|hqxUl_oplkD1n6o`=bMeX_3!7pV+4Z6Zfe4l;t3<)i z0>$bRmnj(+!f6EE6PqL;bL=LC$BBXhPS?>xN(JaxT1hyeNI=@>0sZ^q@YaN94+9XDW<5pV z12K>>3q&)b3EKyz!U1GSp!n^~><>pE7Uu}KcVreJZi@2>cp#<kdj9rAdH86$cFp2FqZVi*U$0x7NqpAUx#ASB}RBQP};J5OL z?%%(EDf(ov3C|C1$4lXY^6-KTT4Lk?+IYm%Q-<8^M7g}>46n;a<@dz<#XK76+#rZ@ z35}*VwjC!dN!u5XVd@i7j1A+fq1a5ZvKpWO#Z;sLcb-0N>Fn$*SbL&gGWNuxBg0uv z7!b^vUL5`f>~2r11x4*ul#?2SjNWu=*PBkb4P{U^p%XW86s%a*_N>33ALsEdFqO(% z*=WS(M&$Am`v=uNl8PwD^4%mWR`4S zt>2w%_F~L2KwOHL7F~vi5|#P)d%PCjIh*F55KI6himSC1B#+o6A_=-|!a9_+0+7o_ za@HwGX8@9R8osh4mO(q`-nl~jCQbdJYU6oD@Af*vwzqh|Q`SQQ<-vto{9)~_W%rLD zKq8Td*Tt|C*e~7q;rQHAU7kAH?4gv7LT2WYFK;q-__cTjeB>Z`;%#i6Qj-wj{^q>n zT??wThuTRJdHeP$yfX@Wf&+q$&IDV)xRZ}Py^bB5O#e@qfDZ(C?40>)&#qlU0uq8d zIa+OLLMw^OfhiO4`$;vUv8E;gI^>v(k07!Gsx+*9P$-$JcRtmk9Bw#%G07B|dM#fZ z-LSN_6wsG|QheNFKRx z=`)u}wOF`YUy`a&(-A0forK~?X#*XA@`PzScII0kd@Cy}OC*GY2qhe1A>+8CQBa&e zq=jTnOr)~!L*qzIu?HG-TOQ@%z~N{GcqImECL3z5P4JtIP->7?hgGXqoocc3Nn)au zDAedOrsNrv`=h1RjSxmdmE~HYLT2j?qDEE4CphWZzIE$yJT5qi^<{W^^{38Bw-&?S zync-d%E_N);n<)i9PA}~9k7nZ8%#{tY8~_b7F1ArRaEb2lDG!A@!bJw!y*WY)+QK~ zM7mQi5;?o6bHYA$E1Uf`F)r@HeCPHZI*1b(k(2LtIJ%iuVvtcGyIflC-;ui`{B#gaBFuqrLy4mY??=M-@p83 z_i`^A@r$R)4y|vdTg}GA;j}Dp5_ndq!!O3>P#KF!9(85Jx%bzZL=krbd*-AEXIyL60TvO+IPmFt196h&M|7=4C&BM^r0 z+&JHcZEy}s!O7*q$dk74W9`x@Rhp@>RmxE{COZEgO!}-v$$e@j@ zKJwGrc;sYm=}uG+e?CjL4RVj`ui6G*Yt)0kGA)Q1ky^j@Q~vp7*NQ3sah-v*b?ETP z^_d%I&whsL<#61;J5ZU@YxJ%9jElkT(__3fJ`d0S_Dv6}+Ne=Cld4xT+Hh}c5CWBr ztxDACiG>fMqkb3nY53O%r4~wmKp4ww!rL7*=s{v)vmis~%qkx1bQ+t@KkqAr7@Gg)1`d>CO zimnxX(Fl9IR@1qcm383S=i$bw;*H)Ts;Od&NH`lwh1 zHYi;Xwxj{=e#QMy#?UbT(2yhgm8YRbbSbx^zd%t?nNdF1z+eqWQ3_gEEt}%*_@-Fa z?@8vMKqm1))wrF$&&|BnY-UC0qZukbdo4`XR`ge_ifF!x3z(|q6R3XV-y1Zsm6PrcTRIrJwDP zZUn}ZVwsuHNvv$ds$3J(u|{Y#|FTt`CpakL7Q z>5qS4bSth1|U3&`lfw==g&JXW*U%-Cl;0 z5*`l{u0>(ECpeh>EJV;uGM$+Ksv#P?#v-ON>RUMvI^T zV;W99CS{24s9N64)+s8jl~o5|)1vVG?vW8e*9&s_&oP9Q>8x~5jfAu>E?@8s{ql_ZD(fQetmoT&s(TN z7@EtRkIC74T-4DEP>NkPy?Gc~qAlPLc6wq12(~5tjtiR-X!(;djtrb=)x6d~vb*K1-?odH&3(LSKK7URmkTCV|AqxwzP+1yNV?o^pt4%$yCiYDXVg_Me3B82u zAVP*G$0{5UG()*@~)`Z6yu7k(i16p(@?UN}y z#a9#1sAZ60qWg%<1PH1hun?tsr|}8QbTC`<=nFt5t z<^K|S?n(LD`-EC)7rkUvC8d^lk2U$D|Aq3uhN>E!%)&L^Gz*ERw<3?H^D2U;C9-;6 zUF4sC{9gCM(_kWxPN$G2p*CRp)CUJZmg#P}Yu z=LG67c>Z#ZAeWNFMN57o-L4r>8|#i3^We6*^kb?YGr!MK<&2x{(Tg*venr1*vgPP> zFbi35-?&+`-YCd0UT=o`zH!hpT7Dci!(TbK?YuRFmKP^KuN!|&AD?uUE(sG#4UHGW zO3mByY{R9c%>>TTQy5 z$++`$DVG>s9}~?0(?CEbDkczrhQ1?5kH(>S60i`HOI^R&NG6tc3Qn)pRejSZ%d%g? zCIYvL;72px<0_rd=7_9U1N>7Etb!JV3Y4i$$3MQ^oD-2f(}4@<$=824{D{!baBSJz z_DvgzF99eZf-H;##xaV3Izw9_?&XPI--=BUvGvo@=>ag?`+(+_m>9RK8>Md}V4(1^ z`2Oy$-PJu;OAG#wai@-N<)3vwL57cSvy9F8=5a}H>xMdhsHc#0vOM6$`dUYbxTXI$ z70-5c>bOdtxji&SurvQ5iUTT?jaQrw0k#jpmh;V7<{jS7{msIv^M(r}Y~~|Tf!QK5 zZ{|rGetoghSZlm%pv50&!QW{E8gsy`uQg;L)6{?o0kb*rmoKob_n>a&bgOGmCagH` z`zI4Kmy)gtN5@6#o%yV*h8t9D;F#hJ+Uc6{#=Gy|#r%A`eZv#+vEH_=K5vA|w?hl# zrnV{%GybjL7~I6P_nZBCrFN{&77X5rk!ge6_|ZFe?lfIfR8W>a1$^>P=#lsn+*c-H z|LxBM{)a!OOr47v(BYGlrdk{C{W$8TQ^E8Kt-rc&%08#F&^b5n-aPx@>6x%B^05;r zU=2)Et~qTwjn1#Rf%k>7Anexs$LG(UeRs*rnUVN$KBas%nW1x{#i^?yuFnBM_gH*F`9J(16msWxz-Qb{|}x^`4;Z@;@nbNYmxL});@Oa5aHsq zO4Su)1EY2x@=#{DHT=98cAy?Dr zw#z6my{i^)WlIJdgoGq!sXom4*GiNx<8TW3l@?*g>`-ziC_w3us&XSnI5LQ5r(U^< zb`PEcl)y5;D{;=By^4LDLWYMekyy`kmrlYvpO)&?Ur+r1eW?DQ?n`H9p59m^&qTQ0 z^!ryL`>w6i6Vn&=?x=iJO_}v&MmlZGi@5_!9`9V>WRug$+N5Lj+hm2dX3tUzwyUjm zyl^mbq4N3L)hmBL`8xEA->^423lhvj4Cgq_D2T1pbjf+6Mw*~85kQ1m4Zs^1%c$AcXLlHY>B}Au z+%<_OXRFg1o*cTkuDBbZY?kB;CgQPg-#)!t{Ww=q2rI~yhX4-*G^gu=SDh!WcyCGp zC=e#p-vps6ZFUw-OkmajB)quVf)`Nr!qWq5i(UKo=kFg=Se8*LW;4LVvMK~HLK~j0 zUa&qJ>T1fLx_wB$DM^~-g6!EXWf(3-+-*i$$NAEugBMtP-gd3pz({Zg)iX~|6lx5G zX(!cpgJ>K5B1v>%!_KV_8eD|Mv=0iRhX6zkvtOQbtbK<_H&^`%xJ5(KT`RuQF9-%M znu9O5MbDh)qx_%e+*oshK|Fd)7niwWA|ys?_>KM1u_> zwTl>@P?2InoJV(k(QOqNo;Y2zFJoC|lv5PfM@07K^spTUy=9oj@KUN+@Fm$*LrDUL zX;*N@LDs*qes41OC;3%m$&N0o6&4}Akrt|ZcN~JlAC)rRhQ9#d)`4mKQ>HM&tfxX0 z;p~{gh^^u#K*S2eBO7AG_sDNSKo+xEQ^GqIEjXooEWIEamj$8P3I{Zc8^bLkgklN0 zYu`R!AD{Qx-abV+at3Gxsh@sT>puQM73})j1xQTT z_L_P0)jJxxv0}XfitIgq<^!iz-(s&qL9&%GFZ!?AK>?|35>vl3b& z+3?lZQ>&=vdGp`;s=fC+x9;w`KJsCG{}n&_6z(vw+1Xv|=>-*|rGLl89<5Wai(5?Z zd)q_ypRS`>&U*Jw)1yjyw3ymAB4SHTw0lML$Z=EVdNx~}v!cA%qC?>OS8OobaqpwY zBJD*B;#1ognazw%-T5XpxLf#&Mkb}VuD-MERGQOp?6u4XThcU>t$m_&??0K_P=Ps? zwBgb=VWe}@(wX*Sma*}k*#W*ZFOTzgtrSCpM~{}E@RbKk1~|&mgdb$+NZ^G)cAwa> zG3Cm<5VOaEAcF?82S>yoj3=d`?J|6y7}{V;w2dW~GBl#pt-}V0_?6l~+_-V*ryXv6#dd}ekc9;)A=wdzc7p(WJXRw~yfM{Z$ChZVE_;IltZW6M?vplAzLPwlgm}}?u`lDXi z7O}L}A{YLCr?KYmzMG=+H7ff$Z>|1^N7#!9lRx0{%I1!hFDaqtZyVUHAe zi5nO4XIU?My|}7aTRCW2&@|2B;Wi}?+Rmxdw|)DAUFJVoa5N*U$AkQ)%F0IXOI)KJ zA3kx&AEWj(>2l)LtlrvQQlvqy%gN6-^!=R9`kS2SIoDUt4>u07_0G7}{@dBJcq0O^ zdJMh`3&u2Zeb7JgM64Cl#wyOG@dI^de-F^)0Xz-q$!KI1X!Ym5)9i61T;-F7)0-k# z!ID4dNoi6=(#ob*wE&%%y``XdBi*W_q8|QRwRYbfPG#6`bZeJA8P2~-5)Wi`FfM#w zIBC&=LWM7xS9`Q{7@?Wotlt@if%9*#tP@#UI3l>FzuCtoE1url(ep$3yugW`P5Z^1 zU%K*oGo7l&&wj-tSH8D5B$-(@CjuqY$Q1? z3+zI5z%%P>*z|Pz{NnRZnY7|fUb9aB%OreMV%FYbDGv#wQU4b<|TbqW@=l~n_!pK$I06U6Kkm%?d$M0fMS+6fFnF($h6 z=+T}VnH%<^5p8FAs9`Hb#Yxb?S?=a+c?Lj6Jh7;RWD6J`h8|I6nK#W%m&4~3?>8`B zhzXSsN*96pAZKj`5lhf1g07~m!%76w#^IKt3){|&867NCZS#2_c6Jli+s$>K6^X#p zyfi!c6~C`(@CI@Oz?F7&nR(6?IQ#;3Zr|R3!pVX`3Pd(lY>i01%T_k|NJ9#CgMJ89 zN<8b#U*8Yj?djRxbKtywfENj$tN7$O{AY9{g-+Gg-7+Qc37a{1 zl=4~_{)+<`^}*h9fUlvys?Gb^pEOpluIx1W?;-Qo{aw~h<;~h9A9mUPDAYOrMq6Wg z+`d7pGd1pJJ>2{>KhNFI@twh6-TeC|TTE>eq||4T!tY;CP14d@X0FcFeOhk2_Vnoq z@h!7ocR8JQI4PxxVf4p)7rIP&-mU#)kFcWV^G`3m5~4c&Os9$!UC$^f72T__P4_gf z*Hcqv=jih}9V_@Dr*DpbWZz4xTg;TA@Uo#ttqou9>;5y%W-%IXXMnJDzk=6IfDtUhp&y zv=bFnlg5qv&$JcGfY1R3ItbH8I5>#;blc$Oh3M| z8}nDVEP@NdDIbca#(e&);D?lM|B&!3(<4+-Q8b7DDhVPeGW~ zY1q=nG7^>-pfBL;1Y{3WHB;{d!*7t$nwx#zArS zGpc-o`u2!>lK+0eL^ZYdONWMARg_-&V7X>((u%u|A?c>OcJG*4YPGg~kCsOjojjhU z4>a)@(PX;kCAW1cgU=ne3=OL@regA#8U!$+=7)AUH!7{aa#NhNE*CbP344RMw+y*G zu#k@38P-M^T%75XdJfC{gE^p6Q&EnE>7o#jp)*SXiCDxye;%c`m=OAWudpuT&LoJ0LdwaPZf-?x5!VADh5(c^Dp{i>x>D#e-|?nxU-2bFre+3#hCv1yIfC6F81i2dyC?R|)c?e| zm4^yn=~j-fepvV_HlTk-tt(DZuJ4%ZW&YHl!Di87fMoILi0S~`NwP_vMn*>Opu$0R z0j~s3?(nw5T5L|@U%Y6);`8#)CCj;}BWyAm52rv*ta+e{``2Ml&+U8nCbrd|(SKXn zjcV`jA2Px$mUR9XQ|mtwt45K014zdj+X%Vv_s`z_`(GT(B5Mkb?cz;k8spk6q03)> zbXUkjG>dNAq(_W{9>o5~mue1Lj9`Q`z&BRR>z>7P62X)3@Cp{BSWQh{w(NrDLEXl^ zr~C)Un=pWBOQJ~r;l^?vV~tCtdt+b%b&OieV;C$V*=Q}ygoh97oMp1d*iHMFQLKZ# zgM$*33KXfD_G#gEd5_drzB(MWf3rLs_H4jJ zTtlHErjY*rAEzCV_lR8BZ)*F?K|xGJct`f1U#bABdn(Vp_vXz>q^Gz=jEB{X>2N(( zU+!l*Ge|`x!1U%Y1xUiugTW;CR{bDoo{+TQgyy1jC9pWd~Jr8-9zjX^i21I07?hS6t?jMb?uea)gb z2oH*|bgECUUN$bX6;O6EL>1{3d0FhJ=fS#c~Y+`oVS-jlUmY?GZ*Po`;hgO3tYLkS zX9bIlJ=d;jfW4pAaqCX6%l+;I^BDJ<5u<+X?J%Sb+mEmja41ZF&ydOm=!QEV3eO+M zv(PJ7vZ=4dWe|^HN&(U89Y+s4B>G1i9%6IEW9%eTWC<(KkC-+IKcyw%t*ybm3XUpIa88PdFPP~|UqY^JUk)zU}6mw8Y6^{W>=<)F2Fb$(1+CH-S(Yk47+JDGUe!7UeT^~8z! zn+sF*t>Jl!OD|wYs_&Gxu2nzhK@V=WU>=L1R;_Z6iHQkouo#;H2LF9v`++`q7+znO3L*YncG=F~kk@*zsR{*y-!P2_RzIZ~kSS@$Ohlk0B zDoS=zhSGMkD2Lun2uIyy3(z=cO(U1IsHYu0n2 z?k|lpWF{{Q_%hB_55HFL^qPcsTKw*;$S=oOh1%B#2xXgo*#8G-C1}5+sHsyYZ71c??mJ$I**AC52KOGnM;b zLUqJ42SED*2TvS&a2H9aH{^lakv_e)_s=W#IMChAJ+^JQt=hGX)^(j0T+y%ZJ?YHR zI6_mKLAA(WO;5y%f*NJT=wMkr)~xh{^YTzvQ-&dUMTvHYY_yZ7bY8IOV(w7O^e2xV zHRa(YKLI%re9Z5usHu678{dEZ%2=%tPV>#0HG|@`@9M&1Dkc)-%{TJWaf5HM36FmQ z_oA0aTQV!ybd2tgLqX`SoQv8Y~fh zrqzo}?I|*Z?Vuo@-b{c&{3gIp*$`cQdv|7Jr{)dK4-+5)MfAc4AplJ#JqSX~?F?V} zx=n)KyOU(Z$j_%voigWueL3gl7F2Aq2oCm1D$*h>V`Rf3nG1TAbXhs7GH4x&D5|k*0yqQb{y*)~|QU$$>`v>|x9q+cQPxlZV>8ANKr{dXtkf z;C$egOe7uURO}np_btUG`84UI#$M1Dg!u=QF1gWW3uBj8U(BIoq3_K#@9R=2i4r=_ zwquEhad}O?!_$|ae;z+^qKG&ukFRWe=Q)64eO}e-WQ;2nd${w{V)`~0)t zZbK~aO=BRe$csZKQREKrGk)HA?Kg1lr3W1D-Oz=xRgwF^r`*YbS`5!pvy^|53!AHZsb*t2Ea2 zBr=J0H&@nP>GBjG4Gx6Wv`@10qi1ZPxzC2MS{)ElQCjr zu$!g!w7g}X!)q+M-*A?5umxcBI_{ygnfn7;?bevKC`@* z$)*GijrP<9l<%YlP#NJf(YnCuLR`Y|V_yu6`c7$2vyR~cPn?8X-UuM!l0kqj`KZUM ziB%-xTsow)@ZZGf6U9U}EIHN(=wfsMi3er@73=HJ#anG7k;Totx@6L+!{@24s<8k1Mnj?tRfv$G;j6{@o=2y_CzmPU(WA0@0P?3Sg20KD za~Uw}d_xWE=Vz(E1z9V2(v6JHxua@lU`0~Tm0Y#L`^*_>Kj9vzuXwqS;B@HZN$Zsw zZ=#&+S*|>VYMvGDROKDN^=9!g*AXg^Tok4os*c|mF_#=q{MfrOwQsKn&lczBm83Un z)3BG`z@S7%K$FbFSla2_+}_JW%y^|d$j7&FEs ze~9r^|G&O|FTV6Up&~4N=&`**xYBH(Q+4=Q*@u@uV|5ctOxn=~=IFaNQ))S4Uitax zc5K2nGB!TVa&Ag-HE=hZCy*1$_p9ELI12woVoAE*?Uldkj&U3FYfa|i0l z+R@no!UB=06z2oHLS!C1;yk@);o^bd0`oF|J3{&tw?i2P5)Lhi%3xa3ZcuI$eP6Q; zm}Um3Hs#j36-3rYa1O)5!yh1?U>G~tENU0TF6tH0%Yu<}6Q~vb^7$y6YqX zYI*bS9_Q1YgMl4sY3+Z0n#)c2_%f&qagn9gqST;_5i#+CTWjwk5=zihfa^DtB9H94 z^l}JxdxSo5tLCS$4pn1+bOb*H&JP4w_0{Pe?d|2hrdVIY6XGP59nHyBk~joK)fq2E zov7>cj_!QOno5j1qyhkHE22`i!*RH?y5(3TU>6t0XdgX#6tcNguDE8&0$fl~KcEV+ zl4iU>#hJ)kX51w!(pk%kJEc_W#P&Y)u{^AuN32fb*$CjZ5VE1G5Tz&-bJ0+8>XrcS zk~?|d(0gP@ zo(}lyt9QLsP2Gm|Cd5BDr>=JYOGppD)IlM!*K{}Q2H=)(+@*SES!qY>gUL;bek{0s za74qL?N{Tcx-2YF9Nbs^%do^_DvPp@d_3J)JNDqXdXDDFD<({gZQ!F$2_*$cdV*NaV^%noXH51+6MUDmX+sDGvA1IWjAkFBi4MDRg&jNF$rvJ%CQMnT zo+-;L2nbjDLb!)z6ou8-zRnt-bnMg1GahblRdaNC>e%leTN&i%gPAN^y?Xyti~Y=M zrv*%~T4va@$pHP(R(><9m#QmRuj?FH`X&Bb$m+C^Umx}xpP3XN6SqA*dbti2nwDYR zKkN5)oeRBw{Q|8E!y-ROW{RcIqyl7GZ13lv@UvlH&ZtzSG^8gowJ9{#6}v%gHy|*BZBI=5M}peU$14 z4TY2C!}Hw7g{ZDAGt$&d-@RwgvhsP&`dUh|X?5;_L9pow5pgi3?yNLCV5U!92%oCh z&&A%3VFGT;={3J$Mi;=?-YPSyT+K~|!)6q{L4z&Vo(~snYo+fQmD6}u)2iYe2hfr8 zhW@kO`X9Aj_Qi!}XKBLOI&l;Hq(<4+sxRWaL#$Z$zYgC}r^#mj(@_iE?dW>uThdA-?%S=6du=e%-{IaV}-zoq63q;j^{qDnk zeE)o9?H8Pz@bH1U?vdHtBcH|Z9UUX&&^`av#ZEC@>+BZ$_j#YwHoGZ>-WXE7(R*aW zPA8IY-#>2?_`_83$3|(E5O#}=)61V=n>KL7>oFkViTFnl( zGWq>3RxfiKhrBHNisL#TlNeuTXvgN_QvvGxjO+j^-m~S=f8XJ*_`g*s>bK{sm_ABw zozwVD#0j7K@xyE%t@S9TGu*Mi6;Qw6be}`f2J;tP# zMRMHl=xyfS&w1;w$TdzfiP$&Kd2PXj?*U7)N&}-8WjJZQy}`KAtI+8{M<0cD2d=H6 z9)MuD3@(JeOIN7jth7FBn5EMx5}~ZX_(8_k_9asMm9yCeV)G(E#L|rQ1j5+PH-xuL zGCH4P>Bua20O?-PS!qUI9!Krc#jUzr3DmTB(|=qp9G9lHnZ0ZFVD&*E*Us#W9HYQf z8Rqi3?&#k)28L~X(tYOsr=?1VJ?A>!)b4P>_4s{z^XwBpHXr@*BPMZw_sw@4=3LZL z-algb1=h6=$!TX140IkYLa>U z(2R^(w$sXo`rlF2!mfc!9#=M+_(K}HV)d*aRg>^-o$!DRm>Z}_OlEaaD2@U-zs14# zTAG=&?DfE{@2wKNc-H8 zlN!!{)yb%PVx+}rLiinqoB>}Wm#H}34eMHS%G<=U{D=4KnFAIrwpqU4H+^yZ^{BkG zkh`JYIUn9eXvVm`)19<>@WMgQ>m1zBXorjECcXLgFK1l7(@{q$_=1&!V`j^vE$n@7 zyQ_qK9PP33qzJiX0k%2$HZ72BLiO!>u= zf#5{VV)y=D1HfGl$I##q!$1m=N8`;!v?3KH&=TE^bR0+>uskkJUbVA%r+ZLrRY!~f zuNu?O;>PL>TJ`qgKMw-ks#U8y@I5w16%A(aZ^0Ci7i@|fU-PablOw*0zbtEWE*b>1 z5#$cEi~9J)gTLu*X>^@J{}Z1y6+x(H{h#%I{-MtwGHP1Xjv!x0FLyh1&*>Hw%93_PC4A^?22o}`#J{P@&D_2=+&O1l|w$jiIf(Qdn|ri z9*+fXVV@%%DI+&o+rvksT-fmCvzR3VBu%0w1B+J#9Wv)Xl#g0#O_*HKxbJHlpFR+b znT#WFlWajVFvy(J5Zh^dcSyfm0e|2uQ#U+v><%(g@M7M=zFJymNMDjb=qF(gBRO5oQ?;1ui=-O^x>dbZwf`-Ea@ntn7Y9t^i!GX6{OU1c*?YRD z9s$i9USAm+HnG97%hOb54v)IwS>!O#zHjI8W*t5a{MgC#X>w|5PM_bdryo4+;ISq& zq06mbv#m!5bxS<;e9o(ImF@c*2W%RV^}E8)bM|h-Q>EeqAjT{7mJQl$=1?(c0LYx3c+#iZLNiDgT|GvG$Btu|;)t=F+E|??rCt@Xs{uKFeQm>zFh>5d&OE z_u{f#lldcfTTb+XLysRncAuhs_D)ClmeOfo**N8rS@dXXQzTHuVb|&31wn*im2!E$ zn$y|;I>E!NWM+Our&EjE$NQt5pSo7MoYFR2uzvmZ1D>0bO5cCZJMPfu?$Y8DmDAqm zPj7W9;JnSZs2k^kyBP?syHnZ+}ZMAjyM}OxFojdzqEj>~iwYB}r zVOPFyZ_vTn(ee1ctz|=L^}z9w*c!1q638rm(a?5I6^u6lqno2%ACNNmiHdRfJDyl! zXeD&%Z3jL{vD(hc8PijmTkc!&n*Tj?wL7U%!PE5dfh1JN0SCG8PUbT+lOB_s!Y!Ze zR$Mi@POI;;dqM%oCP%dyb@Zf$&(^E;ydPIM-#cEocZrj$sb7Jlp;r0_Ch#)T&r zM;g|AKUgnPX<9(irJ1aUGp znkp77rqq2qx>hh{^dadrdigl&C!G?t>n$<%iwB)K&ayEkqkcSQgN#BI&diwSYp}{?p#RSHI56?Nh%GR&9Oy^rFYguiBlwx8v!f4v9`o)FdE4z%b5T1xEYN zlqq*#zb+5^Wi_;8glpAk3?%!ty?aD#>9nHIS^yatbvFa>*tzy= zO3O_l$5aM zjENJYIz|P;Ig!~P%PM-@a%UoJuGF)aP0~YP7_Ap8W(Caln_Q+pQLK7zuKxG7Y!c7k^K-r0?dyk|!K^*s2z!pf;DdoaVrp~z&*#$z;9Lo~QDJdzbxdIa?*qr+7*AM4>Q3*VX zZJXVxuawb#Afn9e{#G7+Az8}joaS$8d})5a<87w1HkxkOwz{(1-_?cjU{`jDfHUOW zcoZwc4*6q5#K@bAYZ`D#M>_u+#Jz3s2xN?z4^xApg>K#XF5q)}UPAY2o)w^W4i=mU znG{XVF!z)uyD)teINQ`pC;h6b#tqK|e_z#orU(s>^ubdBcR)b>egg;YMfqvRu=A2( z&?8WK8JwM+Qze#-kd%VZ-CF>Yak)1#f$w2oWv-l5DC2m*l1ML9lV?Z??d z2ZL)ZM)4Hi4Q_wjXmrjubpG|6oE%x-hZW0WE*#zLC8M_L+oie^)Xrz-8pLF}Y2rS_ z$dDkZGdlx@=n@*keu)@f37v$YTbwsoUXA%vr~_5A zX$?hT&UI|Y?y#W}JDI}_gk>RwwyjIyFVGpCmphAO^uq|-!-8m>gF&;bMG>t7Hwbfmq)+p_h8S#(msBKdE#zR?IPU54sg4Mq4=NtDRu5EwRUaVYyo|hh8fi5 z{@b?|w@ZIi37i56QnB1g|IHQ8w+lo1hX+4+Y;v>egYTNcc9J-?57egK zGH*7k>HpBZkqe4omv%}_vq_(pyyJ2GUmvQU@3*1mWx9KmyEBKE{(F@enb5|Lg_6mA zGNnd<4& zrXPyt1nnFN7@acDH-2}^+-vpOR~6Eu z+_btZr=@7*`CQWq`h%2tRQY)cYh?~@aL7C@N zi^G=<0t}cUv}V{zu)!+y@Lo9?LUMhyeaH5C?`)smI9k5Hk@vY1HXcjfX7TpL@eYo1 z4!89J?baZC-;9C{13$t|r*<21a8p;hGpd;DML&rI)6D8UzchC*5vt_Tz!1&Zfe+$s z9hyDnG_N7{<*ZC;Ip!rI5T0>y!iP+7bP6zmO@Xw&UvfsnBCKcfG9!_T%xFVwijGXR z@KMnx$UPLNsmH0xU-j2Y_k8Q)Xq??qsHPVA!`jHSc#}{aijbX&b(qLKQ zuj1l!OrjZ#9;WC_aT6K z6zF%*!s`lSBDd5V>vqGg#jIJX{Zy2^4R|2Loq#I0)9S<$RPGr*?{3{ZOVg>;MrASg6_b9}4uk z?nKrTYp+STbSk`Au+LUpwaEkptY%Y5?p*Hrbj&9En%kD=&;6riw5GX9LG{Lmx7Y1` zK7MDOjyQ?yPd_ot;sd%n;orTc$5(wJjs=)+&c%I|;0sUrpJt+=R z%zj_EY)}^P`?xNy7NFgGH)cT~zTS;BFbx?Ry57h3%k$j?E;~?sOufcY(ZFZ; zTJL}czz20&t$zw6POW86PCw)jQr|H_sM(rS6m8TrmNP5 z@MOiq45=1#2<3XkHPJ7QZ09}Fwyygm^J)Dh|1oh0Rb0~|?+{&dBhcCiJgwO3)hpri zG9DL8SWki%L3n$2#vxNuF5YF;xLL^VD5=h|Y-G5jAXXZ*DT~d`g})Zy@W{DppD0Y z`%mKDVU+(+x80VB}S!^6k7BD^g^r_M27caG5YC7SkuxY6@??hFOnK>t;j|+|6wA z*q)9t$m(=cO`uy3%*B-)oT`?_$vK2Sd?AF?c z$KS~wNTK2|^cHu<5ruDYH4vDZd}h8twm!nIj#J!#x<+yUecbW2%^C_B3d=zyb@g@$ zdJU^!Ez6{lVeA>sc!v)g+(N*?)L16bGepA@Mwa083Nba+WAgJS>Q~AF1o>K8uG44E z;5Kgo8 zT_StM5a=e(SSoAS$eJ%l2LGMgVv-y5J1)o$$~*@Bc}$&`67~;NHP3i!_XBJ=o0+3h z-@XkX?@B#MdX(1`B$ZGiJVZcuXqXRV!hPdLqgqBxO;u^!%aE4NnRSAvJGE>!Z=Pn{ z0hQ3l*B*Xbj1@B0F}XUUX8nEZS3-r|x6c+AJjOy>VygQ)-PG>q257RQ7yLzsO6Rb4 z)TpxIR=Z{g9PBA_v2#Wrem2~qzw_*{5%Kr#)n#mLpv?cwk)fry3trZWjS7;J&t!}fwS06@-J5$ zi?Vgo@YYm^+2-=0dIVkrT%4wZN`L*zmt}Fj_M+orkDKd{zoA1$5&mmFmyTeYmly8WoQDJf||}m&KfjF2|;y9w}a_ zGV|xpcu=w&G!(;dLZ-Rt)7aSUc;!9z?P7bIb6r&N&CS8Q8|RY;O$>a}K3jj-hP@xZ_xNPB^tW8n%!AkLK-vnZh|beB{VBEGEDZLF?Y=~tTQ_8*;|7L;ciJh@>xe*2y7Mux>hRZb1}EKzwoyI1J)+8%KH|bJ*ct11hf)`#x>V-Oga}`WJukVzQH1Q#q&+%nT2E$2MITP?p**q?Q zB=U&;Eke`a9>IV(&d--h{`h4F>UYXdGvC*{7dpOg;Bg|LB;(Ak2ydoLjJ38<1yO(t z%aZTV`C-)Tt@qx&dy5>HhKfZQrO>7s{!&J_oPO~n(?t{iD&7N*ARQO#?5-?%SW3e# zOV#Mun%p6Cif^NEjQLOcbk2y-#y#qr+^*avCGf@BeoYk>6@77FCno^jtuzfmc+Dt( z4-b10Z~5K3ci)rEuc1Zqy2=40bPTX-&c}q-Jgl&QGbEnz{ zp*5X{B)GOPU7TSv9dlYFB_#>5Pgo!8^W4(Qa$C+-Xw|KoEXT&;?9TWc51q)o3>f_T zw#E!Gi#jgRW@yKKIr_?;k9PL6D)Gn`FvC`J@777@Rj;qsgaASlFT0-0nomb52`xY~ zRgJPs1yD?4^}!MO=OH|)Zf+)I)|@Y=%1h61KCWMH5FhtAW=6{mnbWmLzJ5E)>P@@g zm@#LJR~RbxFLAW7^Rf5?#%X?DicX`J8{j|j)QslcRQGQ>R@W;3?U4};^B%`tHuP`Z zS#bt4=IO4_KZP%O%g*q+aHAv?Q%cL;?rB@NGHO`ynz>c2^Elyw`PhfIH-<28=nlgj z552u?tlk3?xTDe)14fBQ|_oQCjmvsvEq z0M0H|VG7kTiPU^pZ~MirW$zo|=8A`yA5|P$(LP?SwFux4!Sv$%YG_4gv`Cij&)$GpW&fKxiMKKW*rN(qZ6k5)5qdzW?hTH z8#nmq@%vJ<@~8jQ0=c^)r6#T%KXt>j1h7w_Q0t24%4?T>$+nA8?G-}$+P;0FkJ<+- z_0AA$r{jVI|9YxVkXKXp*LjIaURtN1Cex#qBrI6dV=`zFHH@%Bz&F~NL^Fkj2)dQR zP-m9ICpWcg#?v9_6ZeKBe`+u5a?m{-gYu>2nFB{kcoJjAMAj`$C;s0g7s?lgH?yPb zqswhXt21$`Ylofw-MkMUBsc=#NV`Ca!(>@JPTMlj^H&W2eT|;?ECaw(nyd_s|WUGlF{biE*}E z3?=wAvVAG);U#ji9=x*q$tw>bzOh`ZnyKio#*X1sL$v9evRkzv7Y0?@@Y!W z(P-{D{x=JZ7nozW2wkNQ;)9Ok5}k+X_Ktn{@KV!DdS2@tqp6 z&cv^XnK=SCWxD;>((KcGhK^mjB(lW_tX5Z1dAHLy-9@%>P)3hMZRT7-w4=bqJ1KJ` zi5pPl2nw!G&z{1Kw~iW~cERLuqKsUl0fkUweVH>j>w56BTJ@H_bvybd38zgJ14D;7 zH`E1YJT~LQ{^-L(!aT5trW`pB(?sE;1@ta_9o*H+R;?0aF%3D)s1AX-=nF{c z_oEVMdHcocFm+S4DpIcJqE#231GM3N41Iul6yX*q6Oy>R40zgZC4JP?gaR~w&F)ws zgdrL(F{M~3QE0dK-sBcXKG*ly!`j;~nQvnU@bQ~carxnK5DOOq1MHg3cRB*kP`IM>#ekuu-haEJBlyzVomHX)tQ;u)=iz~+9XrGt@BRCy~r_qj7 z;#x%UI`hlxmVHc9eeM0O9Y0q6raHT_&#h$Nr^9GgDD6duipIjtG!`}8_9hX|%n*ZK zS_8WyZ5{JATk7bPDbon8X)(fMBJ>CTR}Khqh`zjSzp~yyU%&U5G2Pi!IIy7N9D*bI zXqZLm@lr(z>Ga0*_0i{Jn+Y_L>l;~cX7p$$EDp%)+M`QG;NX@j(_^#?T^$M!$18W2 z++8n8SPj}Dgm}lbjd>V(%JW%Dlmbu~9T5kM28TW2h97GAx^<6%46HIP9Jn5K9#{aj zNdjP^)3|Zb%{D9PIx1i>L@y=6%N{*)3ajqDYemLtPD7d6ANko3)lmJEIzck z42kInp(JwN4=%2z2#(>7)=NCjuy#ctRdF4%BI!{vSD2mGEbHmIXNUnVc6d<#I9lL_8V|VtEpb?>m~Z&VcG^inDG$sp6oFfZowW4GM%@4&wo%_B-y-`ED-@%ps{64++8Qrx$C<^$>eF6POPCNrp9RoOGtkTCHtRcnToB(H>5eCtiE!I+xARPd z^BuX)(V2zY`)s;Gan)+~`SB$Q)a+v4@#|1aftf!;5W#VoCEWF&UT7NU5={qCnxxyB zzs$+|5YQL+6Z_y9>`CM}CO>&{kxD~m?J|;*ZRRwoVTZI4YS{HsJESc=j(w!I(rUIu z%A)2*%RRTZo(IeXxm1h7@Emv?b6!r+J=aHldwF?*2k`*qq-%5z&H)<1@cWRTFgsW;Iuq?vJ0aa0 zHta^cm#54@Qv(C{irsxQ;r_yZf?C}wQ?s8BHWR?DbV=hodvQ3=)njh#|sK?Yg<2r{GbXD zPPxjd8u9a9hD{qM5w+xYyoG~P_0;Xw8uzx2W%kcmjeWpExH|5i>F#)1YjW_>aRqz& zx=GDa*L7VIs_itw(UW*EVPWRzW`S}PPHLv%@}p#Xd=vlg+&=LCRpl-XYSp#VFRfp9 z36F^;-CIW%RBEIbQ+JgkJwjOzYrquUWJypkvSQCQ1nVB_J??^DW+yoJX=) z`VYl-m_jV6-Nd9MR>Z^-o@Rec&Vo^e01$n?a4Fl0fFYDLl7jz5rw$b)I5G8-6&4ml zm|lrH-F-OfxwBP*CUm(Zbn9 z-7UN|hTn`a0xxHGJP$Qf2CN5)fS^pAR8|xoS`UW3_vX!wJLgAo?hT?&3*@5+q&u;& z`cQ24=1VVDGYG-}E}-!7-{R`#Es`0kSB#+l+@6B(G#C>yGw% zA5^aox&9+vO+f5~CzHXg9za48^LNDJW4tc$fYYjJe5WOYP z|F2skZO`7@SXB8yda#6;)+JP9FaQD`LWes(=T0NSb3SxB{Vp}^! zfy+V<2o&)4#qC-3W0>wBU{jh+fa{=?6MB546}%a7bg+}6e&k7;zp7s@@t;V3rMifS z8q=%v%NK|tk9wz`s#X?>1UosdaW6(?r z;|D-dd`3pcNI(@wQ1aLV0V_U4XF+(vSWXq7{@og{*VK;R=kXySa zyBBYFIgt-*kw9?13V%O!ANx$;4V;|BP_&OBsW1te+W3xj52i4qmcMJ8|Ksz`w7&B$ zwxcNuKre%r>*LO(!#+Mw$VS&cK74JKEZ)4<)^{)x@ZQWjR zGPfUKZzn#RaAYS+<`a@o!*Zs2S;^*0y!uM!pkzV&c_KsLzvJYGwM~9idXo>&m^EE> zW-uDK;*YuRC!Ylh?bw~%&=JQ8<48}B?qSnztDEZXR`>}smSyb4?6ef^ku6wR`r9s!9ghWX@E9J!kASGAcR!#^ko0_~RB6;1<*VM~*{uuP04 zkQY8vOziMzE2zvR^n7)*Eh^oGtJfxH40l}Dq03u9mRKSL7hzFkjc{EXUwFBL-#qj{ zwut}Ry7gzLq*89arASN(%!-VOSNoNwyP-IvH|xhDV?!yVu^7t2h7{f(%tOhz&MKcO zK(dK+ZT5&rHfHuCV&=ilA8i{58ziAy222Vyv>0Q9GFIsP50w7J>to%D*S8~L=zyv+ zFNWkjquj~Ydrd(@j>TDc!)JGG&H0-OT+jT&i1G*0!l_FKtNV2!^5A*zOqNh;oqux{ z))E*@{%+Ett>d^geJF02RM<*F=)TOc!o|6^U5DY8-bG2V`3^z!A?id?_Yj>oV{rSU z-K?DYJKwFZ5NF0~o;zvNX=0p4?wz$c4+4T-tWr09u(5BZYdYC$z%_Vc1nKQ2M4MB7Xi0-0kZ1gtI8&@#9Tfe$e5aH|j4qOn^3Za}|Lz zukur6tMxQIxKW$H@nK$tfK6`E<32k@ot}7J)KiHSI%Q#uCa2C}&Ahz~3Pqd;P^^WX z$?piZ__*m}Bb~2Hu~MnX{o>sgJw%g`~rIU8Y)Qo zXuX~i7SS<$iJ3hl*EaH+i}ZMkDOE+ z1u?S*k<`JXM)JRq&Hau|*!KS{_Wc|M*xeX@Ws%DHW!^QFRj(Qk<}V@97_G%EtMpxP z%b;^;NJKmWjjD*BViaM8kkurgp7IKsk<$$$aSCl$lZ5B_iV+$bPwtvFq(z!KC%JXo zhc1m*TA#V3YF~nex_T?xRIe&=D{*qJq>N}{HF7M3!JW;hSaSQ$9ZL`uQG6!f?AZWc z#sa}wJPW)tXyJ9g6t%v+-%qHX9tBL&r|NEcL-RQgovk48!NjR+R)WPQR&w0VMrlB5cM9qN!|~%|CY97t z+6rMI>S|#+Cx?i->8sJo?Z2PyQ6;HHrAh6ZEUBsT29oOq_Zd8n);(-=$kX}mbJB-W zwvdHmedfvs+gGm^djDKA+^NnUUI8i3{9qmJ<_;kr-G8*diU zO8@kjH2KEbU0aUz-!)NkNtlw!9g`4svPwW46l|>_pP-+zIyg-#?b|bv*Ng(YJ;ljwy(|K zm8_Vhmz&!De<08t2Gwo+9c2|YMqne8EB>!2D_rPaiPDt|T#y~C^R4Z302IS#WX;0Y zNL_8Q8nb%2wRJ{r{$$i|?7Ze{b6c22%IsL)Fty#1oNL>Ay*G=RwQGLTf)1O5mSi8g zeKKW;lh)Ded(3tzo7_Bm_N?uP&(25q#*{xI=8KN{I%{m&Rgm&@&iBBrZ#_=!cr@-w zovl*Yf`%Xwp=D=B>4d-U7qU~4B@_`8oH@|H&vEqfE!6v0gb2aUjxQ|Mc*}hF7(>!rOWAMi3tHVSfZ!h9ksNRGX{~jPt z)6UE5=XKn3Vw*YpK^ug%ZO#H7q?oH_H! zjy>wH;<`B`on`c~9P-$mUkq{H1e0Pm;T7+-7_a9BQ ztjBotiocO7@eyVUt+C(HxtE{ea~yoU>ow&m$;RtLTX+l&Ozbs7&&7Y=`Pu;&QwJ}; z8-uY$^gfgq$mevw>!RHh$>0(cdSv&9O@DJ1D;#H-Br1&NxZP$iJqu*OpNeJq9U87d zV4BLHL2j@Bir$=}hj1R z*_e0B?jB*a^M(Ag@{2+CKleBPSEZx-o=D5`18jX3g#^!45Q(h1!-KpcMYJ;B6E_IQnlUw{Y>rHJ` zFgv$|)))1ps4O*V!BfPDJse(4vN#0TF^>~cj2D#&A>aoN~VTi>#cqucgu%k z*#>FL;(TY+DLUgp&|AOb;G+Q&b~{Si zz5A>!*Q^mt{Yj#?VFy}1jC=4v)XM16$N-Who(9p5uiX>3d`z1se z08O*Vm*V;QQSY$E5-*g3iNn7Zy#I9!G3VoXlhp8%m$OcyM~@jv9lo3x=`eauy2F96 zZ{4(UBL9`;zwJP&^F#a2(Fwgf*(PkN`Q+6v;`4i#1)GBw{#Ezz&2{aCYX%yorB{79 zx zvhYv|_KsFZIQ8ew&FFMCm#_3dOjCz%78(%#E3D-*&d>htkNEjP{ng{a)lDfM#|NGl z`ux=|4nn<2IMr250|O9q`>sJQQK@}?d(u1h3)CZ6Zv2A>yLkOHR7q&>^*{9(V?TsH znMXhP0gYUx=kKG)#eTJg-tU%A@*_Q8PJQmXH)RVnG1lQSo~fC8@zJTVtv8z}dS5?=yPowAN;^(#4lY&Uih#ZYZbFu=?STGxfQJXH9!tznNMa zsb~DO{zXp3L}9Ia*e!G34qLtH7y{@oa7wHsp}A$^?$3DICCH74sC+me@j_1Y3q+I+ z?&hy-S!$Fq-~v zy~@%LxB4X(Qh$Ji@A0*H*0K0hb}w0{QI<>de@5g=UV3#(^W&)2Tcfu6x|M!151B2& zKh@l@pFj0!S1Y8~hbmXpT*J{$?+abs%8RZ4eFVqO8ewp7O z)>pEv5)W*eJ9n;Y_Vq(O2MrRe$iENT|FwR8x~4Jtx_zdLd*|*y)-K7(a<_>NId^JB z*E;p2^exMKh7Q&%*zhBc`RwzA1@61f^|jHC`#kQ??_1ug3wm-08uaQ@bfZW#uL7Cw znzZWkvp|m-sVR;O5Kn?32WotL_X?111ad1Oex}TpAN}SVN8?U^fAjPCQ}*uL$5%}j zxA3Mb^yY$}7V6_`%O-J6mrtLO3?n7*)_;GY|Gi{&BNRbzWuk`lKQ^~klTP2hn40k{ z=2c<(ckdGt2A!n_heL|Nwe=Y~^e<`YLp?Kt=U)aW%6hH4k*z9Rem6 zR6f3el(;`tRbH?csR@2YB9R1M0FQ9$kA2R?96gBL#)#_-2&<#)pFeJRRupXk?l{eW zdphoqW;WToyu6+}|2djH4j)+c=Smk_qxb)m+Ay;pmpc^K{)6s^m;Tv6aB{tceeZwa zzsMPQ7T(wm^M_()^?JJ^;`Hbp|9mjN$>OY-YgLzZI5GD{6X~L+HSi2?U!r`yoq5sA zZyo#o{{_yF=6J| z_yf)@n@rx1PmSHT!+NLQnPJF%yPVh*?S9bx{F3|+sNM_0kF8N#*mQj5jU#Hvos=;Q z*i@oh3%;PAnXacd`SOcq%$L`g5SfbVLmAreSG9oD2G(JO4^tT$SRC>B|QS zs~c6z1po9gFTVryi(U{Sd19(fPDBc|VrlqxU<684W`U+A$o4i6_8wtE-{)&H4+qEc zyn^C^jjL7RoSuIVPDtM!{a5E6`UDP&)QONFtCa@6JtzDcA=ucDU!+R(8xWT{2jQ=n zI>vfRtf#v{=C{(Z{n8-{FSaECZvo7jolYM}SrjBfN25Xhi6{j~r05Fz4vgzt{9`JK zK0weA)FSNXF;|V-&>6ftJnlRwzuA2W*pNWxeio^ULW%Hn(=@9M9M}#d5A5FNh78R) z*pgIsBPNRpK=~|j0R!-9QuGJUY|7OOASb2eIe%xJt<}!EZKBQA>1n-w^=j^$t6eZf zo&Oaf{yTnle`9y#VDw!A@1@JW$lg$Io5T~CLo?rO#&krN`DA;>`WkiWPQMC|F-$Q-q{4mNq;sl`Ri$Xe`b@B&@EJdwNf=2db@kTpa+ zDKoZC(z6ltgZy|o(407Z;X-26j0aAhahL<(w@W?TF?U$<>6j0t8gG8x!F6xApvCg! zXa|BC@U({xtC}i>FH5s+UUH348lL`Sjv?_-sHeSS8WNs7aVL)qXW#@@Fivfh2Fxbk zb@S%Up+17BzqcVltr%5&lNju$&KF9`-DH>m2u}-{Z*FN>{U~t+5esOIz@CH+O8=CW zDmF+KNaMHalc&H0p|-^-~Yf4sa3VE zKWRJSRIBh6(7$lh@-&3w2&Y;fjS`|WQnQ2=?B8_aMbGG&@gN{;aw1bwECM##E?K`A%RP`2&W~?K;a26 zCLq?`ebv#cki!hcjIOr#;z-EM zeFol>h$H1Om>P75(pU5|npx6i&d&l5??9veAf(kjYD*_T=_88)9W+P7PYURT;aOu# zt=%k#DCn7Jclq0CKsb7XX4$uVzh$8sMOCSn_}ozr4Yc~Cc5erv0>F!h+R*sp@buzM zEUJ4&3B8{EAF1R5%GoxnX{f`0&n}v=>pI5($3gG@qZLtr@kP~^{yxl$OD$gJtEs4l zzF7}p1H=raYIbDk9{V#F@*^wr`?pPgU|y?Mon7D2Qv$t6b&n#W^RvgSU!;1l-pO0G z&79GNc1ap~4S&qnV@fci44%L+pTP0`RR3I_Vu>A9`+eB_z80GfsXu?9!ib13Ek(B$ zn0cGvlV7{EO@$FMtmEjEXZA$kE&k2FEYTmLO1L|_S*B}fs9LJ3Hss2CoUr=Q@a9PH zHk7p@EQID&cT+aU1pxvHESDej7E-2t(DDdL7)foNOKg?!Nh4BCjL}f4xlSnta^`gS77<<;(8`+C4EDG%)&DjF#z? zM~|hCYuiXZUX<0j)`=V*L!q^x(_D1!Gk0AA>Q)L;%?%bg!+`?Sp{x6Wi=+Rz`*d{b zu>na?<%jAQCb@R)B{NNxd>rN#pH`Tg-(}|`!S@I9=5*l_?gd~+{TQd?Nax$Y$R>}-aI#Yi@?qxS4|D7|G zSFh4kJ$&Ck?_-0t)T>v{-pXT}?JT?aPIK6=kfbFax3p{LG|r7bVKr2D=V8sNEwWvt z`nVT{Z2a7}``C7U+>0XTUq1h=Ky9?XL7rRJ;+F1x2DP8M-E#_wNm|Z2K2r|0OT3ZR z7-*TW;fL+;TU~vphud|m$@j^*&l@e|^|k!;_vIHNlaLi#i7c_ps4Zxa`;%_qha{6O zNUm=*26M2NczodW7FCE4o0YQ`1WkDpUnsri6mATtb;(MTH6Jo-URnB{eC~bp=wuKF z>a?C1r*N5-W7s);eDe6KjvsWNv+HYef} z)U1no5zUPeM}vS6_*Uc`F$|}=wny6Ts|!#Fmqk>$+eTuZ6-tipW-@X0?!!lqK82PA z)Bh7JNTfzxtN0dIs!U@J;RJ8{w}@#Jq1VR`;mlZxV@Mdl*yzxs!hh@frWAM+kn0@{ zCa}7ghN|}|ikMjJcy}AW=^`88-iw*+GWWpD-s}8a>_7NyZ@W`A;2C1HHXplKt^H3R?Q(@=a=8cLvv0(ifZ(nDZ0mfa|p7{`eV3dYl>0F<> zG;q&9(NX&>?|B|RohmQAdHj-DNxF@*;$J;F=&u}Jp6ViXD|Ai!m=9mJMvXlp6?1TR z@WAYll{HTyF1Wh2AK2|f^0`*-lUJ^kAL-N9H_$8SMR|$6yzb|`N1yh$aQZnkbl`|y z``nz>E7pAM@!`AOgR~rMCH0q30HVD|0!QtijFURcKwTmUv zZ-@c6rkzMwq*4)jH*s20h!vVfBS5Q&yxUVR!CCZ(-PH1pzj;BM%stel2ad|zN- zky++k;Q@9DDZBE2VHdtrH{Au6L+cKptzNdk^XHSWR1Z z=)RP203n~omn&l7(Nv@P7E{phNc_c@1VO|ecx*;C2qf1xGp-|QC@cEf^2eCy&~|d$ z{wY6{*z|M`$kmp7EV8PwLL&opUnOu2EaB<59CGC-tW_Zkh7B@#vkuH=Q~@K6k&=^B~*IeN)H2eVEK7m`zeU zy4V#Cb9!|y&^@wuLyxK@b``fR3-?TpIWQ=Cm->RVK+Q*U`o~6fw>NsY$mWw;?^Ub5 z8~J*s*ggAv{dPmRK|se#4zVGpV~n4f>ly4CWV>pu;=;q9UYy^$`A7ZH=Fy9%pI0uF zoSv!p!(slY&tFsu-A5&D`OtexeqP=Wj*o?;o$q{UVY3%ruu7O^sPCY~(bM5BMd=_w`#X{1M1p*lx`#vha#dk<# z^d8L-y_@UJk3xPSpl~=}Ndht#TS-5DpnDhfm$0o9cmT|O`1(L5Ws1kY_SLRmkzbBX z%TVjxJDoKM#LfBAoMx}Ls_IU0YcaJ)Ld3r!QWHMks?sV(NkM5MXc_u47hNGRY7*tJ zeQ|*Ij);`q|CYf>IA&DrSK2g2CVWYC)j9JoIgvVGM@2XR#EeKbnp{nTF`-d{gNE~W zNPGM|812z9PESb(FZGr!Tc*QVvB>C`K{JG>kRn9P`Q{gn@F~g0T_ipf%UK4pUYJ%Y z)d!*?&%$RXM3j615t7Z%4!~F1@x<~A=~g2*6tn@sh9|E=fVO-|*1~Cq=fF`IH`57# zWqD|=@uvL5_K+V&=K>GPHcoB2QZEtkOw^$Hhp+uWEh@fLUzQBYpLYo9z)FMD6JcLS z*>LB^g@WlElwWGwE6JmWaX|~U@gnW)Z;^I3+*o(L?AMUb@j7qvt%eWXso$+ZBmB(q z*l+CyZ69agSC%y6O5_O_e}^2q-p)Bs^H;1nP&uK@L?*dR?)dS0f3;mX-s__C;B8Sp z2Tz}LPP2Y?&#(D+=k~o4I?ak_tN+K|L`&J2g3lR-dI1e|(w(4v%YVO}y{URgCthS~`OoQQ#Rpn@&8m_5+h)E<^3y}OF zuP0NY52?en)55@Io|@{2dWefpgn}SnRI`bprkgpL^jP}kfM3E5ipef2Hglt49O%Ih zt_V7|Eg&$k+ct%Qw>Dkk=Q5~845#LTHe`Ms!UbyMuKQe>#fEB)zn~P}+J&H4`~)IS ze6ZECXU}FB7>sc8ahgxzPBX3?{w;V(Xi|2zsd^i^#a7m|OL>3m4ukU#?w# z?b^kN-7T@tbqPuhzjS1H>l-PL!;&6dc^6d~J^69^@{Eqd%$C*6kYAA8@~X?E?C7Y; zGLe2s2@gM?FPi_~X8P?&X_v*htwO6@M#gvggbQR#d}azC5(Lc1$*4PnLD0br)y*g8e4!WIV$BjOVBj9x)S3t_X5<(AjGp?JMnCMpS+X%@iC^@8;J~51fL_OMW@-9b4yGZrh>F3p7NBojXSlU1cgC-Lc;lo z%LzLWA<5^ymtI5sI-eVGCU|#K0|D)!^ z@aj}wRh{TUbE8dqIgeh2N&5yR{+VN_xX}IHv`)#&r7Nm6l9QQnVxO-cwBX5+AIve7 ze{(s@dp*XyswKHIQWhz{+cRS6#T@~&8XFS7eK4KW$;5a<*szr?S{Fqc7(B3eaQeXw zPwUdBkJ1m8jac>6s!~1bmAvn+=vg(<3+~rS=S+#8%3!Tchh3z z0S$n_L)j&o9o&dkqc5k~*ICRMCt~B~DqBV_@!&DCzfchI?}acK^%xFD;QWbV;)BVa z4@qBhM&_=;7a+zo3nE5j<)O)YaCeEw=^dn2wXOQ-7Jf%QKsh$eGpGm^7oF*{r7UM# z?gujOI#S-U*so*na`2aJT>XC#Ph3cAPmbCyHEEiw`S1CRC!bw>_r7#~&!oIB?RyLu zS^g>_LZ)A9gAY$_w>C4qsFuFfv8TlL2F3o@Pbx=bC-3p8$}jgl`e#7n>(pNZ8WWS% zKDi(CFV3>xJ8*=4gzKujM|U6FZt^)3FI|>oTjXb-FGTyT8Id%J9q`A9 zd4D)7#bDD1amu>a=W=3+&<)^N;nJnu$ourkWT|W!FO)3Gq=`v210QkA=9GLBlHSmD zf{wmOY7Ej z^VY%t^L)l(_gSJW#V}0mKt+A6F(2}knQFWX!w#SYiP_@#_1RWBiay(>(%Fc~U7U^- zblsdGRP@x>W6AMH947cD9PNx+26Yn??g|QA5Hmp4l1lN~qWh^@*s;f4$ZdF0|1`}E?M+*CjR z%8rNj3_tVm@tzY3L=l(&%*&6>=xMXz)UL*pagR^W4tSVrdh1Y6_0cwuru{#ID8D@~ zJ^L#IGKSe&P5%WtnpGF)MBT%a76V*ZNnJ+G@IG>6SM1Uu#Y-ITAiVq&+u1`6A_0EJ zT>jiNH&fefPb&(_s*tHc?_^rc|IBgAOII#gh&7R0iOERChfSn~mxFUCU%A7SV~Bsy zUE!Z>%2X6HI!G(K$l9!IHF$E{;@7>(7KJxufI2}hJuUiho%{HMhml2cM9Fuf%; zCHBsYNk?byX)&iRu<*l&VKf&aatSXyIf5#^dNpU~7@Qe7fzweKr)oMH5T)|7_es1h zhx<&apULLZHBKqRKFT8@q+$F-unB1618!dvRLH`K9=>TRz(IqO=CJ$d*LH*8} zr8z@lfSeSA?+$)#`5oM8B<3oZIZ{+d$bw*PSc5#f>XRQ6H5z(+-P$}haMJ9Pr_Glw+sU%+e~kY~X$Eu* zIN234=e% zN=w5P)?Y$KsGwJ1;t9}A zq)k#&SEmYcB@P1d7?m{ID>fRAR6H~F3*(v2G>si1(yA~jQF#e*kr;{-_xk6n zAKiKZJ>#=7_e*^J`sd+oqj4N#uOhIS`2dD5^3_zEW>DHUqx9nX>e!=){?>*V@7@-0 zCZg!17kr-m{{Xwk83x6GPf{gE<5GN$^EwCYRtnZerTO*BG)iU?ZsKwdtovc zF%{%DJU+2$U~x%}jhJZ{e?9EhEl0nU!F%jak#!AIl8ko{y6or=CvleyYp|GszFVZU zv1%Sp(GaP|5w)e^0iVwBihVwfZ5r#cj$BQJc6&RXZC7rbM|K`iphPj({kKd12kopMNjL zBn~cm)M_aeCs24~%m@;bRIg35YwPoi$MBm>(84fKHoA1CjzMKLeZD5|tJ{B07U)s` z#x}O`#0-bBhTY^>7&Yr75J|3$p~R+;S23fRD*1(^0Qeo1ZZF+B2JHH2CAv9Hi*kwL z8-srRVcu!SUQJQ4_|(KON3fw^^R;fbd|6{%s7U7(&fPQdUbg{@t1>q|B<6djy#~`RdNUiZXUwCJQ|qs z1Yck8J_{}tc<_o@v=1_Ac(8(IoqLiw2GdIayTH#A82Wg(=eS({XZ5}>c@Bl+0zv~1@d=8ioSyBJt4yt^sxmpZp`KfvgE zw5AhNS^Q{2mhLQMFLMtFY??GAl;;wLXSBvp`JnO>LN@NkGtZwtZ&o6PX91BO`&lAJ z4FMeNgcRI~O!XMp)cM(ce_%5@INbO$W(@`|F}LB(b}!57@WBm-O5II4Mj6tM24{J2 z6*MjBgd|#O*l6XMO>2rSElhK1?{L#h5RwbFML900DXcXxYC@=0C(pP~!O2B~ojKQz zOV6cQD!EDAabN`^27vRGIs^9u1;j3DUyc(ok?pPKY##yA!bf#1$LnuW^a&+)8`GvBlUQN|kudVll6s-<-Tg|5Cb@)TTx#d$Q#vMC>o^5i0|M@KmflKr-WKFdH*_=Rb&xV~8R1g@;j;oxp`c zcBpH$kZ^c<%RYJZXyCO-=H$^uCT8*g5P0DB7S265agOQ@5EOhu7x?0c;{DF;o3`QO zQ}v_fq^F}AnZj!Cp>r#rNitni5!Mv%{k5ZJx?uCb&CNZ3^kL&oyWBZ*dVFb~6dgX$ zv`eq$k^>id`T-PoKCnF2T5Ixh8`W{)*Sm$69Xb$n-8)k)(PyQ++rhC@d(K}gH>Fc& z*(n3VcQli(t=)Y$rPb%9?R0%Nj@)BjN(;mhy@_Q4AYHb4^-O{@hYlY;CD>>#{(f#I5m7;3NWsBe++>Ur73Jk(%Jcd$ zJA}v&D&Bwm2_P9fVckx@EsGP?vqWndrGm)n9Dj?HBYVG~qeK5w2O#)L4@oTz5*ubxd2+GjMVPdEn9|`pj+lOM|JknTlu$*eYXit)@imkCp>|2i8RBhZ=@;= zy;Ls9PI)R*5~bRIbSt;1hr&;<@!4UXo0ILf>&UM&RPHFwRAL(*fxFHQHlnmXXVkyq z>sP@n@GMGmqtn23Xzi02L7CiML0w%PfqN|2qX^+|Ns02<~x+@Zq0j0N(E?>Iwb z4jize@IZP_toRDKWWKFGu(-o6PiNnsTN@j;Q*ShH)ozI5D2HFtrKyOdNST`yY*dLT zQ9MCCJv}rmV%zal81BaP4)78=BJ%-(4up5u@>4*4M2SKvNaZq*jMTU-0Dc0xnccVD zI-3-g#-K8N3C0ns(+nN zfEiyO3z&eNSPF83mvrX}WD9Iz_ zD3+0lfOpzcQVt$3bUFEDGb}j+{I> zgIfg?KTj@Bm+wr9g{GnG4SZ@lzTNb$A zH|l>3gJmn`9kM;R973~=3J8A0r0`h8Mu@HJy2UU<$iKB$Ck^d$m4dA2MC`Pbf_M08v1T)BM;$ zZt8AbylXB~-Nt=Pjutt+T;?#>*t%vQB153{fEtnWg8hED$kd%^iXl?B3zM)4;xA%+ zB{uP(7oWzw8KW5Ht&F;e{!63=QzM3&p4J&XTGV?=W9-K-U8=G=>~-J$-xjcoYHp9+ zL6Qk%raAxqg!NlX`63t$$!Cc2Y* zb0ofQ79)e>ZdF6w%_08tp!{G13dz#a(vb~?k@fst!?Uj&t-e|#`e6s-A@tz%SPwPA z_?Je#vV6I`zvpU|$zAYXDVz7=&{(_fJ2nPBXnaw0USnlH`FYe{_AOs;`6Lx{BjAu* zhH^`T4L5G4JV~JiIsa_MVTK3*1KhtdFk;M_^F|MyrzS)Tb&-s+uH)L6;?Ocj$G@p4 zF@8E+lxmJtLV=V4;_3`&la#w%snbSf1cM`*kkAtOiTf99H~e&9uI>#zgL#`!*R}W9#0=AKi^Ys`sHJ&=W!|h)8iVWI0!_(Jo#D&Xe5be z`x^hn`Dbw-rZBW0PmNc(-FFV!diEnYT*Uaas`NvFTvgW77u~Y@OW?}nA1f&AM?U_x z#h)5?Nw=A+nyDW2xhH*GR%@w3iT1kQL5;Jgx}UW=s(jmW+|KNuHyjI1Ou}ST)}}Qy z)7kR2w?fqHs;s);ebz@j7mh62{^=HG@KJ+r;TNkl6Ruw({ez8yGv?iC1SMkT!AhpQ zGM2F@{4t7fAaSdmXU!;*AhDORuYmqtaY6BlItV!(37M8~%E&yXSvUKoO>vP8?aVuv z{Fj#F$MJDxJiPf|nj;#H&3@c@$Es(Oek{wD;`wD=TQz`BA24NObhA-g##{b)zo~=H z?Fpy9d%OzrJRYYwa<*hdmd-9g8?Y;|Id8xC1)qHj;Sy5{e+){O;Mu*%}gX>SC&$MZiPKBT5GgwQCuIG96rA6nG z+LfA~IoaB}>fMBfno*<5i=A)STP6>h+SteI`SWpA?sd&1^UL1repFZ1pE0G%d%`hq zjR*Tzb=6X|J)PLg`q{d<)*l+SzZ<8%r;GXA6ZLy4rYGz4d%9dt%5LttvI|C+WbUVA zHChw~6W99<5sHP+R}s!cdL2^K&SVUId>vLYFU^^Njo&hRP4h%o$2-Qi*LH>L58#p$ zA?x=C?kYCy>AmhTEaj;At^{+jjTQfzqdLz>%` zY>BGzii$imSF%dSJI`CE`p2qm)<10)yladKPuUlCu&U~L#)TD@nrn7;*il=3uB`D$ zzjf;#%JiBw?81Z@8f%oAy(?@#t1->})cg@@0iRsm1D?g63_tnx^mgz2D?*IN1!=vp z$$6hy9z7N%@Z3hl_AM#-Mba(ZftU=y>57=L>UkB(rNS&h033lUzI~iU1P+PhaKj?0 z2|SjaBdLPI262cO8O#4rt}hj-L}EHAYF80-iz(NIP+sb+;r(@(rQ4}fr+bor+Rlji z7Nc*=PC4qlaoneEFA6jF=eu^-JRI6^C+6CCeMiS;5}@uu9~WvVu8TVKti_g+H3~D@ zmQR^pu4(v2srJCZ4j*~FCy|&hTlEqdFt+rF}~@TpeT{osbkksTY8$gw5W=^ zZ!`Pyy2^oztCAPYD(aTgYn0>1n4+yV7s7_SmS4Bbcv$OD`P12I*^lfDqZxa3?WDdX zJlArXm3krdtK5A(!}sA;{j!V;8bS){B8?PZMXp%FLZcwQ`t^?K}4gyOhNhi<|7qtySq(2&A`ytd)YXa z596;7)>;vsUrXrPt6T3Y-qbl3DJk84DKYY-aYm5KAf1)3yE-|YS<-!nmgxu^MZd>m?EwgD;+eh2w!q#0{Zc{l{qw)IP zs>pEJ+XJer_XZr?_%3nm+ss44b=!AZ15VsB$sd0OgaNiffE!|x zC7d0z&8z2Ten^-5EP{K0F+^B~(WPD6H22QG8~_inq0?{R#-*w9+TYX4q+CyN#P>c{ zF+;e)V%}W7x=hXg&H_`h!Y*x>n9zLhP`lWUdu4howAySN-0Ja{SwqZAeWepOE%clm zdH&81^*oJP;|99<&$GV2V7tQnaILbm=oyEG_Pcdr!=cE(?#(-#>ufgRqHgQzIWs=r zwfF7U@cl&Js=W%%5w@*8VzrHLeoFcFv(|^o34cO%-w;YgnwHKGj~$f8B%^;3;h3XF zB@S3`_wlJD{}|5fY|%*J=B0{)8tN!P$0MTD=>f~>nsCD>K7Je$)9kRo(nsI;Q(U_A zZFBI$*wD$7p zkks>>t=HSMu3hh4p5HjM*C>zAYp3hEY$&Y!kZrK{$oFeohOc$MpPJa!rt|b|Et&sB z;s7#P*9M5V+#9Ph@++NOOSzh(;M+4%O2n`Anv~{;Wiba?@A?0m88xsJl`XZ9$8-Ad*WCRE^Y#;r!nGw;km;3pr4}; z{HZlFj}O%Qg@ye(d-@u&f&c7g{*he&w%DsCuhKGcpW3~TuIkX-x*sl+{rt?OzWks$ zt7B@77zmquy8>W|xde3m(HZ%=_T@W%O_gfUdy}iPNg>j}+h)n9lQ%80qXuca{`ut- zdzXpR;#Bz3w$pkUfNhO8GHO>^de!l-BjK-aPW4|=kab{hE_o{a>KPJ3-wCAl2?d`UdUYtC@`JThCcgS0lI_W-W@?X_f55v6t z%}a}d6sRMH7i_y5zCkN*q@vsH826gK5^b;9eaUB1Dd;ee!3d$4azCVu0SgB~)1f z`$kCJoErmMchEohX0G+4Hdpmd9ImU*`QUis<&3l$0T3x zRI|F+@%id*60|)B4!RFtdk2?|xY(s4I;5wkivbqIo&%f^%bkaQY#Ew7x&}=c0$f8M zk&KxVduJMv zHcjS~cwuTMd|ig6v9Ulv_jBtI-abYtVwG>6K8m**#3ourc&ePZx`t&j&b<3hA{ z%1-h6Z?#)`c}^0GMs!T_e&+i<4C7N$Vr9<_oK9@AU~;IK74_w@hz}RBJ6yzXqn#Nj z-NxT;xUK=s4-K)_%*&t$)K-Ss)yx~D`xnzVpz7RxeU-T~fP~J#wsgWT&2pm^b!3>9 zP+S@gV!B8%YuaVwBuAS&sX+tLAWEeY?e*(FolG;~x+Fh%_GO{23l z!<403KDjw*f33aBaOWJ42alfjeY5KM`kvh)4%P2Fd;QAd8P+O)+OC!}o4VB;XT5vY z>2Ez^thXCSEpJa*mXQ7UnDWbzcUJMXZ?`R&L}=zEQ%czTG43HRR*UReBp_rEs~9#A zrM2+xdQ}jFgjcU>PCksVD^tS$8#3pVNr=-$l?!Lj_V|UeldG|l+6laay!DgZ^dhc} zB#xb6kubA?&q!w(W?A=rPirkUua#%2X>Hf`SQwxf@kMd_j=3(f`VlM9mWFjo+qSy9+=!5<)EWSFfpKHp+H9SLb4T zref8^lpO{qyfr(#dmUzz`uOiPcO!=%J<{WKirb&-z%K?kYJbm<=`*uD!$)t)@~L+$ zZ`kH=`F^MgMB z#%bH_4CnX|)h>aU?cRt;1foJ*=|eXV8x?q*oDFjV&1Pb4g=?W6^#KZ2>~a^BKpLRJ z;rq5%8#m!jAf)XVIxJWWWXNgNPw)Ia&$>>*sc}=!7cZ|q@+uo+K6;L%a^?JI>L=s| zsB3%uImDYa9X01x#cCcfoF2p41W+;2s$w+l+rR9m^>WwT@Okpv?8OH>3`GT~8mb~7 ze2yU+WY-L8{TcjjF@TO6L?CS7tT#DFb?eF}iM+qy;8i>COyT20%c4x}f~*5A1F;Um z1G{$}GuT9CBuCiZrG;L<5td(}!)gGlByauIy}K(h*>LKEkCwa!I^$##)=l~$F(#Q- zd#v540A1oikFB}e?Y8$F6D`e$H?NrNIU;%XuI`!+odflc|8i4tjZfY=`#+#F>3}-E zv6kW>?k$HdK0E}5X3}r}$T*Y{bjUdB`?`?Kk^_eh^#b(+PF=cu`H?=kob+6w&zSsU zsiDos0Dye|;luoMWzQW?L8$<2F}5L9+WYY4UI(t|*j=k!pXTLV^-eOlkD>qfRVvz- zWG6;U{R7ig)#+T!EJ;auqOQ}Yb(7<73X~ae)yEq*$}6w1j6A1`B4qN;^*l$s2CY^sA2REpJ!=NYHh=nI&8n=d=$PGyLCMUMJyl6l>;XO# z1$XLvs|UK-{oEVA+rM`uNb)@%vTen~CQX{e9E24NF9ZB0JhtA$tc;3%F1_rS0hVT5 zp>*!L^<5&O7FLSiq>uwtes9 z>pA_<2dAmUAb$zjA&Qbr=-hP&f1ZvK?03AEfe1A9TYLlMi7%A2JN)khW2_|}j< z$4jU}kyeONNZ{^`RuUai-RSZN7a6KV3W=^UQ!pFWK4 z^P*n&(|rc(>PpJkEl{yD`3@eie#0hqaF?tKXZQ!90pze8<=haVnryXVMM586#|J!0J{@c^5iDH5~RsIp0?zkl92pD=IpskNrm(WOV7lHaVI~5sFK!A0)$vuf@;E zJ{GVmmVO1of~cfX(zM?*QACQ1IYhoDrTifZtoK>h%_;K{1w6st_K>=?_<3+A6lG6t z8b&+S1K@s?_MP?rR9R`a8ZpBtu14rI(Ip}iB z4b#ScFL~A+`?=Akfq=U2FEwCtMwUe8zsEn9@z^*<_k3zQ;lYESekAB))_hGMIzNC z#G^y3Bs@O2o<}-2jA2tbF5fN&Q zpD9kySAWxNsmem1<*U1$XVzM0Sr)78SaJo!x?qdOtQQ-ky4Sihugt+{+rP zyLIDsTo|>Xef`!V_oJ>&_eowulH8YF^P?OcpaZSGx+l=E2}ijY%EXqS7_(pA6}rcf zxvvt4Ms{PvkMrxF)-nFp?b{+%RFIRl$PD5(S&)#xUr8+Tb#@O89Sn92@-fN1Hb{bIV7qO>?3mP}bR8>%b%ky!gVA zDZ$DR3_qo{L^)8)2Hp+1=Mf^i@WJLqm^eCEp4xEWOTp?QWVd-xG5S z(#1{2#`X>`Ck7i$?rHUk4AfLN^|3D9IR5yr9q5`XErWq`}KOIhEvfS!F4=GPy zRJHNlE^~t5ELo)ZYc=fe!+Lv1$J<9oryHWzRkEt_#J&6je9Gq8LVt2d3I6EI`{?d_JM?gSmUdPriM2%) zBL)l-CE0L&1p~>6JsTZY1cdb=oS^#>RJDF6x>|1SP+MIVh*fT($%m^$u4a~qlt5Gy z#1XfKP(2__iFh%XeV z7?qhQP_XCa<^6$Idew>*T=$R67Q^&4_)M6vD}g+&-9oz0oG*e$Zt)24?e+#QI!`6REbOm8iSPu4L+Hftnt&h))l3E5XdrMulqX{Y{fOYZr=r;rB^u2JXvd3B+3`RgVS_Oes6R73X`? zWU7IhX#%mBx%p#FC)6~QWD_(#;PmF-SIRoO=b8k&-Rj1=< zUs$>=Hp1iOOkV>!UlEiISvv>iRpkaehZ`Z-;k^hVUO7uf8Xe(i{x{(tt5#3@-|Dm} zeDL`2jGDoE>)SWfH^)rovkEm@#e8&@S~a6{n#Szqkc&jzPV*i0X;!ME8Y!M>u|XY% zQHO-j+4VH3-z%EhwOk9^mXJh122of`&>f4gYeZBCBX$6X&{^eVyjf8rAA#}Tf}xXj z&5f}qXfchAjIu$8)AB+$t{Duhn21o(TtW}**w;)8aw!aLC64}i9#Fx+S5kyo%Y4#M zCuS6pMV%j>u@Mh1x&80nsiW@vENJcmv*i9Nw3CSSJ9g!ok>2@*K#w6wEye)Uvg$-b z^`f(9*L^I1pu+E~#IAT&^rP+I(&JIl<)@q&Z&y{lm@J-P(+?8x)SFUb(YZOCe?EV3 zMElIi5uMEZ%C2GDDGz{OjjkidBS!W-kfN+aONV0`Z2wPQk0YcP3j^?Id)7Y;$DSw5 zt%`Mkl@LL9r*|5?8acwgH)V19DsumpPv>Rb^&y-$5}ZyI{UIA|bT&Z`BlvK}xA3Gm z3e~=IRK~?Yt1ODCeCq~@(vNxyoEnmiGtDBBor6<>)lV(1-5wQv^{V4_sCGu6>SkcG z@HuEDSsXTUr!k_~t5eZukz} z*qjQ3!KRVeRZ(m~eDA_SMwAbD?Ra8nT6sT9=!P%US!n4Ld;b_393%<*$kaFwUq52L zRDfR(q__4{#wI3SVs$iZ5=Rj;0Q%cCYHMSeJXjtCq+;_cb?>k6Ufz|nqw*FMRd3%Z z46~db5OSE+)`mu`$Y0#=Ei0B| z1JB6FNR#f(Km9M|f8BK9SbCZJr%yAk))qgDCE5gUF`UHMSbGWVA(|B6fGdzB24~)g zR6aij6rhciHA%EzVf@B_#qdN+Tz|+b;Kkp>PQLY+Y6V|-K{ly&-rl|)14pINFC7o_ zqQ8@DHkaH;-}0nv@9~m>n7YX5+_;5i^?5r54lkyv$~N_%f=Jd*CG(!H?g zG>))?S?>gt+ma#Q@3G^I-+g=yG+iK{!;6b@ieIi#rOLC;2G`MVvC!V8*4o_RMve=AK}gc$#m4xuhU)Ze;kw1$J zc{{=n7TQ^GH6BEfp}|}bu2ZLc>E~O6zgb?OjA2L^95)0kYKxbOL@FrcFRy(jRIK~_ z)L2m>n4#C+2lT0Nzj~>Knr5@-acw>Q{(;Dpw3GoT6PAQZSbQoP_tjtYwR#UL+3NSR z81eWokA@}L#cjZiFcE21M_ICEXitp58Mmpae=Cfrj?*t`WIcLzfe+aE$rg?C2mZ6SwoxOsHvyq`k0Q| zn0sqKVL$qm5F{$$cAb^5#&pKi&ET^VG7Wp<;Z+)J_|jJBPnONUekZhJ2L`F0CNk7Z4~xPQQI$S5aV&*Co3Px(+9?TwrQWm1n)vfal!kXunPyCLjWX3qvf9T zHYFbK`f@wjFZ>}Na$(FWLMU=@y|5fD?+LRe_(4{n)`Ck2Ju`t?Li+-=Q2se^ayRLu zvj7)h?zjfL3aVb1HAJji_5snlj5-eS9oVtuYuEDp`v>{^8+8FPm;IodIq0P}OTN;z zKHZa5dTGH}N7qZEdZN1f`df9EF9(cAJbs=MrhGc2Pwl%om@>g?`2a(c|0$aYs_ zoHo%KHNHVqcMdj#J&|%vTlM}wYwrFA73<(Q-M0e4B*CED!OE3N$O6{5$gm{B$i!X7 zaV7(=w86d|x@O1XcfRa6)e$1wUzhfX`9#YE zN_Gj_?_b-R?@9gWHn=p)d#2my(V<)8HDs^Kcm!#Ha1l=`tVR&ZV2M2go@Tmc@N9&I!r3E&y=oYz zkEC6Aruh*g5`J{Sf!{K+#D8y{3nZEv8UmMjjGwSS3RDZICGGv)Z65_+=o^HG&n!uG zU9obqgtk~D^if~v&63M&yJNl?-_va@xz((avN&37SKpe6h^ntXJ7g_NvLjyY!qSN3 zv#rL(5~kTR#-JMssh}pd)Jt^s;YGBx_;6)Ci1B+bv9$He9N58h# z`yGCN+IvLn!M{oE8vc^njdJGO$%i?P+gV{J5ZTB{R9o3^lyKTZw`qi#*U{N|9yHn} zhn@pM$F*Z1TWG!IPQ!Xg@}__Tw&C@GrgmFd9KttBammQpivcVj%jeGhK9FX1&f;S@ zG&C@D1JXs`4HL&253mj1uiGL*mB;{WwS3?Sb#M`@un+hpsH8@Zac_C{%;+Sy^Ok3+ zWg}I-w{vKZuo#$ zG3Y?aY6+5yh|Ckft-(HpxO_oCl> zQQgZ>H2CDP(dniGl-`bB!Rvz5ZS(S+%=w~Urc*ZBPtdDWvt77uwXr8&?1k73^_0%W z;m`h`s1*0<1A0CxVQ&KTzxx*4msnq}@;7~5j6k>MR^Po>8JqLj9QxL|`0sc!t?crh z-O}WLC$WpJ$7D*vJ$0TRTjUWFvlXcQLmW?F$p#P&&zf1{)GftPcpIhMZETKm%Fn|NpE`N?(Zbvs9F6&b$T zrS$riXj~BAv_(F}hBA1|Kf96R!*lOaO^1&=Tc6RL@du8NZM7^C=k95yqQQs34@38w zuRC5rD*?p6po%xiakP6#bhGdi8aPXB{hf-iCf zY4J2S*w_s1yLV;ZDf4TXcB7)A9v#BJ_|yVa1Ga2IFlvOtkyB`;8|B;$?nJ#fdUT4# zW4^FN*%XC+m47;I0j+jrOsk=nl6`H);-Gt}Z^C=1j8AVs;{uF8(4zK*CmM;B?Dr&h zBm9)vj0Vn_1`;j@w&bDC6EY-1ljPP((}t}8Bn*#;ZP6iB7XE5K)5*d1$EfbQ0Z&-U zyN0tC#IX#pD1LH2+%!1z?aZofj&VzSh`&bMmn

a}Ll+n*nA-E(Yuxcp2)B!?{5`Z@BIdF#Kjz&Bk6I|Pr&=f0heO;9IW zEQy~V3+K>86)KEh#-h=3#Qq^<&#dbuzPgWVS4<3L;E+yG9uwz*Z!yZ9ytCgZynvA53S_Y12yuYC3_NRA12DL zsd}WJEQshN8e)~g@+>SnCPxR1`)H^^5idiG*^t_)C@NJG+0Wn!%mSv}XGYw=e@fgh z_+vj=-vH&OF_0dz;}!;h47*_9)~2IuT&{%dV-pjkj@)atdrBNQ=Z8uj@FC!I!k)6cC!69DCB&cOD*T4dmk>bIwn* z5x^Iof4$6!XGh}bkX=Qn8o-Jlv8|0}>D~plJQhM{u({-iX3a=W(HpPGz_XDrXCJOB z8}|?m)*g%l0HTFYwbZ%{Mju!fW)RAOC!2XRPFhOJ>{G&4+=4COw*kOI4M8&2X|$u% zjIdrBO;&lVL6#|B)u&H)A^s3XOTSkS^#k(hX!H&?*#`31KJ>LFmJ=hsAzCpwMJA}l z3Fy=2!zc1F2V8>&_{q5y>8a)4h=vX@#cMEl1g<1O9++ut{xs0WlgR@C08Ti7i`VG* zC6IfC{5KIu_z-t~5v#3`I)dv%A$!-*rih*@V%AGOSBeEDz5_@}` zk6<$))U(at(INmVrWk-Dd^9xXp0eGXy>0MB31ih3|D6uByD73zqTpKb(#^KRVm9CN z>NiA~G;V0cxL#qFFyHRmL9_g2OP9U{V~Y<;!X`~aT^ZWcFE3fNC=AUcDNcAX`b#C6 z2m(spRHS(hPA$UvL{Mu_^^2dYo^&m)fwW%QubUglBE~ARYzR;W)}YRZY@&1(@5)#| zxJ_DHTXXS(!7tth3JVPK4{A}hnHUjzL8(9-p!h>jN5ZC;UOl*gZ0&>j;x4M~b|eJg zGXaTrHoq@3@m-YsKfs*Jpi+A?Cj<#M%9Z6PDqEr&v|(&wnDS!>|M6?_dl?2M+GbQQ z&%J&#PJF}&7W6_Ij*}bNg%zHQHL>L_pH5g{r^8SyszKk6#{`iT(|g7ho2lF25`8YK z{E`CI7=ata{Ezq3*xa1)HHpxII){`5%TEbm`0Cy?w~*8VX>Vb2I&7APXU?dgiU&(N z2qNC#BtSyHh7(e0@bU^nI8G`sR3mq!oyE}vKExFgIu->E5T^@ibHPKdf>av2z!hLY z#Z`CyJWe+H#?{m#nxx?VHLO6if+%aT3)c$G^Z$i0AJ`x0492M1+&+^#(%{ROEp(HB zx=ww~!fA+ke4sgdUh0Kz^M$EU>1d&r&=ANIl>kJIDC6!g`IK?@T}`R;tP%&54%dGe z9i4Krjk2~)$iHEv9sA%X1lAw>KM?E@=}=umfgOztNq7J56n-HR0~Db2yd!Kx2MGn) zDGvOgwWH%Q!ZyLR5u9ui+!EQT9vKz29nY6baR}B;q3{@^p26=!f{mczW!69R5MDc-vXynPLP~5pU0TL%<_%Ve$0yow9*R__V*y zY~SHD&{jyffw*n&t5+9+Em8{+_XQ7a1Mw54{vtdwi{!wQf`dci0$CM-WR!_or;tF~ z@Iy@xj!jrF1fF&~7Z5Eh5?1-0*9 z62>j$B!E2UZ5lxG)G`mqj=_|Id68e$`$v`WX58whD7`$R3xVF?$H|JF>$0&M4(wE z+iGzZ04=A=dodzZT@*zWuM62tf}^QZyO>klVjY0q2(*Wlwe*IlsR%#1jqf@D#t${k z4BO)oI*Qd`<-(<7l$6v|E%0S~X~-j6#$4^9=lY;pDH9t&5f@-3HvAroEC5**c)6AS z&vHxmFdIt?i*>Lx0zty9c>&6cWFH}+_#H%416pOF_z>jYZUSkvk50otODq$8&%6Xl`V1pxKHzB;$5m?!c(#)3YC5 zZwggNt_%;?jCp@Ya+hicWp84409*QrBs=Q|(+KMPKM-sBWJ(qTHP7RRfQSW$z+g#T zQ#!?;z=_b$y1|%?-SL9HM_xZT^>`@c7^mh=U^Y+;@b%g3$LnTT7OKR|_(r?w%s5%; zjFw8qUebPEZ~m8~X~nK{L*;Y^fqJjhG+^)y#zr`+jNvs<>1{1vq5Zr2=5>Jy7r^Nt zgdkvKLE``H%k#1VK!*MLU+CUvy)VC}Rs40vK;Y=|%-3+d6Q@7fB@eznl#&G}kPSL{ zL}-r5^Os-ih(k(80%rVcO|B(*_Tw}$8W!pZpIiu%i6dHWvE3|Z#7 zd*&+H9^~85H*a%$@me>wUN9zrHu_QIQIPxdEFHQ(ksJ*Y_>P!0zvCy{@oo`oH=5{0nC6J>*^~Yn^2PH`#slkz-|4{Iigoc*VwAHM+Td z75?TutodlbjjAebyT`AfP)BI5iH#Op)1mN#XoWXn{K(PV$kMI%9E)-~MNK<|E%h1SY+K#-mFuwfsAW1H z8Axw^F_Ds@71@g<6OhX$Gs!fE(V&tr3FjZ*!^q_l7y+KufioivT~`3#VMY%G%#uBe zh?`bJKtBHb1}-jI4>V!Ma27!cgSP6OY~#rE)d^Q<9zp6|#x`AHgkYF9S|KuSn_dEM zQ+i!plC8oF=UB{7*0f}F;j1aQ8cTD%_X=W+hdw^0&LO-maDJ6;ay zynKwGD!qT*N0C#mjA6aC{jc`imOYkH`e~Yaevrm-2X`;JJ>mz1?PEveGtlrvg@u47 zs5?y)Tygx9g)$%(+h?YGXC55sf$jjK({nCMBuIKYFC#mDiA2v&c6uG$o)D)3TH_KF z?BVQnjvaS%AMNA91w*p87nI;6&K|9SL25mg-`c)S-ij>w*Ck<3H6I*@Jn>jYP@1H} zpboz~rFm{*k(lGf8G!TqNFldRlE>E$!RA#4)O1xByz z`pYIgwX4R)mbp-vUS@rL#c1*+EVXHQ7U=6e+z(n^VRptAk7z;t@HP>FS*4!8WWi#7$-m`61qaGPM#i2XHO|^gC0E5xPQ@ zR>ZlFYe(~t@7(?r5$;jTmH?L#>!o8pO*{uScYNOaulHkNrCCDB@fUxM%6vB#{=o23Rk$qe*YYANcczd{1=~J}4Q?5!R@Vv3v2;??_M; zKUZIX<^bC2h_LVAeUPoR5R1PECnf8~PSv`4BG$cgTy4GK8|TobaBZ8ds?(=}28Avg z=?_WMWbSt4q{{D^suVwDU&usr!hQ7SQAy4g3!^um!l-5v9b1Bry5GzH&c1YWQ~Thu zZr`CVuNb{?X2x#53#PAKST0`3w04AYz6+x7lAk>bSE>{lZLotHnf zu%4uDVtGr)xhyCy%CYB1W~^-$NwLk9UJmgp#MUS+Cn3M5{3u487c#~1(L}&UNR5H6 zBFQE=u?HXz0%q(zr4oeojGm?x!Qy?tMHzgz$m8S@|K3=O0VKkKln^%;{jD8%0EcXT9rm>k)#Fyk>L<0e_QfcoIv8PAzf z_X4vFnQ-B52FmCSmK;DpGtM?24(YpKAxNee{@h^R+~7a;8cw=C=b33nJ@>osVm}8} zTlM(_zVGVnIaM4HTrn8ku6|a=Zuzw*0b&*bGv{jJIm*i$;dUcU9^yceht@Bz9n#(1 zjntZzh`fJ|#%B2@Nn|Jj8$?RKbkqCXOBXMmKY!j3kIh(67nayTt%7G9@vm!P!P(mg zz@7j?`F@~7wRaE>+$Oj3SfFrN4q{QW$=odT{ z1U^A8Lu&}J4(JGE$qHNOK2>C@&h%A2k}yAd#oO2w!g2B8E$-1aloCfNaW|o5b2^)T zMWS+1@JUPK*WNP8ao<;&&%*^1~n3pSjb<}Xplqk(e?U15CwhYo;U;!ktZkg&eiY3(u1 z)f^n}a9kKr+fg#VGprdPPw`&Sd2@`8{s2r>pB1B&ud}Ua}wS zX3V30TQat~L}1tbaQbS>4|{w2Q(zO2W8udk?X*rO#!UyMi5u_+I+}GQ0n+V<5@jA=sNZV4;fGb7KtXr!v~OFdfQ z0_*?rSl^Jg0EvTOuL{vr`a~%qM-zE>veTp7Xw_TL-x7@$+!Fw;&(yyXfv5{k!}Z7s zd+WzphEW4M@(|;;9SyLHpKhkhnCau1J0d0|90&V(BH=)&)SNCym4-l+U?u=Mp~&dR z6?%x5yUNyJ9~uSdUh?28Mur*8L&Qz)S>Id_$l_?G%Pyd3(BuDswm=i93Hcv*pfZ6= zMkm^y_o0ohF+oGZNAQA^-E35N~8KFz@oyuk|;C@g&9 z)eN6chztN~Wm7(X16C-F&tl@|45<@#D`7}2(AJ^eEPK`Gw&6{Pcy{Cw5em&50w6JG z*Vw11V|UJkMVQ>Y54 zBbe1T?%YY@%peh+(Hb@f1_l0{{6p;#YsVK6_z;gO{I=W(LnD!+z(`jtU;Y~UJA<7I zcR3@ zUhN-S*}4WfH)3ZDr|vd|k@qT(4g{81=wIVp7Huscvf$vrIg>j!&z4-uO!FFWE-3g= z6<)1h@?gL;4Qmv%u+<~N2A+sg=s>_jDMnPRi#lxZ>U?(JHCp5c;u+)AZ-=!6rgoEn z3qS-RDmN1ge*tg{t~67*lHA-&;1sH`GHCB75}JZJ3iob1YqkrzYtyAevdb`p%cnH` zPhd~Ze}kwVd`d0bMs{M6FUkOUN%BR(V*x)n zw!66r)yKrx_~P~J`#pmwT^|IXzcf4&JbAdLBWKsawU=$$PxKt?X{1dQkTQxhe}3Fc zi<+I2v(ptso<&pUeY|ASH6n-*$W`KW%|mG)EER&5m6(~a*F2@@KJa9arBC$|zj5|D zc-dz)rPwae*RONYU8|w0+JXy;=2lAgCRG2nNW27e&MEnR>d&WaDkmFKE(Kn8evp1| z!7^oO3TvXqJWAh{jt@!NJ)Fye1diW}cK`N&Lpb-oBROgk*qiQ7SX3|ybeyanAd@(I z!@?I()n<-=<45OGjNXbPtpN(wKCBqR$#oiICMv|()%F_*o*?VWpkPH4c5GAch>~i6 z<0L)5c9f4%LgyeL>9Wa5VF7)Hs`xR^y?=R%=T6>mWh8a?68$?)omQY9feb5R?;&+J zYi3#!9UBf$8T$!vG+MAa0AQNPdsqXj1V-0&n7Kg)$tKq01%<=~!5H^mBqRhZ{%O~D zq!JpOw~`~{^1&u>C%Lp{oE=F&5Sw#v-rSxTqmA9a zS(5{ciObR-Ecbhtsy89_?ip7|7mYqCD}J05QuseL1rz2z&aBDxKtu&mEFwk4r_v8} z2QVj~@kHPnEH2#^SksLg3?{R`X=XaP+^-#>FM7|!(`94#U0rz(({0QIl9oZR7Go_G zCS}kR6TvqeaIer0k3nx43eg9F?0x)Q&0BS{`Chz~|gpjPj&H*dtRCI+V&j9Gs8 zj|$io;bbvf$mok00yO->pfZu$cE~V49kU2XCz6B<`x?bDoleNfJ;MhCI|4@fioRiP zF9S{4ZagPu_e93dzCXnsvTsS90X6%l3D3a$%CT2zDbptQiy0Z^V_fXr7mT43h&lY^ zgKam-@PNSt4K7*23^GQxs%0aPMs6vo`txp(!x;j(M{_%#*u@Q21l)imX<*1kJut^r zxZ&;z>Y&f?+S7TEy$rxxNsb{#vfy2|TM)+tw?Y*p006)yEAwIRW`)h07^>miQRZNe zEx6ujV5RChhiCDc|A&!i99$`~C?}QX-6Y{tSU&(*2-vJ`_rx!E^oR)NvcX6!!m7fq zM4Tjx+tl~>%*D#kzW$fon9yAtpgK2aHf%uJIG}e*65?gqaVUwo+_-XutV}~l`d+=i zf@i#J1Rm?gzQgw$tCiUxPkI0n3{2iNtr!=J(8-D6ySQH99_Kwo--?o0 zbkhwXDwKuNUH~*2kZp4F?fw$@z8eEF2?+w`xZTL%NI3ADV(D{*2gZw+!T3kyTijtH8B?Jl}xDYa}JKea{dx@xnqT zDrE28PS#}oi(Ej|X;~XyIsh}D@w-hiN5ie2IW#46zF7`eUKR5S5sD%^3yZ+b@RLNnZ4cxO)FFtOtE{RhGLuy0bE1E?2Kyk%N6l3y?7^pR#Mazd7;yEVhz%zXb1Tc}; z62;XzEb#y$!AUK^rZ2SM<@mnnmz2p~pQ@jtveUqY2A@3_#gmlG;h@1GiI{Zq4S|c$I)Wk@ z16VcsHNr1)&hvU@w%z76CbA*KumYeL-1i3FvBe}kV|=*N^KuA31Wd?{Tn&o>N_4zi zs}6|wGEw^ph4sh*1+SfJif!#+agShh!2w6md9p!-xSe2FM@n|E*qN0$KuVKm4S*nM zXb`!3Ku?bq!SXaDS6xUbmghtMyLU?=Dg=kuhj@9It1jiJW5Ce&SE$qLgFXK#@F3=J7^%@ahB_TUJAFlGqC>S0bH4_YyiBLl0RRW^LlZ4G zhH(V^5IGEF7BVAe85+ejQnOCH1w^Fz4Myulu9 zHHM1$7BdW(FuZmJ2rjn_Ap1{qC%Ec>mEx2@grfMpEQob5oV7w2m=A>+YBWof{TlE8 zy@-P_${aE-1$d!ZcH2Gr{7L4n&Gg>_tD>K~xw#>I9`!0;vNt^!9@P>sL8H}YwXgva zt?G~Zj{eB5c03s9J^;xqBas3KY$Z|6;M9sPePtPMXzhu;9X9Jy6!AHoiD*P*^Y}H$ zpd6qxkxG;S+(IZB%8y=Ta}WmymAZn3Wvu#5EMA>bZ~{!+B6EsQNB`MQ@oiy6wi)Y> zpWY!}CFG;xxP(KgwfpexxroB^4xIX5?FaF4<>u!0p}lgCg5&^ovi0gj@UL8k`G;q7 z8LEh05v>**-Ci_U$izB3I@X?)k1tvPnqnu=J&@`!?kt362Do<#iV#?LJOkID;U*#T ze^Y_92BML`4Tme!>|;E&;_Fr%I5_mrpD)OeK}p8|1yMfxeCQKoNdgZZ?{@q=FDK{; z&dB3HMkUEBE?$g{;cnP;nxJR-z)2cIt1b~z>KeFuL4A!`0jUMF>z*q>^p80}EHwzm z24sNVLNCIScwdzoj;4kTe86>}?#KrIh_vPs(c}JWhZDLEBYzXGu_E9Wv-giz12sOG z^T(or4Nlj18VrPT%gX$)RFNoNh}aK#RcH@dQJE=84S<}XNq7ha9GO+(WU!x?3-6u= z3juiGPsBMz2m?GVmjT8DzIZoyIS>delIqlf^r0&cP@kJQ8h1jr+QJl}QwO2q5p*=U zbx#E0@lp#G7H=9+T0;+9lXQyMuZqkk5{ZbDKR`e~2#pu8s10PJ7cg{?0jB|ExQu?j z%#Qku*9N>m4E8C4tRQQ#LA#*4fY)%!#18=G0n}$-9SXxJNa2`+VL94e7D#=A+X?|W zf7u$6-^ z=@WHHwnwV8wA8cSVJcAYI4kZrrCe(7jAO+_7Zxb|voz&g?ORk6eSLjMkIGO{tZi)Q z9Am~UJ|$em@n~XYrGf1x$l8E^i`fJGtm*blWm2^Sy7 zqC3VJ;`W_xC%wRLrOJtku3*4vXcKVW@m9xt3-4R~VamSa-Fh4ZgBdlV+qYvOk|KX% zmYVC($$5;-)^U+h(Y9spIF$11Uq`hJD3o_q1X<_Jt`yT|zAfZ=V%g#P$a!-R!-T?Z zKnxvCO!#=}7sNmA+J&jXE_GXc{PbvHN#3mEH9b~`kk0hnbBy~=ouO>ndM%tEf|15)O> z5)&1-6E5?;FOQImv)Eqp@uuW>+yLj9nUML5#UmUph;W2$*=IC$x!I2sOsrN+fFhBH z?(=jWl5UM{l76u7r|w-S;$)|6?lsHCvMEoV=tY{!OAMxG7RvE)&~w2ugqspBmu1(N k+{? Date: Wed, 29 Nov 2017 14:07:29 +0000 Subject: [PATCH 42/98] Use asyncForm directive to trigger import --- .../web/app/views/project/list/modals.pug | 21 +++++++++++++++++-- .../project-list/modal-controllers.coffee | 3 --- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/services/web/app/views/project/list/modals.pug b/services/web/app/views/project/list/modals.pug index 828fa9fe4b..27bfcdd914 100644 --- a/services/web/app/views/project/list/modals.pug +++ b/services/web/app/views/project/list/modals.pug @@ -340,11 +340,28 @@ script(type="text/ng-template", id="v1ImportModalTemplate") h2.v1-import-title #[strong Warning:] Overleaf V2 is in beta p Once importing your project you will lose access to the some of the features of Overleaf V1. This includes the git bridge, journal integrations, WYSIWYG and linked files. We’re working on bringing these features to V2! p Once you have imported a project to V2 you #[strong cannot go back to V1]. + p Are you sure you want to import to V2? .modal-footer.v1-import-footer div(ng-show="step === 1") a.btn.btn-primary.v1-import-btn(ng-href=settings.overleaf.host + "/{{project.id}}") #{translate("open_in_v1")} button.btn.btn-primary.v1-import-btn(ng-click="moveToConfirmation()") #{translate("import_to_v2")} div(ng-show="step === 2") - a.btn.btn-primary.v1-import-btn(ng-href=settings.overleaf.host + "/{{project.id}}") #{translate("never_mind_open_in_v1")} - button.btn.btn-primary.v1-import-btn(ng-click="import()") #{translate("yes_im_sure")} + form( + async-form="v1Import", + name="v1ImportForm", + action="{{'/overleaf/project/'+ project.id + '/import'}}" + method="POST" + ng-cloak + ) + input(name='_csrf', type='hidden', value=csrfToken) + form-messages(for="v1ImportForm") + a.btn.btn-primary.v1-import-btn( + ng-href=settings.overleaf.host + "/{{project.id}}" + ng-class="{disabled: v1ImportForm.inflight || v1ImportForm.success}" + ) #{translate("never_mind_open_in_v1")} + input.btn.btn-primary.v1-import-btn( + type="submit", + value=translate('yes_im_sure') + ng-disabled="v1ImportForm.inflight || v1ImportForm.success" + ) diff --git a/services/web/public/coffee/main/project-list/modal-controllers.coffee b/services/web/public/coffee/main/project-list/modal-controllers.coffee index 5fd0a4707d..609a16cb77 100644 --- a/services/web/public/coffee/main/project-list/modal-controllers.coffee +++ b/services/web/public/coffee/main/project-list/modal-controllers.coffee @@ -133,6 +133,3 @@ define [ $scope.moveToConfirmation = () -> $scope.step = 2 - - $scope.import = () -> - console.log('IMPORT', project) From f232899f3fdd5171ae9f380ab1b291ae3783989a Mon Sep 17 00:00:00 2001 From: Alasdair Smith Date: Wed, 29 Nov 2017 16:14:31 +0000 Subject: [PATCH 43/98] Show warning symbol on second step --- services/web/app/views/project/list/modals.pug | 6 ++---- .../web/public/stylesheets/app/list/v1-import-modal.less | 7 +++++++ 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/services/web/app/views/project/list/modals.pug b/services/web/app/views/project/list/modals.pug index 27bfcdd914..7324a1cc97 100644 --- a/services/web/app/views/project/list/modals.pug +++ b/services/web/app/views/project/list/modals.pug @@ -333,10 +333,8 @@ script(type="text/ng-template", id="v1ImportModalTemplate") li Yet more stuff .v1-import-step-2(ng-show="step === 2") - img.v1-import-img( - src="/img/v1-import/v2-editor.png" - alt="The new V2 Editor." - ) + div.v1-import-warning(aria-label="Warning symbol.") + i.fa.fa-exclamation-triangle h2.v1-import-title #[strong Warning:] Overleaf V2 is in beta p Once importing your project you will lose access to the some of the features of Overleaf V1. This includes the git bridge, journal integrations, WYSIWYG and linked files. We’re working on bringing these features to V2! p Once you have imported a project to V2 you #[strong cannot go back to V1]. diff --git a/services/web/public/stylesheets/app/list/v1-import-modal.less b/services/web/public/stylesheets/app/list/v1-import-modal.less index 780dca9972..64bb9fda37 100644 --- a/services/web/public/stylesheets/app/list/v1-import-modal.less +++ b/services/web/public/stylesheets/app/list/v1-import-modal.less @@ -6,6 +6,13 @@ width: 100%; } +.v1-import-warning { + text-align: center; + color: #fdce02; + font-size: 14em; + line-height: 1em; +} + .v1-import-footer { display: flex; justify-content: space-evenly; From 870e87ebe1b6d8cbff448d3c85df9305a1ec8c7d Mon Sep 17 00:00:00 2001 From: James Allen Date: Thu, 30 Nov 2017 11:00:28 +0000 Subject: [PATCH 44/98] Run npm with -q flag for less verbose test output --- services/web/Makefile | 6 +++--- services/web/package.json | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/services/web/Makefile b/services/web/Makefile index 7ab8b89781..6ce935a2cc 100644 --- a/services/web/Makefile +++ b/services/web/Makefile @@ -1,5 +1,5 @@ DOCKER_COMPOSE_FLAGS ?= -f docker-compose.yml -NPM := docker-compose ${DOCKER_COMPOSE_FLAGS} run --rm npm npm +npm -q := docker-compose ${DOCKER_COMPOSE_FLAGS} run --rm npm -q npm BUILD_NUMBER ?= local BRANCH_NAME ?= $(shell git rev-parse --abbrev-ref HEAD) PROJECT_NAME = web @@ -43,7 +43,7 @@ docker-shared.yml: test: test_unit test_acceptance test_unit: docker-shared.yml - docker-compose ${DOCKER_COMPOSE_FLAGS} run --rm test_unit npm run test:unit -- ${MOCHA_ARGS} + docker-compose ${DOCKER_COMPOSE_FLAGS} run --rm test_unit npm -q run test:unit -- ${MOCHA_ARGS} test_acceptance: test_acceptance_app test_acceptance_modules @@ -56,7 +56,7 @@ test_acceptance_app_stop_service: docker-shared.yml docker-compose ${DOCKER_COMPOSE_FLAGS} stop test_acceptance redis mongo test_acceptance_app_run: docker-shared.yml - docker-compose ${DOCKER_COMPOSE_FLAGS} exec -T test_acceptance npm run test:acceptance -- ${MOCHA_ARGS} + docker-compose ${DOCKER_COMPOSE_FLAGS} exec -T test_acceptance npm -q run test:acceptance -- ${MOCHA_ARGS} test_acceptance_modules: docker-shared.yml for dir in modules/*; \ diff --git a/services/web/package.json b/services/web/package.json index c221dc1052..0e80d91893 100644 --- a/services/web/package.json +++ b/services/web/package.json @@ -12,13 +12,13 @@ "scripts": { "test:acceptance:wait_for_app": "echo 'Waiting for app to be accessible' && while (! curl -s -o /dev/null localhost:3000/status) do sleep 1; done", "test:acceptance:run": "bin/acceptance_test $@", - "test:acceptance:dir": "npm run compile:acceptance_tests && npm run test:acceptance:wait_for_app && npm run test:acceptance:run -- $@", - "test:acceptance": "npm run test:acceptance:dir -- $@ test/acceptance/js", - "test:unit": "npm run compile:app && npm run compile:unit_tests && bin/unit_test $@", + "test:acceptance:dir": "npm -q run compile:acceptance_tests && npm -q run test:acceptance:wait_for_app && npm -q run test:acceptance:run -- $@", + "test:acceptance": "npm -q run test:acceptance:dir -- $@ test/acceptance/js", + "test:unit": "npm -q run compile:app && npm -q run compile:unit_tests && bin/unit_test $@", "compile:unit_tests": "bin/compile_unit_tests", "compile:acceptance_tests": "bin/compile_acceptance_tests", "compile:app": "bin/compile_app", - "start": "npm run compile:app && node app.js" + "start": "npm -q run compile:app && node app.js" }, "dependencies": { "archiver": "0.9.0", From b2a3e06717dc8ac1f82a4c9b1fca2c9ec91923e8 Mon Sep 17 00:00:00 2001 From: James Allen Date: Thu, 30 Nov 2017 11:20:25 +0000 Subject: [PATCH 45/98] Find / replace mistake --- services/web/Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/web/Makefile b/services/web/Makefile index 6ce935a2cc..6b1c66f211 100644 --- a/services/web/Makefile +++ b/services/web/Makefile @@ -1,5 +1,5 @@ DOCKER_COMPOSE_FLAGS ?= -f docker-compose.yml -npm -q := docker-compose ${DOCKER_COMPOSE_FLAGS} run --rm npm -q npm +NPM := docker-compose ${DOCKER_COMPOSE_FLAGS} run --rm npm -q npm BUILD_NUMBER ?= local BRANCH_NAME ?= $(shell git rev-parse --abbrev-ref HEAD) PROJECT_NAME = web From 78709d819edb1f6a3da17fd670834c6ea1cc841d Mon Sep 17 00:00:00 2001 From: Hayden Faulds Date: Thu, 30 Nov 2017 13:15:23 +0000 Subject: [PATCH 46/98] generate test acceptance files volume for modules --- services/web/bin/generate_volumes_file | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/web/bin/generate_volumes_file b/services/web/bin/generate_volumes_file index d70ac11c3d..49ee11c508 100755 --- a/services/web/bin/generate_volumes_file +++ b/services/web/bin/generate_volumes_file @@ -9,7 +9,7 @@ for module in listdir("modules/"): if module[0] != '.': if isfile(join("modules", module, 'index.coffee')): volumes.append(join("modules", module, 'index.coffee')) - for directory in ['app/coffee', 'app/views', 'public/coffee', 'test/unit/coffee', 'test/acceptance/coffee', 'test/acceptance/config']: + for directory in ['app/coffee', 'app/views', 'public/coffee', 'test/unit/coffee', 'test/acceptance/coffee', 'test/acceptance/config', 'test/acceptance/files']: if isdir(join("modules", module, directory)): volumes.append(join("modules", module, directory)) From e916fc906a51ddecbc47a4b9310fda112f398327 Mon Sep 17 00:00:00 2001 From: Hayden Faulds Date: Thu, 30 Nov 2017 13:15:39 +0000 Subject: [PATCH 47/98] add mkdirp dev dependency --- services/web/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/services/web/package.json b/services/web/package.json index c221dc1052..f54137ea4d 100644 --- a/services/web/package.json +++ b/services/web/package.json @@ -109,6 +109,7 @@ "grunt-postcss": "^0.8.0", "grunt-sed": "^0.1.1", "grunt-shell": "^2.1.0", + "mkdirp": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", "sandboxed-module": "0.2.0", "sinon": "^1.17.0", "timekeeper": "", From a5cdcea9c7b1d04e0cc8e96d73b2b916cf08cdc8 Mon Sep 17 00:00:00 2001 From: Alasdair Smith Date: Thu, 30 Nov 2017 13:32:23 +0000 Subject: [PATCH 48/98] Update modules gitignore to ignore everything except specific modules --- services/web/modules/.gitignore | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/services/web/modules/.gitignore b/services/web/modules/.gitignore index 1d30263cb3..a310279ca9 100644 --- a/services/web/modules/.gitignore +++ b/services/web/modules/.gitignore @@ -1,12 +1,6 @@ -*/app/js -*/test/unit/js -*/index.js -ldap -admin-panel -groovehq -launchpad -learn-wiki -references-search -sharelatex-saml -templates -tpr-webmodule +# Ignore all modules except for a whitelist +* +!dropbox +!github-sync +!public-registration +!.gitignore From 52ad852814bf053e8e3bc7a2522cda771c46a116 Mon Sep 17 00:00:00 2001 From: Alasdair Smith Date: Wed, 29 Nov 2017 16:35:50 +0000 Subject: [PATCH 49/98] Add check for OL host to prevent failure on legacy SL --- services/web/app/views/project/list/modals.pug | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/services/web/app/views/project/list/modals.pug b/services/web/app/views/project/list/modals.pug index 7324a1cc97..1e12fb2293 100644 --- a/services/web/app/views/project/list/modals.pug +++ b/services/web/app/views/project/list/modals.pug @@ -342,8 +342,13 @@ script(type="text/ng-template", id="v1ImportModalTemplate") .modal-footer.v1-import-footer div(ng-show="step === 1") - a.btn.btn-primary.v1-import-btn(ng-href=settings.overleaf.host + "/{{project.id}}") #{translate("open_in_v1")} - button.btn.btn-primary.v1-import-btn(ng-click="moveToConfirmation()") #{translate("import_to_v2")} + if settings.overleaf && settings.overleaf.host + a.btn.btn-primary.v1-import-btn( + ng-href=settings.overleaf.host + "/{{project.id}}" + ) #{translate("open_in_v1")} + button.btn.btn-primary.v1-import-btn( + ng-click="moveToConfirmation()" + ) #{translate("import_to_v2")} div(ng-show="step === 2") form( async-form="v1Import", @@ -354,10 +359,11 @@ script(type="text/ng-template", id="v1ImportModalTemplate") ) input(name='_csrf', type='hidden', value=csrfToken) form-messages(for="v1ImportForm") - a.btn.btn-primary.v1-import-btn( - ng-href=settings.overleaf.host + "/{{project.id}}" - ng-class="{disabled: v1ImportForm.inflight || v1ImportForm.success}" - ) #{translate("never_mind_open_in_v1")} + if settings.overleaf && settings.overleaf.host + a.btn.btn-primary.v1-import-btn( + ng-href=settings.overleaf.host + "/{{project.id}}" + ng-class="{disabled: v1ImportForm.inflight || v1ImportForm.success}" + ) #{translate("never_mind_open_in_v1")} input.btn.btn-primary.v1-import-btn( type="submit", value=translate('yes_im_sure') From 7d584274857193acf69352f65e03f1901af956e0 Mon Sep 17 00:00:00 2001 From: Alasdair Smith Date: Thu, 30 Nov 2017 16:59:13 +0000 Subject: [PATCH 50/98] Show warnings on project list for some V1 data --- .../coffee/Features/Project/ProjectController.coffee | 12 +++++++++++- services/web/app/views/project/list/project-list.pug | 4 ++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/services/web/app/coffee/Features/Project/ProjectController.coffee b/services/web/app/coffee/Features/Project/ProjectController.coffee index 9d9b7d9a13..801459d4c5 100644 --- a/services/web/app/coffee/Features/Project/ProjectController.coffee +++ b/services/web/app/coffee/Features/Project/ProjectController.coffee @@ -170,6 +170,8 @@ module.exports = ProjectController = return notification projects = ProjectController._buildProjectList results.projects, results.v1Projects?.projects user = results.user + warnings = ProjectController._buildWarningsList results.v1Projects + ProjectController._injectProjectOwners projects, (error, projects) -> return next(error) if error? viewModel = { @@ -181,7 +183,7 @@ module.exports = ProjectController = user: user hasSubscription: results.hasSubscription[0] isShowingV1Projects: results.v1Projects? - noV1Connection: results.v1Projects?.noConnection + warnings: warnings } if Settings?.algolia?.app_id? and Settings?.algolia?.read_only_api_key? @@ -427,6 +429,14 @@ module.exports = ProjectController = project.owner = users[project.owner_ref.toString()] callback null, projects + _buildWarningsList: (v1ProjectData = {}) -> + warnings = [] + if v1ProjectData.noConnection + warnings.push 'No V1 Connection' + if v1ProjectData.hasHiddenV1Projects + warnings.push "Looks like you've got a lot of V1 projects! Some of them may be hidden on V2. To view them all, use the V1 dashboard." + return warnings + defaultSettingsForAnonymousUser = (user_id)-> id : user_id ace: diff --git a/services/web/app/views/project/list/project-list.pug b/services/web/app/views/project/list/project-list.pug index 0db4f49400..491fef168c 100644 --- a/services/web/app/views/project/list/project-list.pug +++ b/services/web/app/views/project/list/project-list.pug @@ -114,9 +114,9 @@ ) #{translate("delete_forever")} .row.row-spaced - if noV1Connection + each warning in warnings .col-xs-12 - .alert.alert-warning No V1 Connection + .alert.alert-warning= warning .col-xs-12 .card.card-thin.project-list-card From 040546b1d33ef8f4991266ec218995f5985d72fb Mon Sep 17 00:00:00 2001 From: James Allen Date: Fri, 1 Dec 2017 08:49:10 +0000 Subject: [PATCH 51/98] Move -q flag to correct place --- services/web/Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/web/Makefile b/services/web/Makefile index 6b1c66f211..fce72a46d1 100644 --- a/services/web/Makefile +++ b/services/web/Makefile @@ -1,5 +1,5 @@ DOCKER_COMPOSE_FLAGS ?= -f docker-compose.yml -NPM := docker-compose ${DOCKER_COMPOSE_FLAGS} run --rm npm -q npm +NPM := docker-compose ${DOCKER_COMPOSE_FLAGS} run --rm npm npm -q BUILD_NUMBER ?= local BRANCH_NAME ?= $(shell git rev-parse --abbrev-ref HEAD) PROJECT_NAME = web From fa2a3574dbe1b776e042c2f6a261735f0da414e8 Mon Sep 17 00:00:00 2001 From: James Allen Date: Fri, 1 Dec 2017 09:01:36 +0000 Subject: [PATCH 52/98] Look for Makefile, not makefile --- services/web/Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/web/Makefile b/services/web/Makefile index fce72a46d1..d106aa6b9d 100644 --- a/services/web/Makefile +++ b/services/web/Makefile @@ -61,7 +61,7 @@ test_acceptance_app_run: docker-shared.yml test_acceptance_modules: docker-shared.yml for dir in modules/*; \ do \ - if [ -e $$dir/makefile ]; then \ + if [ -e $$dir/Makefile ]; then \ (make test_acceptance_module MODULE=$$dir) \ fi \ done From 65e44d47706d4e9b82455723522d9cf2c69d3739 Mon Sep 17 00:00:00 2001 From: James Allen Date: Fri, 1 Dec 2017 09:31:22 +0000 Subject: [PATCH 53/98] Clean up old docker-shared.yml before running tests --- services/web/Jenkinsfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/web/Jenkinsfile b/services/web/Jenkinsfile index 0650eb4edb..15c698655d 100644 --- a/services/web/Jenkinsfile +++ b/services/web/Jenkinsfile @@ -111,7 +111,7 @@ pipeline { stage('Unit Tests') { steps { - sh 'make install' + sh 'make clean install' sh 'make test_unit MOCHA_ARGS="--reporter=tap"' } } From e9733514af6e3fff2f72853b1d8a9ef9114af9d0 Mon Sep 17 00:00:00 2001 From: James Allen Date: Fri, 1 Dec 2017 10:03:42 +0000 Subject: [PATCH 54/98] Fail on failing module acceptance tests --- services/web/Makefile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/services/web/Makefile b/services/web/Makefile index d106aa6b9d..44017629d1 100644 --- a/services/web/Makefile +++ b/services/web/Makefile @@ -59,6 +59,8 @@ test_acceptance_app_run: docker-shared.yml docker-compose ${DOCKER_COMPOSE_FLAGS} exec -T test_acceptance npm -q run test:acceptance -- ${MOCHA_ARGS} test_acceptance_modules: docker-shared.yml + # Break and error on any module failure + set -e; \ for dir in modules/*; \ do \ if [ -e $$dir/Makefile ]; then \ From e895a495d6c344a8d3983a38886a99682b39148d Mon Sep 17 00:00:00 2001 From: James Allen Date: Fri, 1 Dec 2017 10:49:00 +0000 Subject: [PATCH 55/98] Move make clean step to before compile --- services/web/Jenkinsfile | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/services/web/Jenkinsfile b/services/web/Jenkinsfile index 15c698655d..9cdebb434c 100644 --- a/services/web/Jenkinsfile +++ b/services/web/Jenkinsfile @@ -70,6 +70,19 @@ pipeline { sh 'ls -l node_modules/.bin' } } + + stage('Unit Tests') { + steps { + sh 'make clean install' // Removes js files, so do before compile + sh 'make test_unit MOCHA_ARGS="--reporter=tap"' + } + } + + stage('Acceptance Tests') { + steps { + sh 'make test_acceptance MOCHA_ARGS="--reporter=tap"' + } + } stage('Compile') { agent { @@ -109,19 +122,6 @@ pipeline { } } - stage('Unit Tests') { - steps { - sh 'make clean install' - sh 'make test_unit MOCHA_ARGS="--reporter=tap"' - } - } - - stage('Acceptance Tests') { - steps { - sh 'make test_acceptance MOCHA_ARGS="--reporter=tap"' - } - } - stage('Package') { steps { sh 'rm -rf ./node_modules/grunt*' From 5b8c9b2f4cab965021647eba097bf995d19f284a Mon Sep 17 00:00:00 2001 From: Alasdair Smith Date: Fri, 1 Dec 2017 11:00:13 +0000 Subject: [PATCH 56/98] Add aria role to warning --- services/web/app/views/project/list/project-list.pug | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/web/app/views/project/list/project-list.pug b/services/web/app/views/project/list/project-list.pug index 491fef168c..6f568a7b40 100644 --- a/services/web/app/views/project/list/project-list.pug +++ b/services/web/app/views/project/list/project-list.pug @@ -116,7 +116,7 @@ .row.row-spaced each warning in warnings .col-xs-12 - .alert.alert-warning= warning + .alert.alert-warning(role="alert")= warning .col-xs-12 .card.card-thin.project-list-card From 2ea3caf08b7fd42e1b66e38cbc06c0642e4e17e7 Mon Sep 17 00:00:00 2001 From: Shane Kilkelly Date: Fri, 1 Dec 2017 11:22:41 +0000 Subject: [PATCH 57/98] Properly version the fineuploader library --- services/web/Gruntfile.coffee | 1 + services/web/app/coffee/infrastructure/ExpressLocals.coffee | 2 ++ services/web/app/coffee/infrastructure/PackageVersions.coffee | 1 + services/web/app/views/project/editor.pug | 3 ++- services/web/public/coffee/directives/fineUpload.coffee | 2 +- services/web/public/coffee/libs.coffee | 2 +- .../public/js/libs/{fineuploader.js => fineuploader-5.15.4.js} | 0 7 files changed, 8 insertions(+), 3 deletions(-) rename services/web/public/js/libs/{fineuploader.js => fineuploader-5.15.4.js} (100%) diff --git a/services/web/Gruntfile.coffee b/services/web/Gruntfile.coffee index b93cc8dcbb..c49add4a91 100644 --- a/services/web/Gruntfile.coffee +++ b/services/web/Gruntfile.coffee @@ -196,6 +196,7 @@ module.exports = (grunt) -> "mathjax": "/js/libs/mathjax/MathJax.js?config=TeX-AMS_HTML" "pdfjs-dist/build/pdf": "libs/#{PackageVersions.lib('pdfjs')}/pdf" "ace": "#{PackageVersions.lib('ace')}" + "fineuploader": "libs/#{PackageVersions.lib('fineuploader')}" shim: "pdfjs-dist/build/pdf": deps: ["libs/#{PackageVersions.lib('pdfjs')}/compatibility"] diff --git a/services/web/app/coffee/infrastructure/ExpressLocals.coffee b/services/web/app/coffee/infrastructure/ExpressLocals.coffee index fa9a223ad7..2fdf219962 100644 --- a/services/web/app/coffee/infrastructure/ExpressLocals.coffee +++ b/services/web/app/coffee/infrastructure/ExpressLocals.coffee @@ -24,6 +24,7 @@ jsPath = ace = PackageVersions.lib('ace') pdfjs = PackageVersions.lib('pdfjs') +fineuploader = PackageVersions.lib('fineuploader') getFileContent = (filePath)-> filePath = Path.join __dirname, "../../../", "public#{filePath}" @@ -37,6 +38,7 @@ getFileContent = (filePath)-> logger.log "Generating file fingerprints..." pathList = [ + ["#{jsPath}libs/#{fineuploader}.js"] ["#{jsPath}libs/require.js"] ["#{jsPath}ide.js"] ["#{jsPath}main.js"] diff --git a/services/web/app/coffee/infrastructure/PackageVersions.coffee b/services/web/app/coffee/infrastructure/PackageVersions.coffee index f8c2011c11..5acdfec666 100644 --- a/services/web/app/coffee/infrastructure/PackageVersions.coffee +++ b/services/web/app/coffee/infrastructure/PackageVersions.coffee @@ -2,6 +2,7 @@ version = { "pdfjs": "1.7.225" "moment": "2.9.0" "ace": "1.2.5" + "fineuploader": "5.15.4" } module.exports = { diff --git a/services/web/app/views/project/editor.pug b/services/web/app/views/project/editor.pug index 1e1f2c982b..625bea883d 100644 --- a/services/web/app/views/project/editor.pug +++ b/services/web/app/views/project/editor.pug @@ -130,7 +130,8 @@ block requirejs "moment": "libs/#{lib('moment')}", "pdfjs-dist/build/pdf": "libs/#{lib('pdfjs')}/pdf", "pdfjs-dist/build/pdf.worker": "#{pdfWorkerPath}", - "ace": "#{lib('ace')}" + "ace": "#{lib('ace')}", + "fineuploader": "libs/#{lib('fineuploader')}" }, "urlArgs" : "fingerprint=#{fingerprint(jsPath + 'ide.js')}-#{fingerprint(jsPath + 'libs.js')}", "waitSeconds": 0, diff --git a/services/web/public/coffee/directives/fineUpload.coffee b/services/web/public/coffee/directives/fineUpload.coffee index 7fffcc366a..b3d3766ba6 100644 --- a/services/web/public/coffee/directives/fineUpload.coffee +++ b/services/web/public/coffee/directives/fineUpload.coffee @@ -1,6 +1,6 @@ define [ "base" - "libs/fineuploader" + "fineuploader" ], (App, qq) -> App.directive 'fineUpload', ($timeout) -> return { diff --git a/services/web/public/coffee/libs.coffee b/services/web/public/coffee/libs.coffee index 533891548b..e99fbc13ce 100644 --- a/services/web/public/coffee/libs.coffee +++ b/services/web/public/coffee/libs.coffee @@ -6,7 +6,7 @@ define [ "libs/underscore-1.3.3" "libs/algolia-2.5.2" "libs/jquery.storage" - "libs/fineuploader" + "fineuploader" "libs/angular-sanitize-1.2.17" "libs/angular-cookie" "libs/passfield" diff --git a/services/web/public/js/libs/fineuploader.js b/services/web/public/js/libs/fineuploader-5.15.4.js similarity index 100% rename from services/web/public/js/libs/fineuploader.js rename to services/web/public/js/libs/fineuploader-5.15.4.js From 3ee6f5d4be2618f20f5d33f186c7ae5e1269a624 Mon Sep 17 00:00:00 2001 From: Shane Kilkelly Date: Fri, 1 Dec 2017 11:27:06 +0000 Subject: [PATCH 58/98] Update fineuploader on project page --- services/web/app/views/layout.pug | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/services/web/app/views/layout.pug b/services/web/app/views/layout.pug index 822923ac08..31afc17d29 100644 --- a/services/web/app/views/layout.pug +++ b/services/web/app/views/layout.pug @@ -132,7 +132,8 @@ html(itemscope, itemtype='http://schema.org/Product') // minimal requirejs configuration (can be extended/overridden) window.requirejs = { "paths" : { - "moment": "libs/#{lib('moment')}" + "moment": "libs/#{lib('moment')}", + "fineuploader": "libs/#{lib('fineuploader')}" }, "urlArgs": "fingerprint=#{fingerprint(jsPath + 'main.js')}-#{fingerprint(jsPath + 'libs.js')}", "config":{ From 381761127ea319553527a007acb9838d5cbd5eed Mon Sep 17 00:00:00 2001 From: James Allen Date: Fri, 1 Dec 2017 13:26:31 +0000 Subject: [PATCH 59/98] Replace previous fineuploader path with old version --- services/web/public/js/libs/fineuploader.js | 3761 +++++++++++++++++++ 1 file changed, 3761 insertions(+) create mode 100644 services/web/public/js/libs/fineuploader.js diff --git a/services/web/public/js/libs/fineuploader.js b/services/web/public/js/libs/fineuploader.js new file mode 100644 index 0000000000..b109b8a1c1 --- /dev/null +++ b/services/web/public/js/libs/fineuploader.js @@ -0,0 +1,3761 @@ +/** + * http://github.com/Valums-File-Uploader/file-uploader + * + * Multiple file upload component with progress-bar, drag-and-drop, support for all modern browsers. + * + * Original version: 1.0 © 2010 Andrew Valums ( andrew(at)valums.com ) + * Current Maintainer (2.0+): © 2012, Ray Nicholus ( fineuploader(at)garstasio.com ) + * + * Licensed under MIT license, GNU GPL 2 or later, GNU LGPL 2 or later, see license.txt. + */ +/*globals window, navigator, document, FormData, File, HTMLInputElement, XMLHttpRequest, Blob*/ +var qq = function(element) { + "use strict"; + + return { + hide: function() { + element.style.display = 'none'; + return this; + }, + + /** Returns the function which detaches attached event */ + attach: function(type, fn) { + if (element.addEventListener){ + element.addEventListener(type, fn, false); + } else if (element.attachEvent){ + element.attachEvent('on' + type, fn); + } + return function() { + qq(element).detach(type, fn); + }; + }, + + detach: function(type, fn) { + if (element.removeEventListener){ + element.removeEventListener(type, fn, false); + } else if (element.attachEvent){ + element.detachEvent('on' + type, fn); + } + return this; + }, + + contains: function(descendant) { + // compareposition returns false in this case + if (element === descendant) { + return true; + } + + if (element.contains){ + return element.contains(descendant); + } else { + /*jslint bitwise: true*/ + return !!(descendant.compareDocumentPosition(element) & 8); + } + }, + + /** + * Insert this element before elementB. + */ + insertBefore: function(elementB) { + elementB.parentNode.insertBefore(element, elementB); + return this; + }, + + remove: function() { + element.parentNode.removeChild(element); + return this; + }, + + /** + * Sets styles for an element. + * Fixes opacity in IE6-8. + */ + css: function(styles) { + if (styles.opacity !== null){ + if (typeof element.style.opacity !== 'string' && typeof(element.filters) !== 'undefined'){ + styles.filter = 'alpha(opacity=' + Math.round(100 * styles.opacity) + ')'; + } + } + qq.extend(element.style, styles); + + return this; + }, + + hasClass: function(name) { + var re = new RegExp('(^| )' + name + '( |$)'); + return re.test(element.className); + }, + + addClass: function(name) { + if (!qq(element).hasClass(name)){ + element.className += ' ' + name; + } + return this; + }, + + removeClass: function(name) { + var re = new RegExp('(^| )' + name + '( |$)'); + element.className = element.className.replace(re, ' ').replace(/^\s+|\s+$/g, ""); + return this; + }, + + getByClass: function(className) { + var candidates, + result = []; + + if (element.querySelectorAll){ + return element.querySelectorAll('.' + className); + } + + candidates = element.getElementsByTagName("*"); + + qq.each(candidates, function(idx, val) { + if (qq(val).hasClass(className)){ + result.push(val); + } + }); + return result; + }, + + children: function() { + var children = [], + child = element.firstChild; + + while (child){ + if (child.nodeType === 1){ + children.push(child); + } + child = child.nextSibling; + } + + return children; + }, + + setText: function(text) { + element.innerText = text; + element.textContent = text; + return this; + }, + + clearText: function() { + return qq(element).setText(""); + } + }; +}; + +qq.log = function(message, level) { + "use strict"; + + if (window.console) { + if (!level || level === 'info') { + window.console.log(message); + } + else + { + if (window.console[level]) { + window.console[level](message); + } + else { + window.console.log('<' + level + '> ' + message); + } + } + } +}; + +qq.isObject = function(variable) { + "use strict"; + return variable !== null && variable && typeof(variable) === "object" && variable.constructor === Object; +}; + +qq.isFunction = function(variable) { + "use strict"; + return typeof(variable) === "function"; +}; + +qq.trimStr = function(string) { + if (String.prototype.trim) { + return string.trim(); + } + + return string.replace(/^\s+|\s+$/g,''); +}; + +qq.isFileOrInput = function(maybeFileOrInput) { + "use strict"; + if (qq.isBlob(maybeFileOrInput) && window.File && maybeFileOrInput instanceof File) { + return true; + } + else if (window.HTMLInputElement) { + if (maybeFileOrInput instanceof HTMLInputElement) { + if (maybeFileOrInput.type && maybeFileOrInput.type.toLowerCase() === 'file') { + return true; + } + } + } + else if (maybeFileOrInput.tagName) { + if (maybeFileOrInput.tagName.toLowerCase() === 'input') { + if (maybeFileOrInput.type && maybeFileOrInput.type.toLowerCase() === 'file') { + return true; + } + } + } + + return false; +}; + +qq.isBlob = function(maybeBlob) { + "use strict"; + return window.Blob && maybeBlob instanceof Blob; +}; + +qq.isXhrUploadSupported = function() { + "use strict"; + var input = document.createElement('input'); + input.type = 'file'; + + return ( + input.multiple !== undefined && + typeof File !== "undefined" && + typeof FormData !== "undefined" && + typeof (new XMLHttpRequest()).upload !== "undefined" ); +}; + +qq.isFolderDropSupported = function(dataTransfer) { + "use strict"; + return (dataTransfer.items && dataTransfer.items[0].webkitGetAsEntry); +}; + +qq.isFileChunkingSupported = function() { + "use strict"; + return !qq.android() && //android's impl of Blob.slice is broken + qq.isXhrUploadSupported() && + (File.prototype.slice || File.prototype.webkitSlice || File.prototype.mozSlice); +}; + +qq.extend = function (first, second, extendNested) { + "use strict"; + qq.each(second, function(prop, val) { + if (extendNested && qq.isObject(val)) { + if (first[prop] === undefined) { + first[prop] = {}; + } + qq.extend(first[prop], val, true); + } + else { + first[prop] = val; + } + }); +}; + +/** + * Searches for a given element in the array, returns -1 if it is not present. + * @param {Number} [from] The index at which to begin the search + */ +qq.indexOf = function(arr, elt, from){ + "use strict"; + + if (arr.indexOf) { + return arr.indexOf(elt, from); + } + + from = from || 0; + var len = arr.length; + + if (from < 0) { + from += len; + } + + for (; from < len; from+=1){ + if (arr.hasOwnProperty(from) && arr[from] === elt){ + return from; + } + } + return -1; +}; + +//this is a version 4 UUID +qq.getUniqueId = function(){ + "use strict"; + + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + /*jslint eqeq: true, bitwise: true*/ + var r = Math.random()*16|0, v = c == 'x' ? r : (r&0x3|0x8); + return v.toString(16); + }); +}; + +// +// Browsers and platforms detection + +qq.ie = function(){ + "use strict"; + return navigator.userAgent.indexOf('MSIE') !== -1; +}; +qq.ie10 = function(){ + "use strict"; + return navigator.userAgent.indexOf('MSIE 10') !== -1; +}; +qq.safari = function(){ + "use strict"; + return navigator.vendor !== undefined && navigator.vendor.indexOf("Apple") !== -1; +}; +qq.chrome = function(){ + "use strict"; + return navigator.vendor !== undefined && navigator.vendor.indexOf('Google') !== -1; +}; +qq.firefox = function(){ + "use strict"; + return (navigator.userAgent.indexOf('Mozilla') !== -1 && navigator.vendor !== undefined && navigator.vendor === ''); +}; +qq.windows = function(){ + "use strict"; + return navigator.platform === "Win32"; +}; +qq.android = function(){ + "use strict"; + return navigator.userAgent.toLowerCase().indexOf('android') !== -1; +}; + +// +// Events + +qq.preventDefault = function(e){ + "use strict"; + if (e.preventDefault){ + e.preventDefault(); + } else{ + e.returnValue = false; + } +}; + +/** + * Creates and returns element from html string + * Uses innerHTML to create an element + */ +qq.toElement = (function(){ + "use strict"; + var div = document.createElement('div'); + return function(html){ + div.innerHTML = html; + var element = div.firstChild; + div.removeChild(element); + return element; + }; +}()); + +//key and value are passed to callback for each item in the object or array +qq.each = function(obj, callback) { + "use strict"; + var key, retVal; + if (obj) { + for (key in obj) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { + retVal = callback(key, obj[key]); + if (retVal === false) { + break; + } + } + } + } +}; + +/** + * obj2url() takes a json-object as argument and generates + * a querystring. pretty much like jQuery.param() + * + * how to use: + * + * `qq.obj2url({a:'b',c:'d'},'http://any.url/upload?otherParam=value');` + * + * will result in: + * + * `http://any.url/upload?otherParam=value&a=b&c=d` + * + * @param Object JSON-Object + * @param String current querystring-part + * @return String encoded querystring + */ +qq.obj2url = function(obj, temp, prefixDone){ + "use strict"; + /*jshint laxbreak: true*/ + var i, len, + uristrings = [], + prefix = '&', + add = function(nextObj, i){ + var nextTemp = temp + ? (/\[\]$/.test(temp)) // prevent double-encoding + ? temp + : temp+'['+i+']' + : i; + if ((nextTemp !== 'undefined') && (i !== 'undefined')) { + uristrings.push( + (typeof nextObj === 'object') + ? qq.obj2url(nextObj, nextTemp, true) + : (Object.prototype.toString.call(nextObj) === '[object Function]') + ? encodeURIComponent(nextTemp) + '=' + encodeURIComponent(nextObj()) + : encodeURIComponent(nextTemp) + '=' + encodeURIComponent(nextObj) + ); + } + }; + + if (!prefixDone && temp) { + prefix = (/\?/.test(temp)) ? (/\?$/.test(temp)) ? '' : '&' : '?'; + uristrings.push(temp); + uristrings.push(qq.obj2url(obj)); + } else if ((Object.prototype.toString.call(obj) === '[object Array]') && (typeof obj !== 'undefined') ) { + // we wont use a for-in-loop on an array (performance) + for (i = -1, len = obj.length; i < len; i+=1){ + add(obj[i], i); + } + } else if ((typeof obj !== 'undefined') && (obj !== null) && (typeof obj === "object")){ + // for anything else but a scalar, we will use for-in-loop + for (i in obj){ + if (obj.hasOwnProperty(i)) { + add(obj[i], i); + } + } + } else { + uristrings.push(encodeURIComponent(temp) + '=' + encodeURIComponent(obj)); + } + + if (temp) { + return uristrings.join(prefix); + } else { + return uristrings.join(prefix) + .replace(/^&/, '') + .replace(/%20/g, '+'); + } +}; + +qq.obj2FormData = function(obj, formData, arrayKeyName) { + "use strict"; + if (!formData) { + formData = new FormData(); + } + + qq.each(obj, function(key, val) { + key = arrayKeyName ? arrayKeyName + '[' + key + ']' : key; + + if (qq.isObject(val)) { + qq.obj2FormData(val, formData, key); + } + else if (qq.isFunction(val)) { + formData.append(key, val()); + } + else { + formData.append(key, val); + } + }); + + return formData; +}; + +qq.obj2Inputs = function(obj, form) { + "use strict"; + var input; + + if (!form) { + form = document.createElement('form'); + } + + qq.obj2FormData(obj, { + append: function(key, val) { + input = document.createElement('input'); + input.setAttribute('name', key); + input.setAttribute('value', val); + form.appendChild(input); + } + }); + + return form; +}; + +qq.setCookie = function(name, value, days) { + var date = new Date(), + expires = ""; + + if (days) { + date.setTime(date.getTime()+(days*24*60*60*1000)); + expires = "; expires="+date.toGMTString(); + } + + document.cookie = name+"="+value+expires+"; path=/"; +}; + +qq.getCookie = function(name) { + var nameEQ = name + "=", + ca = document.cookie.split(';'), + c; + + for(var i=0;i < ca.length;i++) { + c = ca[i]; + while (c.charAt(0)==' ') { + c = c.substring(1,c.length); + } + if (c.indexOf(nameEQ) === 0) { + return c.substring(nameEQ.length,c.length); + } + } +}; + +qq.getCookieNames = function(regexp) { + var cookies = document.cookie.split(';'), + cookieNames = []; + + qq.each(cookies, function(idx, cookie) { + cookie = qq.trimStr(cookie); + + var equalsIdx = cookie.indexOf("="); + + if (cookie.match(regexp)) { + cookieNames.push(cookie.substr(0, equalsIdx)); + } + }); + + return cookieNames; +}; + +qq.deleteCookie = function(name) { + qq.setCookie(name, "", -1); +}; + +qq.areCookiesEnabled = function() { + var randNum = Math.random() * 100000, + name = "qqCookieTest:" + randNum; + qq.setCookie(name, 1); + + if (qq.getCookie(name)) { + qq.deleteCookie(name); + return true; + } + return false; +}; + +/** + * Not recommended for use outside of Fine Uploader since this falls back to an unchecked eval if JSON.parse is not + * implemented. For a more secure JSON.parse polyfill, use Douglas Crockford's json2.js. + */ +qq.parseJson = function(json) { + /*jshint evil: true*/ + if (window.JSON && qq.isFunction(JSON.parse)) { + return JSON.parse(json); + } else { + return eval("(" + json + ")"); + } +}; + +/** + * A generic module which supports object disposing in dispose() method. + * */ +qq.DisposeSupport = function() { + "use strict"; + var disposers = []; + + return { + /** Run all registered disposers */ + dispose: function() { + var disposer; + do { + disposer = disposers.shift(); + if (disposer) { + disposer(); + } + } + while (disposer); + }, + + /** Attach event handler and register de-attacher as a disposer */ + attach: function() { + var args = arguments; + /*jslint undef:true*/ + this.addDisposer(qq(args[0]).attach.apply(this, Array.prototype.slice.call(arguments, 1))); + }, + + /** Add disposer to the collection */ + addDisposer: function(disposeFunction) { + disposers.push(disposeFunction); + } + }; +}; +qq.UploadButton = function(o){ + this._options = { + element: null, + // if set to true adds multiple attribute to file input + multiple: false, + acceptFiles: null, + // name attribute of file input + name: 'file', + onChange: function(input){}, + hoverClass: 'qq-upload-button-hover', + focusClass: 'qq-upload-button-focus' + }; + + qq.extend(this._options, o); + this._disposeSupport = new qq.DisposeSupport(); + + this._element = this._options.element; + + // make button suitable container for input + qq(this._element).css({ + position: 'relative', + overflow: 'hidden', + // Make sure browse button is in the right side + // in Internet Explorer + direction: 'ltr' + }); + + this._input = this._createInput(); +}; + +qq.UploadButton.prototype = { + /* returns file input element */ + getInput: function(){ + return this._input; + }, + /* cleans/recreates the file input */ + reset: function(){ + if (this._input.parentNode){ + qq(this._input).remove(); + } + + qq(this._element).removeClass(this._options.focusClass); + this._input = this._createInput(); + }, + _createInput: function(){ + var input = document.createElement("input"); + + if (this._options.multiple){ + input.setAttribute("multiple", "multiple"); + } + + if (this._options.acceptFiles) input.setAttribute("accept", this._options.acceptFiles); + + input.setAttribute("type", "file"); + input.setAttribute("name", this._options.name); + + qq(input).css({ + position: 'absolute', + // in Opera only 'browse' button + // is clickable and it is located at + // the right side of the input + right: 0, + top: 0, + fontFamily: 'Arial', + // 4 persons reported this, the max values that worked for them were 243, 236, 236, 118 + fontSize: '118px', + margin: 0, + padding: 0, + cursor: 'pointer', + opacity: 0 + }); + + this._element.appendChild(input); + + var self = this; + this._disposeSupport.attach(input, 'change', function(){ + self._options.onChange(input); + }); + + this._disposeSupport.attach(input, 'mouseover', function(){ + qq(self._element).addClass(self._options.hoverClass); + }); + this._disposeSupport.attach(input, 'mouseout', function(){ + qq(self._element).removeClass(self._options.hoverClass); + }); + this._disposeSupport.attach(input, 'focus', function(){ + qq(self._element).addClass(self._options.focusClass); + }); + this._disposeSupport.attach(input, 'blur', function(){ + qq(self._element).removeClass(self._options.focusClass); + }); + + // IE and Opera, unfortunately have 2 tab stops on file input + // which is unacceptable in our case, disable keyboard access + if (window.attachEvent){ + // it is IE or Opera + input.setAttribute('tabIndex', "-1"); + } + + return input; + } +}; +qq.FineUploaderBasic = function(o){ + var that = this; + this._options = { + debug: false, + button: null, + multiple: true, + maxConnections: 3, + disableCancelForFormUploads: false, + autoUpload: true, + request: { + endpoint: '/server/upload', + params: {}, + paramsInBody: true, + customHeaders: {}, + forceMultipart: true, + inputName: 'qqfile', + uuidName: 'qquuid', + totalFileSizeName: 'qqtotalfilesize' + }, + validation: { + allowedExtensions: [], + sizeLimit: 0, + minSizeLimit: 0, + stopOnFirstInvalidFile: true + }, + callbacks: { + onSubmit: function(id, name){}, + onComplete: function(id, name, responseJSON){}, + onCancel: function(id, name){}, + onUpload: function(id, name){}, + onUploadChunk: function(id, name, chunkData){}, + onResume: function(id, fileName, chunkData){}, + onProgress: function(id, name, loaded, total){}, + onError: function(id, name, reason) {}, + onAutoRetry: function(id, name, attemptNumber) {}, + onManualRetry: function(id, name) {}, + onValidateBatch: function(fileOrBlobData) {}, + onValidate: function(fileOrBlobData) {}, + onSubmitDelete: function(id) {}, + onDelete: function(id){}, + onDeleteComplete: function(id, xhr, isError){} + }, + messages: { + typeError: "{file} has an invalid extension. Valid extension(s): {extensions}.", + sizeError: "{file} is too large, maximum file size is {sizeLimit}.", + minSizeError: "{file} is too small, minimum file size is {minSizeLimit}.", + emptyError: "{file} is empty, please select files again without it.", + noFilesError: "No files to upload.", + onLeave: "The files are being uploaded, if you leave now the upload will be cancelled." + }, + retry: { + enableAuto: false, + maxAutoAttempts: 3, + autoAttemptDelay: 5, + preventRetryResponseProperty: 'preventRetry' + }, + classes: { + buttonHover: 'qq-upload-button-hover', + buttonFocus: 'qq-upload-button-focus' + }, + chunking: { + enabled: false, + partSize: 2000000, + paramNames: { + partIndex: 'qqpartindex', + partByteOffset: 'qqpartbyteoffset', + chunkSize: 'qqchunksize', + totalFileSize: 'qqtotalfilesize', + totalParts: 'qqtotalparts', + filename: 'qqfilename' + } + }, + resume: { + enabled: false, + id: null, + cookiesExpireIn: 7, //days + paramNames: { + resuming: "qqresume" + } + }, + formatFileName: function(fileOrBlobName) { + if (fileOrBlobName.length > 33) { + fileOrBlobName = fileOrBlobName.slice(0, 19) + '...' + fileOrBlobName.slice(-14); + } + return fileOrBlobName; + }, + text: { + sizeSymbols: ['kB', 'MB', 'GB', 'TB', 'PB', 'EB'] + }, + deleteFile : { + enabled: false, + endpoint: '/server/upload', + customHeaders: {}, + params: {} + }, + cors: { + expected: false, + sendCredentials: false + }, + blobs: { + defaultName: 'Misc data', + paramNames: { + name: 'qqblobname' + } + } + }; + + qq.extend(this._options, o, true); + this._wrapCallbacks(); + this._disposeSupport = new qq.DisposeSupport(); + + // number of files being uploaded + this._filesInProgress = []; + + this._storedIds = []; + + this._autoRetries = []; + this._retryTimeouts = []; + this._preventRetries = []; + + this._paramsStore = this._createParamsStore("request"); + this._deleteFileParamsStore = this._createParamsStore("deleteFile"); + + this._endpointStore = this._createEndpointStore("request"); + this._deleteFileEndpointStore = this._createEndpointStore("deleteFile"); + + this._handler = this._createUploadHandler(); + this._deleteHandler = this._createDeleteHandler(); + + if (this._options.button){ + this._button = this._createUploadButton(this._options.button); + } + + this._preventLeaveInProgress(); +}; + +qq.FineUploaderBasic.prototype = { + log: function(str, level) { + if (this._options.debug && (!level || level === 'info')) { + qq.log('[FineUploader] ' + str); + } + else if (level && level !== 'info') { + qq.log('[FineUploader] ' + str, level); + + } + }, + setParams: function(params, id) { + /*jshint eqeqeq: true, eqnull: true*/ + if (id == null) { + this._options.request.params = params; + } + else { + this._paramsStore.setParams(params, id); + } + }, + setDeleteFileParams: function(params, id) { + /*jshint eqeqeq: true, eqnull: true*/ + if (id == null) { + this._options.deleteFile.params = params; + } + else { + this._deleteFileParamsStore.setParams(params, id); + } + }, + setEndpoint: function(endpoint, id) { + /*jshint eqeqeq: true, eqnull: true*/ + if (id == null) { + this._options.request.endpoint = endpoint; + } + else { + this._endpointStore.setEndpoint(endpoint, id); + } + }, + getInProgress: function(){ + return this._filesInProgress.length; + }, + uploadStoredFiles: function(){ + "use strict"; + var idToUpload; + + while(this._storedIds.length) { + idToUpload = this._storedIds.shift(); + this._filesInProgress.push(idToUpload); + this._handler.upload(idToUpload); + } + }, + clearStoredFiles: function(){ + this._storedIds = []; + }, + retry: function(id) { + if (this._onBeforeManualRetry(id)) { + this._handler.retry(id); + return true; + } + else { + return false; + } + }, + cancel: function(id) { + this._handler.cancel(id); + }, + cancelAll: function() { + var storedIdsCopy = [], + self = this; + + qq.extend(storedIdsCopy, this._storedIds); + qq.each(storedIdsCopy, function(idx, storedFileId) { + self.cancel(storedFileId); + }); + + this._handler.cancelAll(); + }, + reset: function() { + this.log("Resetting uploader..."); + this._handler.reset(); + this._filesInProgress = []; + this._storedIds = []; + this._autoRetries = []; + this._retryTimeouts = []; + this._preventRetries = []; + this._button.reset(); + this._paramsStore.reset(); + this._endpointStore.reset(); + }, + addFiles: function(filesBlobDataOrInputs) { + var self = this, + verifiedFilesOrInputs = [], + index, fileOrInput; + + if (filesBlobDataOrInputs) { + if (!window.FileList || !(filesBlobDataOrInputs instanceof FileList)) { + filesBlobDataOrInputs = [].concat(filesBlobDataOrInputs); + } + + for (index = 0; index < filesBlobDataOrInputs.length; index+=1) { + fileOrInput = filesBlobDataOrInputs[index]; + + if (qq.isFileOrInput(fileOrInput)) { + verifiedFilesOrInputs.push(fileOrInput); + } + else { + self.log(fileOrInput + ' is not a File or INPUT element! Ignoring!', 'warn'); + } + } + + this.log('Processing ' + verifiedFilesOrInputs.length + ' files or inputs...'); + this._uploadFileOrBlobDataList(verifiedFilesOrInputs); + } + }, + addBlobs: function(blobDataOrArray) { + if (blobDataOrArray) { + var blobDataArray = [].concat(blobDataOrArray), + verifiedBlobDataList = [], + self = this; + + qq.each(blobDataArray, function(idx, blobData) { + if (qq.isBlob(blobData) && !qq.isFileOrInput(blobData)) { + verifiedBlobDataList.push({ + blob: blobData, + name: self._options.blobs.defaultName + }); + } + else if (qq.isObject(blobData) && blobData.blob && blobData.name) { + verifiedBlobDataList.push(blobData); + } + else { + self.log("addBlobs: entry at index " + idx + " is not a Blob or a BlobData object", "error"); + } + }); + + this._uploadFileOrBlobDataList(verifiedBlobDataList); + } + else { + this.log("undefined or non-array parameter passed into addBlobs", "error"); + } + }, + getUuid: function(id) { + return this._handler.getUuid(id); + }, + getResumableFilesData: function() { + return this._handler.getResumableFilesData(); + }, + getSize: function(id) { + return this._handler.getSize(id); + }, + getFile: function(fileOrBlobId) { + return this._handler.getFile(fileOrBlobId); + }, + deleteFile: function(id) { + this._onSubmitDelete(id); + }, + setDeleteFileEndpoint: function(endpoint, id) { + /*jshint eqeqeq: true, eqnull: true*/ + if (id == null) { + this._options.deleteFile.endpoint = endpoint; + } + else { + this._deleteFileEndpointStore.setEndpoint(endpoint, id); + } + }, + _createUploadButton: function(element){ + var self = this; + + var button = new qq.UploadButton({ + element: element, + multiple: this._options.multiple && qq.isXhrUploadSupported(), + acceptFiles: this._options.validation.acceptFiles, + onChange: function(input){ + self._onInputChange(input); + }, + hoverClass: this._options.classes.buttonHover, + focusClass: this._options.classes.buttonFocus + }); + + this._disposeSupport.addDisposer(function() { button.dispose(); }); + return button; + }, + _createUploadHandler: function(){ + var self = this; + + return new qq.UploadHandler({ + debug: this._options.debug, + forceMultipart: this._options.request.forceMultipart, + maxConnections: this._options.maxConnections, + customHeaders: this._options.request.customHeaders, + inputName: this._options.request.inputName, + uuidParamName: this._options.request.uuidName, + totalFileSizeParamName: this._options.request.totalFileSizeName, + cors: this._options.cors, + demoMode: this._options.demoMode, + paramsInBody: this._options.request.paramsInBody, + paramsStore: this._paramsStore, + endpointStore: this._endpointStore, + chunking: this._options.chunking, + resume: this._options.resume, + blobs: this._options.blobs, + log: function(str, level) { + self.log(str, level); + }, + onProgress: function(id, name, loaded, total){ + self._onProgress(id, name, loaded, total); + self._options.callbacks.onProgress(id, name, loaded, total); + }, + onComplete: function(id, name, result, xhr){ + self._onComplete(id, name, result, xhr); + self._options.callbacks.onComplete(id, name, result); + }, + onCancel: function(id, name){ + self._onCancel(id, name); + self._options.callbacks.onCancel(id, name); + }, + onUpload: function(id, name){ + self._onUpload(id, name); + self._options.callbacks.onUpload(id, name); + }, + onUploadChunk: function(id, name, chunkData){ + self._options.callbacks.onUploadChunk(id, name, chunkData); + }, + onResume: function(id, name, chunkData) { + return self._options.callbacks.onResume(id, name, chunkData); + }, + onAutoRetry: function(id, name, responseJSON, xhr) { + self._preventRetries[id] = responseJSON[self._options.retry.preventRetryResponseProperty]; + + if (self._shouldAutoRetry(id, name, responseJSON)) { + self._maybeParseAndSendUploadError(id, name, responseJSON, xhr); + self._options.callbacks.onAutoRetry(id, name, self._autoRetries[id] + 1); + self._onBeforeAutoRetry(id, name); + + self._retryTimeouts[id] = setTimeout(function() { + self._onAutoRetry(id, name, responseJSON) + }, self._options.retry.autoAttemptDelay * 1000); + + return true; + } + else { + return false; + } + } + }); + }, + _createDeleteHandler: function() { + var self = this; + + return new qq.DeleteFileAjaxRequestor({ + maxConnections: this._options.maxConnections, + customHeaders: this._options.deleteFile.customHeaders, + paramsStore: this._deleteFileParamsStore, + endpointStore: this._deleteFileEndpointStore, + demoMode: this._options.demoMode, + cors: this._options.cors, + log: function(str, level) { + self.log(str, level); + }, + onDelete: function(id) { + self._onDelete(id); + self._options.callbacks.onDelete(id); + }, + onDeleteComplete: function(id, xhr, isError) { + self._onDeleteComplete(id, xhr, isError); + self._options.callbacks.onDeleteComplete(id, xhr, isError); + } + + }); + }, + _preventLeaveInProgress: function(){ + var self = this; + + this._disposeSupport.attach(window, 'beforeunload', function(e){ + if (!self._filesInProgress.length){return;} + + var e = e || window.event; + // for ie, ff + e.returnValue = self._options.messages.onLeave; + // for webkit + return self._options.messages.onLeave; + }); + }, + _onSubmit: function(id, name){ + if (this._options.autoUpload) { + this._filesInProgress.push(id); + } + }, + _onProgress: function(id, name, loaded, total){ + }, + _onComplete: function(id, name, result, xhr){ + this._removeFromFilesInProgress(id); + this._maybeParseAndSendUploadError(id, name, result, xhr); + }, + _onCancel: function(id, name){ + this._removeFromFilesInProgress(id); + + clearTimeout(this._retryTimeouts[id]); + + var storedItemIndex = qq.indexOf(this._storedIds, id); + if (!this._options.autoUpload && storedItemIndex >= 0) { + this._storedIds.splice(storedItemIndex, 1); + } + }, + _isDeletePossible: function() { + return (this._options.deleteFile.enabled && + (!this._options.cors.expected || + (this._options.cors.expected && (qq.ie10() || !qq.ie())) + ) + ); + }, + _onSubmitDelete: function(id) { + if (this._isDeletePossible()) { + if (this._options.callbacks.onSubmitDelete(id)) { + this._deleteHandler.sendDelete(id, this.getUuid(id)); + } + } + else { + this.log("Delete request ignored for ID " + id + ", delete feature is disabled or request not possible " + + "due to CORS on a user agent that does not support pre-flighting.", "warn"); + return false; + } + }, + _onDelete: function(fileId) {}, + _onDeleteComplete: function(id, xhr, isError) { + var name = this._handler.getName(id); + + if (isError) { + this.log("Delete request for '" + name + "' has failed.", "error"); + this._options.callbacks.onError(id, name, "Delete request failed with response code " + xhr.status); + } + else { + this.log("Delete request for '" + name + "' has succeeded."); + } + }, + _removeFromFilesInProgress: function(id) { + var index = qq.indexOf(this._filesInProgress, id); + if (index >= 0) { + this._filesInProgress.splice(index, 1); + } + }, + _onUpload: function(id, name){}, + _onInputChange: function(input){ + if (qq.isXhrUploadSupported()){ + this.addFiles(input.files); + } else { + this.addFiles(input); + } + this._button.reset(); + }, + _onBeforeAutoRetry: function(id, name) { + this.log("Waiting " + this._options.retry.autoAttemptDelay + " seconds before retrying " + name + "..."); + }, + _onAutoRetry: function(id, name, responseJSON) { + this.log("Retrying " + name + "..."); + this._autoRetries[id]++; + this._handler.retry(id); + }, + _shouldAutoRetry: function(id, name, responseJSON) { + if (!this._preventRetries[id] && this._options.retry.enableAuto) { + if (this._autoRetries[id] === undefined) { + this._autoRetries[id] = 0; + } + + return this._autoRetries[id] < this._options.retry.maxAutoAttempts + } + + return false; + }, + //return false if we should not attempt the requested retry + _onBeforeManualRetry: function(id) { + if (this._preventRetries[id]) { + this.log("Retries are forbidden for id " + id, 'warn'); + return false; + } + else if (this._handler.isValid(id)) { + var fileName = this._handler.getName(id); + + if (this._options.callbacks.onManualRetry(id, fileName) === false) { + return false; + } + + this.log("Retrying upload for '" + fileName + "' (id: " + id + ")..."); + this._filesInProgress.push(id); + return true; + } + else { + this.log("'" + id + "' is not a valid file ID", 'error'); + return false; + } + }, + _maybeParseAndSendUploadError: function(id, name, response, xhr) { + //assuming no one will actually set the response code to something other than 200 and still set 'success' to true + if (!response.success){ + if (xhr && xhr.status !== 200 && !response.error) { + this._options.callbacks.onError(id, name, "XHR returned response code " + xhr.status); + } + else { + var errorReason = response.error ? response.error : "Upload failure reason unknown"; + this._options.callbacks.onError(id, name, errorReason); + } + } + }, + _uploadFileOrBlobDataList: function(fileOrBlobDataList){ + var validationDescriptors, index, batchInvalid; + + validationDescriptors = this._getValidationDescriptors(fileOrBlobDataList); + batchInvalid = this._options.callbacks.onValidateBatch(validationDescriptors) === false; + + if (!batchInvalid) { + if (fileOrBlobDataList.length > 0) { + for (index = 0; index < fileOrBlobDataList.length; index++){ + if (this._validateFileOrBlobData(fileOrBlobDataList[index])){ + this._upload(fileOrBlobDataList[index]); + } else { + if (this._options.validation.stopOnFirstInvalidFile){ + return; + } + } + } + } + else { + this._error('noFilesError', ""); + } + } + }, + _upload: function(blobOrFileContainer){ + var id = this._handler.add(blobOrFileContainer); + var name = this._handler.getName(id); + + if (this._options.callbacks.onSubmit(id, name) !== false){ + this._onSubmit(id, name); + if (this._options.autoUpload) { + this._handler.upload(id); + } + else { + this._storeForLater(id); + } + } + }, + _storeForLater: function(id) { + this._storedIds.push(id); + }, + _validateFileOrBlobData: function(fileOrBlobData){ + var validationDescriptor, name, size; + + validationDescriptor = this._getValidationDescriptor(fileOrBlobData); + name = validationDescriptor.name; + size = validationDescriptor.size; + + if (this._options.callbacks.onValidate(validationDescriptor) === false) { + return false; + } + + if (qq.isFileOrInput(fileOrBlobData) && !this._isAllowedExtension(name)){ + this._error('typeError', name); + return false; + + } + else if (size === 0){ + this._error('emptyError', name); + return false; + + } + else if (size && this._options.validation.sizeLimit && size > this._options.validation.sizeLimit){ + this._error('sizeError', name); + return false; + + } + else if (size && size < this._options.validation.minSizeLimit){ + this._error('minSizeError', name); + return false; + } + + return true; + }, + _error: function(code, name){ + var message = this._options.messages[code]; + function r(name, replacement){ message = message.replace(name, replacement); } + + var extensions = this._options.validation.allowedExtensions.join(', ').toLowerCase(); + + r('{file}', this._options.formatFileName(name)); + r('{extensions}', extensions); + r('{sizeLimit}', this._formatSize(this._options.validation.sizeLimit)); + r('{minSizeLimit}', this._formatSize(this._options.validation.minSizeLimit)); + + this._options.callbacks.onError(null, name, message); + + return message; + }, + _isAllowedExtension: function(fileName){ + var allowed = this._options.validation.allowedExtensions, + valid = false; + + if (!allowed.length) { + return true; + } + + qq.each(allowed, function(idx, allowedExt) { + /*jshint eqeqeq: true, eqnull: true*/ + var extRegex = new RegExp('\\.' + allowedExt + "$", 'i'); + + if (fileName.match(extRegex) != null) { + valid = true; + return false; + } + }); + + return valid; + }, + _formatSize: function(bytes){ + var i = -1; + do { + bytes = bytes / 1024; + i++; + } while (bytes > 99); + + return Math.max(bytes, 0.1).toFixed(1) + this._options.text.sizeSymbols[i]; + }, + _wrapCallbacks: function() { + var self, safeCallback; + + self = this; + + safeCallback = function(name, callback, args) { + try { + return callback.apply(self, args); + } + catch (exception) { + self.log("Caught exception in '" + name + "' callback - " + exception.message, 'error'); + } + } + + for (var prop in this._options.callbacks) { + (function() { + var callbackName, callbackFunc; + callbackName = prop; + callbackFunc = self._options.callbacks[callbackName]; + self._options.callbacks[callbackName] = function() { + return safeCallback(callbackName, callbackFunc, arguments); + } + }()); + } + }, + _parseFileOrBlobDataName: function(fileOrBlobData) { + var name; + + if (qq.isFileOrInput(fileOrBlobData)) { + if (fileOrBlobData.value) { + // it is a file input + // get input value and remove path to normalize + name = fileOrBlobData.value.replace(/.*(\/|\\)/, ""); + } else { + // fix missing properties in Safari 4 and firefox 11.0a2 + name = (fileOrBlobData.fileName !== null && fileOrBlobData.fileName !== undefined) ? fileOrBlobData.fileName : fileOrBlobData.name; + } + } + else { + name = fileOrBlobData.name; + } + + return name; + }, + _parseFileOrBlobDataSize: function(fileOrBlobData) { + var size; + + if (qq.isFileOrInput(fileOrBlobData)) { + if (!fileOrBlobData.value){ + // fix missing properties in Safari 4 and firefox 11.0a2 + size = (fileOrBlobData.fileSize !== null && fileOrBlobData.fileSize !== undefined) ? fileOrBlobData.fileSize : fileOrBlobData.size; + } + } + else { + size = fileOrBlobData.blob.size; + } + + return size; + }, + _getValidationDescriptor: function(fileOrBlobData) { + var name, size, fileDescriptor; + + fileDescriptor = {}; + name = this._parseFileOrBlobDataName(fileOrBlobData); + size = this._parseFileOrBlobDataSize(fileOrBlobData); + + fileDescriptor.name = name; + if (size) { + fileDescriptor.size = size; + } + + return fileDescriptor; + }, + _getValidationDescriptors: function(files) { + var self = this, + fileDescriptors = []; + + qq.each(files, function(idx, file) { + fileDescriptors.push(self._getValidationDescriptor(file)); + }); + + return fileDescriptors; + }, + _createParamsStore: function(type) { + var paramsStore = {}, + self = this; + + return { + setParams: function(params, id) { + var paramsCopy = {}; + qq.extend(paramsCopy, params); + paramsStore[id] = paramsCopy; + }, + + getParams: function(id) { + /*jshint eqeqeq: true, eqnull: true*/ + var paramsCopy = {}; + + if (id != null && paramsStore[id]) { + qq.extend(paramsCopy, paramsStore[id]); + } + else { + qq.extend(paramsCopy, self._options[type].params); + } + + return paramsCopy; + }, + + remove: function(fileId) { + return delete paramsStore[fileId]; + }, + + reset: function() { + paramsStore = {}; + } + }; + }, + _createEndpointStore: function(type) { + var endpointStore = {}, + self = this; + + return { + setEndpoint: function(endpoint, id) { + endpointStore[id] = endpoint; + }, + + getEndpoint: function(id) { + /*jshint eqeqeq: true, eqnull: true*/ + if (id != null && endpointStore[id]) { + return endpointStore[id]; + } + + return self._options[type].endpoint; + }, + + remove: function(fileId) { + return delete endpointStore[fileId]; + }, + + reset: function() { + endpointStore = {}; + } + }; + } +}; +/*globals qq, document*/ +qq.DragAndDrop = function(o) { + "use strict"; + + var options, dz, dirPending, + droppedFiles = [], + droppedEntriesCount = 0, + droppedEntriesParsedCount = 0, + disposeSupport = new qq.DisposeSupport(); + + options = { + dropArea: null, + extraDropzones: [], + hideDropzones: true, + multiple: true, + classes: { + dropActive: null + }, + callbacks: { + dropProcessing: function(isProcessing, files) {}, + error: function(code, filename) {}, + log: function(message, level) {} + } + }; + + qq.extend(options, o); + + function maybeUploadDroppedFiles() { + if (droppedEntriesCount === droppedEntriesParsedCount && !dirPending) { + options.callbacks.log('Grabbed ' + droppedFiles.length + " files after tree traversal."); + dz.dropDisabled(false); + options.callbacks.dropProcessing(false, droppedFiles); + } + } + function addDroppedFile(file) { + droppedFiles.push(file); + droppedEntriesParsedCount+=1; + maybeUploadDroppedFiles(); + } + + function traverseFileTree(entry) { + var dirReader, i; + + droppedEntriesCount+=1; + + if (entry.isFile) { + entry.file(function(file) { + addDroppedFile(file); + }); + } + else if (entry.isDirectory) { + dirPending = true; + dirReader = entry.createReader(); + dirReader.readEntries(function(entries) { + droppedEntriesParsedCount+=1; + for (i = 0; i < entries.length; i+=1) { + traverseFileTree(entries[i]); + } + + dirPending = false; + + if (!entries.length) { + maybeUploadDroppedFiles(); + } + }); + } + } + + function handleDataTransfer(dataTransfer) { + var i, items, entry; + + options.callbacks.dropProcessing(true); + dz.dropDisabled(true); + + if (dataTransfer.files.length > 1 && !options.multiple) { + options.callbacks.dropProcessing(false); + options.callbacks.error('tooManyFilesError', ""); + dz.dropDisabled(false); + } + else { + droppedFiles = []; + droppedEntriesCount = 0; + droppedEntriesParsedCount = 0; + + if (qq.isFolderDropSupported(dataTransfer)) { + items = dataTransfer.items; + + for (i = 0; i < items.length; i+=1) { + entry = items[i].webkitGetAsEntry(); + if (entry) { + //due to a bug in Chrome's File System API impl - #149735 + if (entry.isFile) { + droppedFiles.push(items[i].getAsFile()); + if (i === items.length-1) { + maybeUploadDroppedFiles(); + } + } + + else { + traverseFileTree(entry); + } + } + } + } + else { + options.callbacks.dropProcessing(false, dataTransfer.files); + dz.dropDisabled(false); + } + } + } + + function setupDropzone(dropArea){ + dz = new qq.UploadDropZone({ + element: dropArea, + onEnter: function(e){ + qq(dropArea).addClass(options.classes.dropActive); + e.stopPropagation(); + }, + onLeaveNotDescendants: function(e){ + qq(dropArea).removeClass(options.classes.dropActive); + }, + onDrop: function(e){ + if (options.hideDropzones) { + qq(dropArea).hide(); + } + qq(dropArea).removeClass(options.classes.dropActive); + + handleDataTransfer(e.dataTransfer); + } + }); + + disposeSupport.addDisposer(function() { + dz.dispose(); + }); + + if (options.hideDropzones) { + qq(dropArea).hide(); + } + } + + function isFileDrag(dragEvent) { + var fileDrag; + + qq.each(dragEvent.dataTransfer.types, function(key, val) { + if (val === 'Files') { + fileDrag = true; + return false; + } + }); + + return fileDrag; + } + + function setupDragDrop(){ + if (options.dropArea) { + options.extraDropzones.push(options.dropArea); + } + + var i, dropzones = options.extraDropzones; + + for (i=0; i < dropzones.length; i+=1){ + setupDropzone(dropzones[i]); + } + + // IE <= 9 does not support the File API used for drag+drop uploads + if (options.dropArea && (!qq.ie() || qq.ie10())) { + disposeSupport.attach(document, 'dragenter', function(e) { + if (!dz.dropDisabled() && isFileDrag(e)) { + if (qq(options.dropArea).hasClass(options.classes.dropDisabled)) { + return; + } + + options.dropArea.style.display = 'block'; + for (i=0; i < dropzones.length; i+=1) { + dropzones[i].style.display = 'block'; + } + } + }); + } + disposeSupport.attach(document, 'dragleave', function(e){ + if (options.hideDropzones && qq.FineUploader.prototype._leaving_document_out(e)) { + for (i=0; i < dropzones.length; i+=1) { + qq(dropzones[i]).hide(); + } + } + }); + disposeSupport.attach(document, 'drop', function(e){ + if (options.hideDropzones) { + for (i=0; i < dropzones.length; i+=1) { + qq(dropzones[i]).hide(); + } + } + e.preventDefault(); + }); + } + + return { + setup: function() { + setupDragDrop(); + }, + + setupExtraDropzone: function(element) { + options.extraDropzones.push(element); + setupDropzone(element); + }, + + removeExtraDropzone: function(element) { + var i, dzs = options.extraDropzones; + for(i in dzs) { + if (dzs[i] === element) { + return dzs.splice(i, 1); + } + } + }, + + dispose: function() { + disposeSupport.dispose(); + dz.dispose(); + } + }; +}; + + +qq.UploadDropZone = function(o){ + "use strict"; + + var options, element, preventDrop, dropOutsideDisabled, disposeSupport = new qq.DisposeSupport(); + + options = { + element: null, + onEnter: function(e){}, + onLeave: function(e){}, + // is not fired when leaving element by hovering descendants + onLeaveNotDescendants: function(e){}, + onDrop: function(e){} + }; + + qq.extend(options, o); + element = options.element; + + function dragover_should_be_canceled(){ + return qq.safari() || (qq.firefox() && qq.windows()); + } + + function disableDropOutside(e){ + // run only once for all instances + if (!dropOutsideDisabled ){ + + // for these cases we need to catch onDrop to reset dropArea + if (dragover_should_be_canceled){ + disposeSupport.attach(document, 'dragover', function(e){ + e.preventDefault(); + }); + } else { + disposeSupport.attach(document, 'dragover', function(e){ + if (e.dataTransfer){ + e.dataTransfer.dropEffect = 'none'; + e.preventDefault(); + } + }); + } + + dropOutsideDisabled = true; + } + } + + function isValidFileDrag(e){ + // e.dataTransfer currently causing IE errors + // IE9 does NOT support file API, so drag-and-drop is not possible + if (qq.ie() && !qq.ie10()) { + return false; + } + + var effectTest, dt = e.dataTransfer, + // do not check dt.types.contains in webkit, because it crashes safari 4 + isSafari = qq.safari(); + + // dt.effectAllowed is none in Safari 5 + // dt.types.contains check is for firefox + effectTest = qq.ie10() ? true : dt.effectAllowed !== 'none'; + return dt && effectTest && (dt.files || (!isSafari && dt.types.contains && dt.types.contains('Files'))); + } + + function isOrSetDropDisabled(isDisabled) { + if (isDisabled !== undefined) { + preventDrop = isDisabled; + } + return preventDrop; + } + + function attachEvents(){ + disposeSupport.attach(element, 'dragover', function(e){ + if (!isValidFileDrag(e)) { + return; + } + + var effect = qq.ie() ? null : e.dataTransfer.effectAllowed; + if (effect === 'move' || effect === 'linkMove'){ + e.dataTransfer.dropEffect = 'move'; // for FF (only move allowed) + } else { + e.dataTransfer.dropEffect = 'copy'; // for Chrome + } + + e.stopPropagation(); + e.preventDefault(); + }); + + disposeSupport.attach(element, 'dragenter', function(e){ + if (!isOrSetDropDisabled()) { + if (!isValidFileDrag(e)) { + return; + } + options.onEnter(e); + } + }); + + disposeSupport.attach(element, 'dragleave', function(e){ + if (!isValidFileDrag(e)) { + return; + } + + options.onLeave(e); + + var relatedTarget = document.elementFromPoint(e.clientX, e.clientY); + // do not fire when moving a mouse over a descendant + if (qq(this).contains(relatedTarget)) { + return; + } + + options.onLeaveNotDescendants(e); + }); + + disposeSupport.attach(element, 'drop', function(e){ + if (!isOrSetDropDisabled()) { + if (!isValidFileDrag(e)) { + return; + } + + e.preventDefault(); + options.onDrop(e); + } + }); + } + + disableDropOutside(); + attachEvents(); + + return { + dropDisabled: function(isDisabled) { + return isOrSetDropDisabled(isDisabled); + }, + + dispose: function() { + disposeSupport.dispose(); + } + }; +}; +/** + * Class that creates upload widget with drag-and-drop and file list + * @inherits qq.FineUploaderBasic + */ +qq.FineUploader = function(o){ + // call parent constructor + qq.FineUploaderBasic.apply(this, arguments); + + // additional options + qq.extend(this._options, { + element: null, + listElement: null, + dragAndDrop: { + extraDropzones: [], + hideDropzones: true, + disableDefaultDropzone: false + }, + text: { + uploadButton: 'Upload a file', + cancelButton: 'Cancel', + retryButton: 'Retry', + deleteButton: 'Delete', + failUpload: 'Upload failed', + dragZone: 'Drop files here to upload', + dropProcessing: 'Processing dropped files...', + formatProgress: "{percent}% of {total_size}", + waitingForResponse: "Processing..." + }, + template: '

', + + // template for one item in file list + fileTemplate: '
  • ' + + '
    ' + + '' + + '' + + '' + + '' + + '
    {cancelButtonText}' + + '{retryButtonText}' + + '{deleteButtonText}' + + '{statusText}' + + '
  • ', + classes: { + button: 'qq-upload-button', + drop: 'qq-upload-drop-area', + dropActive: 'qq-upload-drop-area-active', + dropDisabled: 'qq-upload-drop-area-disabled', + list: 'qq-upload-list', + progressBar: 'qq-progress-bar', + file: 'qq-upload-file', + spinner: 'qq-upload-spinner', + finished: 'qq-upload-finished', + retrying: 'qq-upload-retrying', + retryable: 'qq-upload-retryable', + size: 'qq-upload-size', + cancel: 'qq-upload-cancel', + deleteButton: 'qq-upload-delete', + retry: 'qq-upload-retry', + statusText: 'qq-upload-status-text', + + success: 'qq-upload-success', + fail: 'qq-upload-fail', + + successIcon: null, + failIcon: null, + + dropProcessing: 'qq-drop-processing', + dropProcessingSpinner: 'qq-drop-processing-spinner' + }, + failedUploadTextDisplay: { + mode: 'default', //default, custom, or none + maxChars: 50, + responseProperty: 'error', + enableTooltip: true + }, + messages: { + tooManyFilesError: "You may only drop one file" + }, + retry: { + showAutoRetryNote: true, + autoRetryNote: "Retrying {retryNum}/{maxAuto}...", + showButton: false + }, + deleteFile: { + forceConfirm: false, + confirmMessage: "Are you sure you want to delete {filename}?", + deletingStatusText: "Deleting...", + deletingFailedText: "Delete failed" + + }, + display: { + fileSizeOnSubmit: false + }, + showMessage: function(message){ + setTimeout(function() { + alert(message); + }, 0); + }, + showConfirm: function(message, okCallback, cancelCallback) { + setTimeout(function() { + var result = confirm(message); + if (result) { + okCallback(); + } + else if (cancelCallback) { + cancelCallback(); + } + }, 0); + } + }, true); + + // overwrite options with user supplied + qq.extend(this._options, o, true); + this._wrapCallbacks(); + + // overwrite the upload button text if any + // same for the Cancel button and Fail message text + this._options.template = this._options.template.replace(/\{dragZoneText\}/g, this._options.text.dragZone); + this._options.template = this._options.template.replace(/\{uploadButtonText\}/g, this._options.text.uploadButton); + this._options.template = this._options.template.replace(/\{dropProcessingText\}/g, this._options.text.dropProcessing); + this._options.fileTemplate = this._options.fileTemplate.replace(/\{cancelButtonText\}/g, this._options.text.cancelButton); + this._options.fileTemplate = this._options.fileTemplate.replace(/\{retryButtonText\}/g, this._options.text.retryButton); + this._options.fileTemplate = this._options.fileTemplate.replace(/\{deleteButtonText\}/g, this._options.text.deleteButton); + this._options.fileTemplate = this._options.fileTemplate.replace(/\{statusText\}/g, ""); + + this._element = this._options.element; + this._element.innerHTML = this._options.template; + this._listElement = this._options.listElement || this._find(this._element, 'list'); + + this._classes = this._options.classes; + + if (!this._button) { + this._button = this._createUploadButton(this._find(this._element, 'button')); + } + + this._bindCancelAndRetryEvents(); + + this._dnd = this._setupDragAndDrop(); +}; + +// inherit from Basic Uploader +qq.extend(qq.FineUploader.prototype, qq.FineUploaderBasic.prototype); + +qq.extend(qq.FineUploader.prototype, { + clearStoredFiles: function() { + qq.FineUploaderBasic.prototype.clearStoredFiles.apply(this, arguments); + this._listElement.innerHTML = ""; + }, + addExtraDropzone: function(element){ + this._dnd.setupExtraDropzone(element); + }, + removeExtraDropzone: function(element){ + return this._dnd.removeExtraDropzone(element); + }, + getItemByFileId: function(id){ + var item = this._listElement.firstChild; + + // there can't be txt nodes in dynamically created list + // and we can use nextSibling + while (item){ + if (item.qqFileId == id) return item; + item = item.nextSibling; + } + }, + reset: function() { + qq.FineUploaderBasic.prototype.reset.apply(this, arguments); + this._element.innerHTML = this._options.template; + this._listElement = this._options.listElement || this._find(this._element, 'list'); + if (!this._options.button) { + this._button = this._createUploadButton(this._find(this._element, 'button')); + } + this._bindCancelAndRetryEvents(); + this._dnd.dispose(); + this._dnd = this._setupDragAndDrop(); + }, + _removeFileItem: function(fileId) { + var item = this.getItemByFileId(fileId); + qq(item).remove(); + }, + _setupDragAndDrop: function() { + var self = this, + dropProcessingEl = this._find(this._element, 'dropProcessing'), + dnd, preventSelectFiles, defaultDropAreaEl; + + preventSelectFiles = function(event) { + event.preventDefault(); + }; + + if (!this._options.dragAndDrop.disableDefaultDropzone) { + defaultDropAreaEl = this._find(this._options.element, 'drop'); + } + + dnd = new qq.DragAndDrop({ + dropArea: defaultDropAreaEl, + extraDropzones: this._options.dragAndDrop.extraDropzones, + hideDropzones: this._options.dragAndDrop.hideDropzones, + multiple: this._options.multiple, + classes: { + dropActive: this._options.classes.dropActive + }, + callbacks: { + dropProcessing: function(isProcessing, files) { + var input = self._button.getInput(); + + if (isProcessing) { + qq(dropProcessingEl).css({display: 'block'}); + qq(input).attach('click', preventSelectFiles); + } + else { + qq(dropProcessingEl).hide(); + qq(input).detach('click', preventSelectFiles); + } + + if (files) { + self.addFiles(files); + } + }, + error: function(code, filename) { + self._error(code, filename); + }, + log: function(message, level) { + self.log(message, level); + } + } + }); + + dnd.setup(); + + return dnd; + }, + _leaving_document_out: function(e){ + return ((qq.chrome() || (qq.safari() && qq.windows())) && e.clientX == 0 && e.clientY == 0) // null coords for Chrome and Safari Windows + || (qq.firefox() && !e.relatedTarget); // null e.relatedTarget for Firefox + }, + _storeForLater: function(id) { + qq.FineUploaderBasic.prototype._storeForLater.apply(this, arguments); + var item = this.getItemByFileId(id); + qq(this._find(item, 'spinner')).hide(); + }, + /** + * Gets one of the elements listed in this._options.classes + **/ + _find: function(parent, type){ + var element = qq(parent).getByClass(this._options.classes[type])[0]; + if (!element){ + throw new Error('element not found ' + type); + } + + return element; + }, + _onSubmit: function(id, name){ + qq.FineUploaderBasic.prototype._onSubmit.apply(this, arguments); + this._addToList(id, name); + }, + // Update the progress bar & percentage as the file is uploaded + _onProgress: function(id, name, loaded, total){ + qq.FineUploaderBasic.prototype._onProgress.apply(this, arguments); + + var item, progressBar, percent, cancelLink; + + item = this.getItemByFileId(id); + progressBar = this._find(item, 'progressBar'); + percent = Math.round(loaded / total * 100); + + if (loaded === total) { + cancelLink = this._find(item, 'cancel'); + qq(cancelLink).hide(); + + qq(progressBar).hide(); + qq(this._find(item, 'statusText')).setText(this._options.text.waitingForResponse); + + // If last byte was sent, display total file size + this._displayFileSize(id); + } + else { + // If still uploading, display percentage - total size is actually the total request(s) size + this._displayFileSize(id, loaded, total); + + qq(progressBar).css({display: 'block'}); + } + + // Update progress bar element + qq(progressBar).css({width: percent + '%'}); + }, + _onComplete: function(id, name, result, xhr){ + qq.FineUploaderBasic.prototype._onComplete.apply(this, arguments); + + var item = this.getItemByFileId(id); + + qq(this._find(item, 'statusText')).clearText(); + + qq(item).removeClass(this._classes.retrying); + qq(this._find(item, 'progressBar')).hide(); + + if (!this._options.disableCancelForFormUploads || qq.isXhrUploadSupported()) { + qq(this._find(item, 'cancel')).hide(); + } + qq(this._find(item, 'spinner')).hide(); + + if (result.success) { + if (this._isDeletePossible()) { + this._showDeleteLink(id); + } + + qq(item).addClass(this._classes.success); + if (this._classes.successIcon) { + this._find(item, 'finished').style.display = "inline-block"; + qq(item).addClass(this._classes.successIcon); + } + } else { + qq(item).addClass(this._classes.fail); + if (this._classes.failIcon) { + this._find(item, 'finished').style.display = "inline-block"; + qq(item).addClass(this._classes.failIcon); + } + if (this._options.retry.showButton && !this._preventRetries[id]) { + qq(item).addClass(this._classes.retryable); + } + this._controlFailureTextDisplay(item, result); + } + }, + _onUpload: function(id, name){ + qq.FineUploaderBasic.prototype._onUpload.apply(this, arguments); + + this._showSpinner(id); + }, + _onCancel: function(id, name) { + qq.FineUploaderBasic.prototype._onCancel.apply(this, arguments); + this._removeFileItem(id); + }, + _onBeforeAutoRetry: function(id) { + var item, progressBar, failTextEl, retryNumForDisplay, maxAuto, retryNote; + + qq.FineUploaderBasic.prototype._onBeforeAutoRetry.apply(this, arguments); + + item = this.getItemByFileId(id); + progressBar = this._find(item, 'progressBar'); + + this._showCancelLink(item); + progressBar.style.width = 0; + qq(progressBar).hide(); + + if (this._options.retry.showAutoRetryNote) { + failTextEl = this._find(item, 'statusText'); + retryNumForDisplay = this._autoRetries[id] + 1; + maxAuto = this._options.retry.maxAutoAttempts; + + retryNote = this._options.retry.autoRetryNote.replace(/\{retryNum\}/g, retryNumForDisplay); + retryNote = retryNote.replace(/\{maxAuto\}/g, maxAuto); + + qq(failTextEl).setText(retryNote); + if (retryNumForDisplay === 1) { + qq(item).addClass(this._classes.retrying); + } + } + }, + //return false if we should not attempt the requested retry + _onBeforeManualRetry: function(id) { + if (qq.FineUploaderBasic.prototype._onBeforeManualRetry.apply(this, arguments)) { + var item = this.getItemByFileId(id); + this._find(item, 'progressBar').style.width = 0; + qq(item).removeClass(this._classes.fail); + qq(this._find(item, 'statusText')).clearText(); + this._showSpinner(id); + this._showCancelLink(item); + return true; + } + return false; + }, + _onSubmitDelete: function(id) { + if (this._isDeletePossible()) { + if (this._options.callbacks.onSubmitDelete(id) !== false) { + if (this._options.deleteFile.forceConfirm) { + this._showDeleteConfirm(id); + } + else { + this._sendDeleteRequest(id); + } + } + } + else { + this.log("Delete request ignored for file ID " + id + ", delete feature is disabled.", "warn"); + return false; + } + }, + _onDeleteComplete: function(id, xhr, isError) { + qq.FineUploaderBasic.prototype._onDeleteComplete.apply(this, arguments); + + var item = this.getItemByFileId(id), + spinnerEl = this._find(item, 'spinner'), + statusTextEl = this._find(item, 'statusText'); + + qq(spinnerEl).hide(); + + if (isError) { + qq(statusTextEl).setText(this._options.deleteFile.deletingFailedText); + this._showDeleteLink(id); + } + else { + this._removeFileItem(id); + } + }, + _sendDeleteRequest: function(id) { + var item = this.getItemByFileId(id), + deleteLink = this._find(item, 'deleteButton'), + statusTextEl = this._find(item, 'statusText'); + + qq(deleteLink).hide(); + this._showSpinner(id); + qq(statusTextEl).setText(this._options.deleteFile.deletingStatusText); + this._deleteHandler.sendDelete(id, this.getUuid(id)); + }, + _showDeleteConfirm: function(id) { + var fileName = this._handler.getName(id), + confirmMessage = this._options.deleteFile.confirmMessage.replace(/\{filename\}/g, fileName), + uuid = this.getUuid(id), + self = this; + + this._options.showConfirm(confirmMessage, function() { + self._sendDeleteRequest(id); + }); + }, + _addToList: function(id, name){ + var item = qq.toElement(this._options.fileTemplate); + if (this._options.disableCancelForFormUploads && !qq.isXhrUploadSupported()) { + var cancelLink = this._find(item, 'cancel'); + qq(cancelLink).remove(); + } + + item.qqFileId = id; + + var fileElement = this._find(item, 'file'); + qq(fileElement).setText(this._options.formatFileName(name)); + qq(this._find(item, 'size')).hide(); + if (!this._options.multiple) { + this._handler.cancelAll(); + this._clearList(); + } + + this._listElement.appendChild(item); + + if (this._options.display.fileSizeOnSubmit && qq.isXhrUploadSupported()) { + this._displayFileSize(id); + } + }, + _clearList: function(){ + this._listElement.innerHTML = ''; + this.clearStoredFiles(); + }, + _displayFileSize: function(id, loadedSize, totalSize) { + var item = this.getItemByFileId(id), + size = this.getSize(id), + sizeForDisplay = this._formatSize(size), + sizeEl = this._find(item, 'size'); + + if (loadedSize !== undefined && totalSize !== undefined) { + sizeForDisplay = this._formatProgress(loadedSize, totalSize); + } + + qq(sizeEl).css({display: 'inline'}); + qq(sizeEl).setText(sizeForDisplay); + }, + /** + * delegate click event for cancel & retry links + **/ + _bindCancelAndRetryEvents: function(){ + var self = this, + list = this._listElement; + + this._disposeSupport.attach(list, 'click', function(e){ + e = e || window.event; + var target = e.target || e.srcElement; + + if (qq(target).hasClass(self._classes.cancel) || qq(target).hasClass(self._classes.retry) || qq(target).hasClass(self._classes.deleteButton)){ + qq.preventDefault(e); + + var item = target.parentNode; + while(item.qqFileId === undefined) { + item = target = target.parentNode; + } + + if (qq(target).hasClass(self._classes.deleteButton)) { + self.deleteFile(item.qqFileId); + } + else if (qq(target).hasClass(self._classes.cancel)) { + self.cancel(item.qqFileId); + } + else { + qq(item).removeClass(self._classes.retryable); + self.retry(item.qqFileId); + } + } + }); + }, + _formatProgress: function (uploadedSize, totalSize) { + var message = this._options.text.formatProgress; + function r(name, replacement) { message = message.replace(name, replacement); } + + r('{percent}', Math.round(uploadedSize / totalSize * 100)); + r('{total_size}', this._formatSize(totalSize)); + return message; + }, + _controlFailureTextDisplay: function(item, response) { + var mode, maxChars, responseProperty, failureReason, shortFailureReason; + + mode = this._options.failedUploadTextDisplay.mode; + maxChars = this._options.failedUploadTextDisplay.maxChars; + responseProperty = this._options.failedUploadTextDisplay.responseProperty; + + if (mode === 'custom') { + failureReason = response[responseProperty]; + if (failureReason) { + if (failureReason.length > maxChars) { + shortFailureReason = failureReason.substring(0, maxChars) + '...'; + } + } + else { + failureReason = this._options.text.failUpload; + this.log("'" + responseProperty + "' is not a valid property on the server response.", 'warn'); + } + + qq(this._find(item, 'statusText')).setText(shortFailureReason || failureReason); + + if (this._options.failedUploadTextDisplay.enableTooltip) { + this._showTooltip(item, failureReason); + } + } + else if (mode === 'default') { + qq(this._find(item, 'statusText')).setText(this._options.text.failUpload); + } + else if (mode !== 'none') { + this.log("failedUploadTextDisplay.mode value of '" + mode + "' is not valid", 'warn'); + } + }, + _showTooltip: function(item, text) { + item.title = text; + }, + _showSpinner: function(id) { + var item = this.getItemByFileId(id), + spinnerEl = this._find(item, 'spinner'); + + spinnerEl.style.display = "inline-block"; + }, + _showCancelLink: function(item) { + if (!this._options.disableCancelForFormUploads || qq.isXhrUploadSupported()) { + var cancelLink = this._find(item, 'cancel'); + + qq(cancelLink).css({display: 'inline'}); + } + }, + _showDeleteLink: function(id) { + var item = this.getItemByFileId(id), + deleteLink = this._find(item, 'deleteButton'); + + qq(deleteLink).css({display: 'inline'}); + }, + _error: function(code, name){ + var message = qq.FineUploaderBasic.prototype._error.apply(this, arguments); + this._options.showMessage(message); + } +}); +/** Generic class for sending non-upload ajax requests and handling the associated responses **/ +//TODO Use XDomainRequest if expectCors = true. Not necessary now since only DELETE requests are sent and XDR doesn't support pre-flighting. +/*globals qq, XMLHttpRequest*/ +qq.AjaxRequestor = function(o) { + "use strict"; + + var log, shouldParamsBeInQueryString, + queue = [], + requestState = [], + options = { + method: 'POST', + maxConnections: 3, + customHeaders: {}, + endpointStore: {}, + paramsStore: {}, + successfulResponseCodes: [200], + demoMode: false, + cors: { + expected: false, + sendCredentials: false + }, + log: function(str, level) {}, + onSend: function(id) {}, + onComplete: function(id, xhr, isError) {}, + onCancel: function(id) {} + }; + + qq.extend(options, o); + log = options.log; + shouldParamsBeInQueryString = getMethod() === 'GET' || getMethod() === 'DELETE'; + + + /** + * Removes element from queue, sends next request + */ + function dequeue(id) { + var i = qq.indexOf(queue, id), + max = options.maxConnections, + nextId; + + delete requestState[id]; + queue.splice(i, 1); + + if (queue.length >= max && i < max){ + nextId = queue[max-1]; + sendRequest(nextId); + } + } + + function onComplete(id) { + var xhr = requestState[id].xhr, + method = getMethod(), + isError = false; + + dequeue(id); + + if (!isResponseSuccessful(xhr.status)) { + isError = true; + log(method + " request for " + id + " has failed - response code " + xhr.status, "error"); + } + + options.onComplete(id, xhr, isError); + } + + function sendRequest(id) { + var xhr = new XMLHttpRequest(), + method = getMethod(), + params = {}, + url; + + options.onSend(id); + + if (options.paramsStore.getParams) { + params = options.paramsStore.getParams(id); + } + + url = createUrl(id, params); + + requestState[id].xhr = xhr; + xhr.onreadystatechange = getReadyStateChangeHandler(id); + xhr.open(method, url, true); + + if (options.cors.expected && options.cors.sendCredentials) { + xhr.withCredentials = true; + } + + setHeaders(id); + + log('Sending ' + method + " request for " + id); + if (!shouldParamsBeInQueryString && params) { + xhr.send(qq.obj2url(params, "")); + } + else { + xhr.send(); + } + } + + function createUrl(id, params) { + var endpoint = options.endpointStore.getEndpoint(id), + addToPath = requestState[id].addToPath; + + if (addToPath !== undefined) { + endpoint += "/" + addToPath; + } + + if (shouldParamsBeInQueryString && params) { + return qq.obj2url(params, endpoint); + } + else { + return endpoint; + } + } + + function getReadyStateChangeHandler(id) { + var xhr = requestState[id].xhr; + + return function() { + if (xhr.readyState === 4) { + onComplete(id, xhr); + } + }; + } + + function setHeaders(id) { + var xhr = requestState[id].xhr, + customHeaders = options.customHeaders; + + xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest"); + xhr.setRequestHeader("Cache-Control", "no-cache"); + + qq.each(customHeaders, function(name, val) { + xhr.setRequestHeader(name, val); + }); + } + + function cancelRequest(id) { + var xhr = requestState[id].xhr, + method = getMethod(); + + if (xhr) { + xhr.onreadystatechange = null; + xhr.abort(); + dequeue(id); + + log('Cancelled ' + method + " for " + id); + options.onCancel(id); + + return true; + } + + return false; + } + + function isResponseSuccessful(responseCode) { + return qq.indexOf(options.successfulResponseCodes, responseCode) >= 0; + } + + function getMethod() { + if (options.demoMode) { + return "GET"; + } + + return options.method; + } + + + return { + send: function(id, addToPath) { + requestState[id] = { + addToPath: addToPath + }; + + var len = queue.push(id); + + // if too many active connections, wait... + if (len <= options.maxConnections){ + sendRequest(id); + } + }, + cancel: function(id) { + return cancelRequest(id); + } + }; +}; +/** Generic class for sending non-upload ajax requests and handling the associated responses **/ +/*globals qq, XMLHttpRequest*/ +qq.DeleteFileAjaxRequestor = function(o) { + "use strict"; + + var requestor, + options = { + endpointStore: {}, + maxConnections: 3, + customHeaders: {}, + paramsStore: {}, + demoMode: false, + cors: { + expected: false, + sendCredentials: false + }, + log: function(str, level) {}, + onDelete: function(id) {}, + onDeleteComplete: function(id, xhr, isError) {} + }; + + qq.extend(options, o); + + requestor = new qq.AjaxRequestor({ + method: 'DELETE', + endpointStore: options.endpointStore, + paramsStore: options.paramsStore, + maxConnections: options.maxConnections, + customHeaders: options.customHeaders, + successfulResponseCodes: [200, 202, 204], + demoMode: options.demoMode, + log: options.log, + onSend: options.onDelete, + onComplete: options.onDeleteComplete + }); + + + return { + sendDelete: function(id, uuid) { + requestor.send(id, uuid); + options.log("Submitted delete file request for " + id); + } + }; +}; +qq.WindowReceiveMessage = function(o) { + var options = { + log: function(message, level) {} + }, + callbackWrapperDetachers = {}; + + qq.extend(options, o); + + return { + receiveMessage : function(id, callback) { + var onMessageCallbackWrapper = function(event) { + callback(event.data); + }; + + if (window.postMessage) { + callbackWrapperDetachers[id] = qq(window).attach("message", onMessageCallbackWrapper); + } + else { + log("iframe message passing not supported in this browser!", "error"); + } + }, + + stopReceivingMessages : function(id) { + if (window.postMessage) { + var detacher = callbackWrapperDetachers[id]; + if (detacher) { + detacher(); + } + } + } + }; +}; +/** + * Class for uploading files, uploading itself is handled by child classes + */ +/*globals qq*/ +qq.UploadHandler = function(o) { + "use strict"; + + var queue = [], + options, log, dequeue, handlerImpl; + + // Default options, can be overridden by the user + options = { + debug: false, + forceMultipart: true, + paramsInBody: false, + paramsStore: {}, + endpointStore: {}, + cors: { + expected: false, + sendCredentials: false + }, + maxConnections: 3, // maximum number of concurrent uploads + uuidParamName: 'qquuid', + totalFileSizeParamName: 'qqtotalfilesize', + chunking: { + enabled: false, + partSize: 2000000, //bytes + paramNames: { + partIndex: 'qqpartindex', + partByteOffset: 'qqpartbyteoffset', + chunkSize: 'qqchunksize', + totalParts: 'qqtotalparts', + filename: 'qqfilename' + } + }, + resume: { + enabled: false, + id: null, + cookiesExpireIn: 7, //days + paramNames: { + resuming: "qqresume" + } + }, + blobs: { + paramNames: { + name: 'qqblobname' + } + }, + log: function(str, level) {}, + onProgress: function(id, fileName, loaded, total){}, + onComplete: function(id, fileName, response, xhr){}, + onCancel: function(id, fileName){}, + onUpload: function(id, fileName){}, + onUploadChunk: function(id, fileName, chunkData){}, + onAutoRetry: function(id, fileName, response, xhr){}, + onResume: function(id, fileName, chunkData){} + + }; + qq.extend(options, o); + + log = options.log; + + /** + * Removes element from queue, starts upload of next + */ + dequeue = function(id) { + var i = qq.indexOf(queue, id), + max = options.maxConnections, + nextId; + + if (i >= 0) { + queue.splice(i, 1); + + if (queue.length >= max && i < max){ + nextId = queue[max-1]; + handlerImpl.upload(nextId); + } + } + }; + + if (qq.isXhrUploadSupported()) { + handlerImpl = new qq.UploadHandlerXhr(options, dequeue, log); + } + else { + handlerImpl = new qq.UploadHandlerForm(options, dequeue, log); + } + + + return { + /** + * Adds file or file input to the queue + * @returns id + **/ + add: function(file){ + return handlerImpl.add(file); + }, + /** + * Sends the file identified by id + */ + upload: function(id){ + var len = queue.push(id); + + // if too many active uploads, wait... + if (len <= options.maxConnections){ + return handlerImpl.upload(id); + } + }, + retry: function(id) { + var i = qq.indexOf(queue, id); + if (i >= 0) { + return handlerImpl.upload(id, true); + } + else { + return this.upload(id); + } + }, + /** + * Cancels file upload by id + */ + cancel: function(id) { + log('Cancelling ' + id); + options.paramsStore.remove(id); + handlerImpl.cancel(id); + dequeue(id); + }, + /** + * Cancels all queued or in-progress uploads + */ + cancelAll: function() { + var self = this, + queueCopy = []; + + qq.extend(queueCopy, queue); + qq.each(queueCopy, function(idx, fileId) { + self.cancel(fileId); + }); + + queue = []; + }, + /** + * Returns name of the file identified by id + */ + getName: function(id){ + return handlerImpl.getName(id); + }, + /** + * Returns size of the file identified by id + */ + getSize: function(id){ + if (handlerImpl.getSize) { + return handlerImpl.getSize(id); + } + }, + getFile: function(id) { + if (handlerImpl.getFile) { + return handlerImpl.getFile(id); + } + }, + /** + * Returns id of files being uploaded or + * waiting for their turn + */ + getQueue: function(){ + return queue; + }, + reset: function() { + log('Resetting upload handler'); + queue = []; + handlerImpl.reset(); + }, + getUuid: function(id) { + return handlerImpl.getUuid(id); + }, + /** + * Determine if the file exists. + */ + isValid: function(id) { + return handlerImpl.isValid(id); + }, + getResumableFilesData: function() { + if (handlerImpl.getResumableFilesData) { + return handlerImpl.getResumableFilesData(); + } + return []; + } + }; +}; +/*globals qq, document, setTimeout*/ +/*globals clearTimeout*/ +qq.UploadHandlerForm = function(o, uploadCompleteCallback, logCallback) { + "use strict"; + + var options = o, + inputs = [], + uuids = [], + detachLoadEvents = {}, + postMessageCallbackTimers = {}, + uploadComplete = uploadCompleteCallback, + log = logCallback, + corsMessageReceiver = new qq.WindowReceiveMessage({log: log}), + onloadCallbacks = {}, + api; + + + function detachLoadEvent(id) { + if (detachLoadEvents[id] !== undefined) { + detachLoadEvents[id](); + delete detachLoadEvents[id]; + } + } + + function registerPostMessageCallback(iframe, callback) { + var id = iframe.id; + + onloadCallbacks[uuids[id]] = callback; + + detachLoadEvents[id] = qq(iframe).attach('load', function() { + if (inputs[id]) { + log("Received iframe load event for CORS upload request (file id " + id + ")"); + + postMessageCallbackTimers[id] = setTimeout(function() { + var errorMessage = "No valid message received from loaded iframe for file id " + id; + log(errorMessage, "error"); + callback({ + error: errorMessage + }); + }, 1000); + } + }); + + corsMessageReceiver.receiveMessage(id, function(message) { + log("Received the following window message: '" + message + "'"); + var response = qq.parseJson(message), + uuid = response.uuid, + onloadCallback; + + if (uuid && onloadCallbacks[uuid]) { + clearTimeout(postMessageCallbackTimers[id]); + delete postMessageCallbackTimers[id]; + + detachLoadEvent(id); + + onloadCallback = onloadCallbacks[uuid]; + + delete onloadCallbacks[uuid]; + corsMessageReceiver.stopReceivingMessages(id); + onloadCallback(response); + } + else if (!uuid) { + log("'" + message + "' does not contain a UUID - ignoring."); + } + }); + } + + function attachLoadEvent(iframe, callback) { + /*jslint eqeq: true*/ + + if (options.cors.expected) { + registerPostMessageCallback(iframe, callback); + } + else { + detachLoadEvents[iframe.id] = qq(iframe).attach('load', function(){ + log('Received response for ' + iframe.id); + + // when we remove iframe from dom + // the request stops, but in IE load + // event fires + if (!iframe.parentNode){ + return; + } + + try { + // fixing Opera 10.53 + if (iframe.contentDocument && + iframe.contentDocument.body && + iframe.contentDocument.body.innerHTML == "false"){ + // In Opera event is fired second time + // when body.innerHTML changed from false + // to server response approx. after 1 sec + // when we upload file with iframe + return; + } + } + catch (error) { + //IE may throw an "access is denied" error when attempting to access contentDocument on the iframe in some cases + log('Error when attempting to access iframe during handling of upload response (' + error + ")", 'error'); + } + + callback(); + }); + } + } + + /** + * Returns json object received by iframe from server. + */ + function getIframeContentJson(iframe) { + /*jshint evil: true*/ + + var response; + + //IE may throw an "access is denied" error when attempting to access contentDocument on the iframe in some cases + try { + // iframe.contentWindow.document - for IE<7 + var doc = iframe.contentDocument || iframe.contentWindow.document, + innerHTML = doc.body.innerHTML; + + log("converting iframe's innerHTML to JSON"); + log("innerHTML = " + innerHTML); + //plain text response may be wrapped in
     tag
    +            if (innerHTML && innerHTML.match(/^
    ');
    +
    +        iframe.setAttribute('id', id);
    +
    +        iframe.style.display = 'none';
    +        document.body.appendChild(iframe);
    +
    +        return iframe;
    +    }
    +
    +    /**
    +     * Creates form, that will be submitted to iframe
    +     */
    +    function createForm(id, iframe){
    +        var params = options.paramsStore.getParams(id),
    +            protocol = options.demoMode ? "GET" : "POST",
    +            form = qq.toElement('
    '), + endpoint = options.endpointStore.getEndpoint(id), + url = endpoint; + + params[options.uuidParamName] = uuids[id]; + + if (!options.paramsInBody) { + url = qq.obj2url(params, endpoint); + } + else { + qq.obj2Inputs(params, form); + } + + form.setAttribute('action', url); + form.setAttribute('target', iframe.name); + form.style.display = 'none'; + document.body.appendChild(form); + + return form; + } + + + api = { + add: function(fileInput) { + fileInput.setAttribute('name', options.inputName); + + var id = inputs.push(fileInput) - 1; + uuids[id] = qq.getUniqueId(); + + // remove file input from DOM + if (fileInput.parentNode){ + qq(fileInput).remove(); + } + + return id; + }, + getName: function(id) { + /*jslint regexp: true*/ + + // get input value and remove path to normalize + return inputs[id].value.replace(/.*(\/|\\)/, ""); + }, + isValid: function(id) { + return inputs[id] !== undefined; + }, + reset: function() { + qq.UploadHandler.prototype.reset.apply(this, arguments); + inputs = []; + uuids = []; + detachLoadEvents = {}; + }, + getUuid: function(id) { + return uuids[id]; + }, + cancel: function(id) { + options.onCancel(id, this.getName(id)); + + delete inputs[id]; + delete uuids[id]; + delete detachLoadEvents[id]; + + if (options.cors.expected) { + clearTimeout(postMessageCallbackTimers[id]); + delete postMessageCallbackTimers[id]; + corsMessageReceiver.stopReceivingMessages(id); + } + + var iframe = document.getElementById(id); + if (iframe) { + // to cancel request set src to something else + // we use src="javascript:false;" because it doesn't + // trigger ie6 prompt on https + iframe.setAttribute('src', 'java' + String.fromCharCode(115) + 'cript:false;'); //deal with "JSLint: javascript URL" warning, which apparently cannot be turned off + + qq(iframe).remove(); + } + }, + upload: function(id){ + var input = inputs[id], + fileName = api.getName(id), + iframe = createIframe(id), + form; + + if (!input){ + throw new Error('file with passed id was not added, or already uploaded or cancelled'); + } + + options.onUpload(id, this.getName(id)); + + form = createForm(id, iframe); + form.appendChild(input); + + attachLoadEvent(iframe, function(responseFromMessage){ + log('iframe loaded'); + + var response = responseFromMessage ? responseFromMessage : getIframeContentJson(iframe); + + detachLoadEvent(id); + + //we can't remove an iframe if the iframe doesn't belong to the same domain + if (!options.cors.expected) { + qq(iframe).remove(); + } + + if (!response.success) { + if (options.onAutoRetry(id, fileName, response)) { + return; + } + } + options.onComplete(id, fileName, response); + uploadComplete(id); + }); + + log('Sending upload request for ' + id); + form.submit(); + qq(form).remove(); + + return id; + } + }; + + return api; +}; +/*globals qq, File, XMLHttpRequest, FormData, Blob*/ +qq.UploadHandlerXhr = function(o, uploadCompleteCallback, logCallback) { + "use strict"; + + var options = o, + uploadComplete = uploadCompleteCallback, + log = logCallback, + fileState = [], + cookieItemDelimiter = "|", + chunkFiles = options.chunking.enabled && qq.isFileChunkingSupported(), + resumeEnabled = options.resume.enabled && chunkFiles && qq.areCookiesEnabled(), + resumeId = getResumeId(), + multipart = options.forceMultipart || options.paramsInBody, + api; + + + function addChunkingSpecificParams(id, params, chunkData) { + var size = api.getSize(id), + name = api.getName(id); + + params[options.chunking.paramNames.partIndex] = chunkData.part; + params[options.chunking.paramNames.partByteOffset] = chunkData.start; + params[options.chunking.paramNames.chunkSize] = chunkData.size; + params[options.chunking.paramNames.totalParts] = chunkData.count; + params[options.totalFileSizeParamName] = size; + + /** + * When a Blob is sent in a multipart request, the filename value in the content-disposition header is either "blob" + * or an empty string. So, we will need to include the actual file name as a param in this case. + */ + if (multipart) { + params[options.chunking.paramNames.filename] = name; + } + } + + function addResumeSpecificParams(params) { + params[options.resume.paramNames.resuming] = true; + } + + function getChunk(fileOrBlob, startByte, endByte) { + if (fileOrBlob.slice) { + return fileOrBlob.slice(startByte, endByte); + } + else if (fileOrBlob.mozSlice) { + return fileOrBlob.mozSlice(startByte, endByte); + } + else if (fileOrBlob.webkitSlice) { + return fileOrBlob.webkitSlice(startByte, endByte); + } + } + + function getChunkData(id, chunkIndex) { + var chunkSize = options.chunking.partSize, + fileSize = api.getSize(id), + fileOrBlob = fileState[id].file || fileState[id].blobData.blob, + startBytes = chunkSize * chunkIndex, + endBytes = startBytes+chunkSize >= fileSize ? fileSize : startBytes+chunkSize, + totalChunks = getTotalChunks(id); + + return { + part: chunkIndex, + start: startBytes, + end: endBytes, + count: totalChunks, + blob: getChunk(fileOrBlob, startBytes, endBytes), + size: endBytes - startBytes + }; + } + + function getTotalChunks(id) { + var fileSize = api.getSize(id), + chunkSize = options.chunking.partSize; + + return Math.ceil(fileSize / chunkSize); + } + + function createXhr(id) { + var xhr = new XMLHttpRequest(); + + fileState[id].xhr = xhr; + + return xhr; + } + + function setParamsAndGetEntityToSend(params, xhr, fileOrBlob, id) { + var formData = new FormData(), + method = options.demoMode ? "GET" : "POST", + endpoint = options.endpointStore.getEndpoint(id), + url = endpoint, + name = api.getName(id), + size = api.getSize(id), + blobData = fileState[id].blobData; + + params[options.uuidParamName] = fileState[id].uuid; + + if (multipart) { + params[options.totalFileSizeParamName] = size; + + if (blobData) { + /** + * When a Blob is sent in a multipart request, the filename value in the content-disposition header is either "blob" + * or an empty string. So, we will need to include the actual file name as a param in this case. + */ + params[options.blobs.paramNames.name] = blobData.name; + } + } + + //build query string + if (!options.paramsInBody) { + if (!multipart) { + params[options.inputName] = name; + } + url = qq.obj2url(params, endpoint); + } + + xhr.open(method, url, true); + + if (options.cors.expected && options.cors.sendCredentials) { + xhr.withCredentials = true; + } + + if (multipart) { + if (options.paramsInBody) { + qq.obj2FormData(params, formData); + } + + formData.append(options.inputName, fileOrBlob); + return formData; + } + + return fileOrBlob; + } + + function setHeaders(id, xhr) { + var extraHeaders = options.customHeaders, + fileOrBlob = fileState[id].file || fileState[id].blobData.blob; + + xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest"); + xhr.setRequestHeader("Cache-Control", "no-cache"); + + if (!multipart) { + xhr.setRequestHeader("Content-Type", "application/octet-stream"); + //NOTE: return mime type in xhr works on chrome 16.0.9 firefox 11.0a2 + xhr.setRequestHeader("X-Mime-Type", fileOrBlob.type); + } + + qq.each(extraHeaders, function(name, val) { + xhr.setRequestHeader(name, val); + }); + } + + function handleCompletedItem(id, response, xhr) { + var name = api.getName(id), + size = api.getSize(id); + + fileState[id].attemptingResume = false; + + options.onProgress(id, name, size, size); + + options.onComplete(id, name, response, xhr); + delete fileState[id].xhr; + uploadComplete(id); + } + + function uploadNextChunk(id) { + var chunkIdx = fileState[id].remainingChunkIdxs[0], + chunkData = getChunkData(id, chunkIdx), + xhr = createXhr(id), + size = api.getSize(id), + name = api.getName(id), + toSend, params; + + if (fileState[id].loaded === undefined) { + fileState[id].loaded = 0; + } + + if (resumeEnabled && fileState[id].file) { + persistChunkData(id, chunkData); + } + + xhr.onreadystatechange = getReadyStateChangeHandler(id, xhr); + + xhr.upload.onprogress = function(e) { + if (e.lengthComputable) { + var totalLoaded = e.loaded + fileState[id].loaded, + estTotalRequestsSize = calcAllRequestsSizeForChunkedUpload(id, chunkIdx, e.total); + + options.onProgress(id, name, totalLoaded, estTotalRequestsSize); + } + }; + + options.onUploadChunk(id, name, getChunkDataForCallback(chunkData)); + + params = options.paramsStore.getParams(id); + addChunkingSpecificParams(id, params, chunkData); + + if (fileState[id].attemptingResume) { + addResumeSpecificParams(params); + } + + toSend = setParamsAndGetEntityToSend(params, xhr, chunkData.blob, id); + setHeaders(id, xhr); + + log('Sending chunked upload request for item ' + id + ": bytes " + (chunkData.start+1) + "-" + chunkData.end + " of " + size); + xhr.send(toSend); + } + + function calcAllRequestsSizeForChunkedUpload(id, chunkIdx, requestSize) { + var chunkData = getChunkData(id, chunkIdx), + blobSize = chunkData.size, + overhead = requestSize - blobSize, + size = api.getSize(id), + chunkCount = chunkData.count, + initialRequestOverhead = fileState[id].initialRequestOverhead, + overheadDiff = overhead - initialRequestOverhead; + + fileState[id].lastRequestOverhead = overhead; + + if (chunkIdx === 0) { + fileState[id].lastChunkIdxProgress = 0; + fileState[id].initialRequestOverhead = overhead; + fileState[id].estTotalRequestsSize = size + (chunkCount * overhead); + } + else if (fileState[id].lastChunkIdxProgress !== chunkIdx) { + fileState[id].lastChunkIdxProgress = chunkIdx; + fileState[id].estTotalRequestsSize += overheadDiff; + } + + return fileState[id].estTotalRequestsSize; + } + + function getLastRequestOverhead(id) { + if (multipart) { + return fileState[id].lastRequestOverhead; + } + else { + return 0; + } + } + + function handleSuccessfullyCompletedChunk(id, response, xhr) { + var chunkIdx = fileState[id].remainingChunkIdxs.shift(), + chunkData = getChunkData(id, chunkIdx); + + fileState[id].attemptingResume = false; + fileState[id].loaded += chunkData.size + getLastRequestOverhead(id); + + if (fileState[id].remainingChunkIdxs.length > 0) { + uploadNextChunk(id); + } + else { + if (resumeEnabled) { + deletePersistedChunkData(id); + } + + handleCompletedItem(id, response, xhr); + } + } + + function isErrorResponse(xhr, response) { + return xhr.status !== 200 || !response.success || response.reset; + } + + function parseResponse(xhr) { + var response; + + try { + response = qq.parseJson(xhr.responseText); + } + catch(error) { + log('Error when attempting to parse xhr response text (' + error + ')', 'error'); + response = {}; + } + + return response; + } + + function handleResetResponse(id) { + log('Server has ordered chunking effort to be restarted on next attempt for item ID ' + id, 'error'); + + if (resumeEnabled) { + deletePersistedChunkData(id); + fileState[id].attemptingResume = false; + } + + fileState[id].remainingChunkIdxs = []; + delete fileState[id].loaded; + delete fileState[id].estTotalRequestsSize; + delete fileState[id].initialRequestOverhead; + } + + function handleResetResponseOnResumeAttempt(id) { + fileState[id].attemptingResume = false; + log("Server has declared that it cannot handle resume for item ID " + id + " - starting from the first chunk", 'error'); + handleResetResponse(id); + api.upload(id, true); + } + + function handleNonResetErrorResponse(id, response, xhr) { + var name = api.getName(id); + + if (options.onAutoRetry(id, name, response, xhr)) { + return; + } + else { + handleCompletedItem(id, response, xhr); + } + } + + function onComplete(id, xhr) { + var response; + + // the request was aborted/cancelled + if (!fileState[id]) { + return; + } + + log("xhr - server response received for " + id); + log("responseText = " + xhr.responseText); + response = parseResponse(xhr); + + if (isErrorResponse(xhr, response)) { + if (response.reset) { + handleResetResponse(id); + } + + if (fileState[id].attemptingResume && response.reset) { + handleResetResponseOnResumeAttempt(id); + } + else { + handleNonResetErrorResponse(id, response, xhr); + } + } + else if (chunkFiles) { + handleSuccessfullyCompletedChunk(id, response, xhr); + } + else { + handleCompletedItem(id, response, xhr); + } + } + + function getChunkDataForCallback(chunkData) { + return { + partIndex: chunkData.part, + startByte: chunkData.start + 1, + endByte: chunkData.end, + totalParts: chunkData.count + }; + } + + function getReadyStateChangeHandler(id, xhr) { + return function() { + if (xhr.readyState === 4) { + onComplete(id, xhr); + } + }; + } + + function persistChunkData(id, chunkData) { + var fileUuid = api.getUuid(id), + lastByteSent = fileState[id].loaded, + initialRequestOverhead = fileState[id].initialRequestOverhead, + estTotalRequestsSize = fileState[id].estTotalRequestsSize, + cookieName = getChunkDataCookieName(id), + cookieValue = fileUuid + + cookieItemDelimiter + chunkData.part + + cookieItemDelimiter + lastByteSent + + cookieItemDelimiter + initialRequestOverhead + + cookieItemDelimiter + estTotalRequestsSize, + cookieExpDays = options.resume.cookiesExpireIn; + + qq.setCookie(cookieName, cookieValue, cookieExpDays); + } + + function deletePersistedChunkData(id) { + if (fileState[id].file) { + var cookieName = getChunkDataCookieName(id); + qq.deleteCookie(cookieName); + } + } + + function getPersistedChunkData(id) { + var chunkCookieValue = qq.getCookie(getChunkDataCookieName(id)), + filename = api.getName(id), + sections, uuid, partIndex, lastByteSent, initialRequestOverhead, estTotalRequestsSize; + + if (chunkCookieValue) { + sections = chunkCookieValue.split(cookieItemDelimiter); + + if (sections.length === 5) { + uuid = sections[0]; + partIndex = parseInt(sections[1], 10); + lastByteSent = parseInt(sections[2], 10); + initialRequestOverhead = parseInt(sections[3], 10); + estTotalRequestsSize = parseInt(sections[4], 10); + + return { + uuid: uuid, + part: partIndex, + lastByteSent: lastByteSent, + initialRequestOverhead: initialRequestOverhead, + estTotalRequestsSize: estTotalRequestsSize + }; + } + else { + log('Ignoring previously stored resume/chunk cookie for ' + filename + " - old cookie format", "warn"); + } + } + } + + function getChunkDataCookieName(id) { + var filename = api.getName(id), + fileSize = api.getSize(id), + maxChunkSize = options.chunking.partSize, + cookieName; + + cookieName = "qqfilechunk" + cookieItemDelimiter + encodeURIComponent(filename) + cookieItemDelimiter + fileSize + cookieItemDelimiter + maxChunkSize; + + if (resumeId !== undefined) { + cookieName += cookieItemDelimiter + resumeId; + } + + return cookieName; + } + + function getResumeId() { + if (options.resume.id !== null && + options.resume.id !== undefined && + !qq.isFunction(options.resume.id) && + !qq.isObject(options.resume.id)) { + + return options.resume.id; + } + } + + function handleFileChunkingUpload(id, retry) { + var name = api.getName(id), + firstChunkIndex = 0, + persistedChunkInfoForResume, firstChunkDataForResume, currentChunkIndex; + + if (!fileState[id].remainingChunkIdxs || fileState[id].remainingChunkIdxs.length === 0) { + fileState[id].remainingChunkIdxs = []; + + if (resumeEnabled && !retry && fileState[id].file) { + persistedChunkInfoForResume = getPersistedChunkData(id); + if (persistedChunkInfoForResume) { + firstChunkDataForResume = getChunkData(id, persistedChunkInfoForResume.part); + if (options.onResume(id, name, getChunkDataForCallback(firstChunkDataForResume)) !== false) { + firstChunkIndex = persistedChunkInfoForResume.part; + fileState[id].uuid = persistedChunkInfoForResume.uuid; + fileState[id].loaded = persistedChunkInfoForResume.lastByteSent; + fileState[id].estTotalRequestsSize = persistedChunkInfoForResume.estTotalRequestsSize; + fileState[id].initialRequestOverhead = persistedChunkInfoForResume.initialRequestOverhead; + fileState[id].attemptingResume = true; + log('Resuming ' + name + " at partition index " + firstChunkIndex); + } + } + } + + for (currentChunkIndex = getTotalChunks(id)-1; currentChunkIndex >= firstChunkIndex; currentChunkIndex-=1) { + fileState[id].remainingChunkIdxs.unshift(currentChunkIndex); + } + } + + uploadNextChunk(id); + } + + function handleStandardFileUpload(id) { + var fileOrBlob = fileState[id].file || fileState[id].blobData.blob, + name = api.getName(id), + xhr, params, toSend; + + fileState[id].loaded = 0; + + xhr = createXhr(id); + + xhr.upload.onprogress = function(e){ + if (e.lengthComputable){ + fileState[id].loaded = e.loaded; + options.onProgress(id, name, e.loaded, e.total); + } + }; + + xhr.onreadystatechange = getReadyStateChangeHandler(id, xhr); + + params = options.paramsStore.getParams(id); + toSend = setParamsAndGetEntityToSend(params, xhr, fileOrBlob, id); + setHeaders(id, xhr); + + log('Sending upload request for ' + id); + xhr.send(toSend); + } + + + api = { + /** + * Adds File or Blob to the queue + * Returns id to use with upload, cancel + **/ + add: function(fileOrBlobData){ + var id; + + if (fileOrBlobData instanceof File) { + id = fileState.push({file: fileOrBlobData}) - 1; + } + else if (fileOrBlobData.blob instanceof Blob) { + id = fileState.push({blobData: fileOrBlobData}) - 1; + } + else { + throw new Error('Passed obj in not a File or BlobData (in qq.UploadHandlerXhr)'); + } + + fileState[id].uuid = qq.getUniqueId(); + return id; + }, + getName: function(id){ + var file = fileState[id].file, + blobData = fileState[id].blobData; + + if (file) { + // fix missing name in Safari 4 + //NOTE: fixed missing name firefox 11.0a2 file.fileName is actually undefined + return (file.fileName !== null && file.fileName !== undefined) ? file.fileName : file.name; + } + else { + return blobData.name; + } + }, + getSize: function(id){ + /*jshint eqnull: true*/ + var fileOrBlob = fileState[id].file || fileState[id].blobData.blob; + + if (qq.isFileOrInput(fileOrBlob)) { + return fileOrBlob.fileSize != null ? fileOrBlob.fileSize : fileOrBlob.size; + } + else { + return fileOrBlob.size; + } + }, + getFile: function(id) { + if (fileState[id]) { + return fileState[id].file || fileState[id].blobData.blob; + } + }, + /** + * Returns uploaded bytes for file identified by id + */ + getLoaded: function(id){ + return fileState[id].loaded || 0; + }, + isValid: function(id) { + return fileState[id] !== undefined; + }, + reset: function() { + fileState = []; + }, + getUuid: function(id) { + return fileState[id].uuid; + }, + /** + * Sends the file identified by id to the server + */ + upload: function(id, retry){ + var name = this.getName(id); + + options.onUpload(id, name); + + if (chunkFiles) { + handleFileChunkingUpload(id, retry); + } + else { + handleStandardFileUpload(id); + } + }, + cancel: function(id){ + var xhr = fileState[id].xhr; + + options.onCancel(id, this.getName(id)); + + if (xhr) { + xhr.onreadystatechange = null; + xhr.abort(); + } + + if (resumeEnabled) { + deletePersistedChunkData(id); + } + + delete fileState[id]; + }, + getResumableFilesData: function() { + var matchingCookieNames = [], + resumableFilesData = []; + + if (chunkFiles && resumeEnabled) { + if (resumeId === undefined) { + matchingCookieNames = qq.getCookieNames(new RegExp("^qqfilechunk\\" + cookieItemDelimiter + ".+\\" + + cookieItemDelimiter + "\\d+\\" + cookieItemDelimiter + options.chunking.partSize + "=")); + } + else { + matchingCookieNames = qq.getCookieNames(new RegExp("^qqfilechunk\\" + cookieItemDelimiter + ".+\\" + + cookieItemDelimiter + "\\d+\\" + cookieItemDelimiter + options.chunking.partSize + "\\" + + cookieItemDelimiter + resumeId + "=")); + } + + qq.each(matchingCookieNames, function(idx, cookieName) { + var cookiesNameParts = cookieName.split(cookieItemDelimiter); + var cookieValueParts = qq.getCookie(cookieName).split(cookieItemDelimiter); + + resumableFilesData.push({ + name: decodeURIComponent(cookiesNameParts[1]), + size: cookiesNameParts[2], + uuid: cookieValueParts[0], + partIdx: cookieValueParts[1] + }); + }); + + return resumableFilesData; + } + return []; + } + }; + + return api; +}; From c020e4e6bd7512a555b2f9a13e59c51cad65e89e Mon Sep 17 00:00:00 2001 From: Henry Oswald Date: Sat, 2 Dec 2017 12:29:06 +0000 Subject: [PATCH 60/98] moment and fineuploader are loaded seperately in editor, not packaged up --- services/web/app/views/layout.pug | 3 +-- services/web/public/coffee/libs.coffee | 2 -- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/services/web/app/views/layout.pug b/services/web/app/views/layout.pug index 31afc17d29..822923ac08 100644 --- a/services/web/app/views/layout.pug +++ b/services/web/app/views/layout.pug @@ -132,8 +132,7 @@ html(itemscope, itemtype='http://schema.org/Product') // minimal requirejs configuration (can be extended/overridden) window.requirejs = { "paths" : { - "moment": "libs/#{lib('moment')}", - "fineuploader": "libs/#{lib('fineuploader')}" + "moment": "libs/#{lib('moment')}" }, "urlArgs": "fingerprint=#{fingerprint(jsPath + 'main.js')}-#{fingerprint(jsPath + 'libs.js')}", "config":{ diff --git a/services/web/public/coffee/libs.coffee b/services/web/public/coffee/libs.coffee index e99fbc13ce..2b0b034fb7 100644 --- a/services/web/public/coffee/libs.coffee +++ b/services/web/public/coffee/libs.coffee @@ -1,12 +1,10 @@ define [ - "moment" "libs/angular-autocomplete/angular-autocomplete" "libs/ui-bootstrap" "libs/ng-context-menu-0.1.4" "libs/underscore-1.3.3" "libs/algolia-2.5.2" "libs/jquery.storage" - "fineuploader" "libs/angular-sanitize-1.2.17" "libs/angular-cookie" "libs/passfield" From a9ca54b98ae1bf9ea26165db6d03508eaa43fcf5 Mon Sep 17 00:00:00 2001 From: James Allen Date: Sat, 2 Dec 2017 13:02:37 +0000 Subject: [PATCH 61/98] Generate docker-compose.yml before any docker-compose command --- services/web/Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/web/Makefile b/services/web/Makefile index 44017629d1..98695a8c1f 100644 --- a/services/web/Makefile +++ b/services/web/Makefile @@ -30,11 +30,11 @@ clean: rm -rf $$dir/test/unit/js; \ rm -rf $$dir/test/acceptance/js; \ done - # Deletes node_modules volume - docker-compose down --volumes # Regenerate docker-shared.yml - not stictly a 'clean', # but lets `make clean install` work nicely bin/generate_volumes_file + # Deletes node_modules volume + docker-compose down --volumes # Need regenerating if you change the web modules you have installed docker-shared.yml: From aaa908187d7088a151c35fb0968b6c079bcfb554 Mon Sep 17 00:00:00 2001 From: Henry Oswald Date: Sat, 2 Dec 2017 13:38:23 +0000 Subject: [PATCH 62/98] added layout.pug back in --- services/web/app/views/layout.pug | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/services/web/app/views/layout.pug b/services/web/app/views/layout.pug index 822923ac08..31afc17d29 100644 --- a/services/web/app/views/layout.pug +++ b/services/web/app/views/layout.pug @@ -132,7 +132,8 @@ html(itemscope, itemtype='http://schema.org/Product') // minimal requirejs configuration (can be extended/overridden) window.requirejs = { "paths" : { - "moment": "libs/#{lib('moment')}" + "moment": "libs/#{lib('moment')}", + "fineuploader": "libs/#{lib('fineuploader')}" }, "urlArgs": "fingerprint=#{fingerprint(jsPath + 'main.js')}-#{fingerprint(jsPath + 'libs.js')}", "config":{ From b51ee7ea7e833955d217ee4aaaaeda5e1b2286c0 Mon Sep 17 00:00:00 2001 From: James Allen Date: Mon, 4 Dec 2017 09:22:47 +0000 Subject: [PATCH 63/98] Point track-changes-web-module back at master --- services/web/Jenkinsfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/web/Jenkinsfile b/services/web/Jenkinsfile index 9cdebb434c..6421296330 100644 --- a/services/web/Jenkinsfile +++ b/services/web/Jenkinsfile @@ -42,7 +42,7 @@ pipeline { checkout([$class: 'GitSCM', branches: [[name: '*/master']], extensions: [[$class: 'RelativeTargetDirectory', relativeTargetDir: 'modules/tpr-webmodule'], [$class: 'CloneOption', shallow: true]], userRemoteConfigs: [[credentialsId: 'GIT_DEPLOY_KEY', url: 'git@github.com:sharelatex/tpr-webmodule.git ']]]) checkout([$class: 'GitSCM', branches: [[name: '*/master']], extensions: [[$class: 'RelativeTargetDirectory', relativeTargetDir: 'modules/learn-wiki'], [$class: 'CloneOption', shallow: true]], userRemoteConfigs: [[credentialsId: 'GIT_DEPLOY_KEY', url: 'git@bitbucket.org:sharelatex/learn-wiki-web-module.git']]]) checkout([$class: 'GitSCM', branches: [[name: '*/master']], extensions: [[$class: 'RelativeTargetDirectory', relativeTargetDir: 'modules/templates'], [$class: 'CloneOption', shallow: true]], userRemoteConfigs: [[credentialsId: 'GIT_DEPLOY_KEY', url: 'git@github.com:sharelatex/templates-webmodule.git']]]) - checkout([$class: 'GitSCM', branches: [[name: '*/sk-unlisted-projects']], extensions: [[$class: 'RelativeTargetDirectory', relativeTargetDir: 'modules/track-changes'], [$class: 'CloneOption', shallow: true]], userRemoteConfigs: [[credentialsId: 'GIT_DEPLOY_KEY', url: 'git@github.com:sharelatex/track-changes-web-module.git']]]) + checkout([$class: 'GitSCM', branches: [[name: '*/master']], extensions: [[$class: 'RelativeTargetDirectory', relativeTargetDir: 'modules/track-changes'], [$class: 'CloneOption', shallow: true]], userRemoteConfigs: [[credentialsId: 'GIT_DEPLOY_KEY', url: 'git@github.com:sharelatex/track-changes-web-module.git']]]) checkout([$class: 'GitSCM', branches: [[name: '*/master']], extensions: [[$class: 'RelativeTargetDirectory', relativeTargetDir: 'modules/overleaf-integration'], [$class: 'CloneOption', shallow: true]], userRemoteConfigs: [[credentialsId: 'GIT_DEPLOY_KEY', url: 'git@github.com:sharelatex/overleaf-integration-web-module.git']]]) checkout([$class: 'GitSCM', branches: [[name: '*/master']], extensions: [[$class: 'RelativeTargetDirectory', relativeTargetDir: 'modules/overleaf-account-merge'], [$class: 'CloneOption', shallow: true]], userRemoteConfigs: [[credentialsId: 'GIT_DEPLOY_KEY', url: 'git@github.com:sharelatex/overleaf-account-merge.git']]]) } From 938d22da3f6a99ed66fb614e700b68a991f49098 Mon Sep 17 00:00:00 2001 From: Hayden Faulds Date: Fri, 1 Dec 2017 16:47:38 +0000 Subject: [PATCH 64/98] get csrf token from /dev/csrf not /register --- services/web/test/acceptance/coffee/helpers/User.coffee | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/services/web/test/acceptance/coffee/helpers/User.coffee b/services/web/test/acceptance/coffee/helpers/User.coffee index d9f0b1409a..6dde67849d 100644 --- a/services/web/test/acceptance/coffee/helpers/User.coffee +++ b/services/web/test/acceptance/coffee/helpers/User.coffee @@ -161,13 +161,10 @@ class User getCsrfToken: (callback = (error) ->) -> @request.get { - url: "/register" + url: "/dev/csrf" }, (err, response, body) => return callback(err) if err? - csrfMatches = body.match("window.csrfToken = \"(.*?)\";") - if !csrfMatches? - return callback(new Error("no csrf token found")) - @csrfToken = csrfMatches[1] + @csrfToken = body @request = @request.defaults({ headers: "x-csrf-token": @csrfToken From eacb19fac91aa8018c498137a7a1971d9da4198e Mon Sep 17 00:00:00 2001 From: Hayden Faulds Date: Fri, 1 Dec 2017 16:54:45 +0000 Subject: [PATCH 65/98] create user in db and use login endpoint instead of register --- .../acceptance/coffee/helpers/User.coffee | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/services/web/test/acceptance/coffee/helpers/User.coffee b/services/web/test/acceptance/coffee/helpers/User.coffee index 6dde67849d..61d8b8328c 100644 --- a/services/web/test/acceptance/coffee/helpers/User.coffee +++ b/services/web/test/acceptance/coffee/helpers/User.coffee @@ -1,6 +1,9 @@ request = require("./request") +_ = require("underscore") settings = require("settings-sharelatex") {db, ObjectId} = require("../../../../app/js/infrastructure/mongojs") +UserModel = require("../../../../app/js/models/User").User +AuthenticationManager = require("../../../../app/js/Features/Authentication/AuthenticationManager") count = 0 @@ -17,20 +20,20 @@ class User login: (callback = (error) ->) -> @getCsrfToken (error) => return callback(error) if error? - @request.post { - url: "/register" # Register will log in, but also ensure user exists - json: - email: @email - password: @password - }, (error, response, body) => + filter = {@email} + options = {upsert: true, new: true, setDefaultsOnInsert: true} + UserModel.findOneAndUpdate filter, {}, options, (error, user) => return callback(error) if error? - db.users.findOne {email: @email}, (error, user) => + AuthenticationManager.setUserPassword user._id, @password, (error) => return callback(error) if error? @id = user?._id?.toString() @_id = user?._id?.toString() @first_name = user?.first_name @referal_id = user?.referal_id - callback() + @request.post { + url: "/login" + json: { @email, @password } + }, callback logout: (callback = (error) ->) -> @getCsrfToken (error) => From 28bcf83d873f09eea11a425ca7752fc97eb46919 Mon Sep 17 00:00:00 2001 From: Hayden Faulds Date: Fri, 1 Dec 2017 17:21:54 +0000 Subject: [PATCH 66/98] better error when project creation fails --- services/web/test/acceptance/coffee/helpers/User.coffee | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/services/web/test/acceptance/coffee/helpers/User.coffee b/services/web/test/acceptance/coffee/helpers/User.coffee index 61d8b8328c..6cd7edd7d4 100644 --- a/services/web/test/acceptance/coffee/helpers/User.coffee +++ b/services/web/test/acceptance/coffee/helpers/User.coffee @@ -107,8 +107,10 @@ class User }, (error, response, body) -> return callback(error) if error? if !body?.project_id? - console.error "SOMETHING WENT WRONG CREATING PROJECT", response.statusCode, response.headers["location"], body - callback(null, body.project_id) + error = new Error("SOMETHING WENT WRONG CREATING PROJECT", response.statusCode, response.headers["location"], body) + callback error + else + callback(null, body.project_id) deleteProject: (project_id, callback=(error)) -> @request.delete { From 0178f782490f7eebff70e9371ad00806f382996a Mon Sep 17 00:00:00 2001 From: Hayden Faulds Date: Fri, 10 Nov 2017 15:47:12 +0000 Subject: [PATCH 67/98] handle file additions in DocumentUpdaterHandler.updateProjectStructure --- .../DocumentUpdaterHandler.coffee | 12 +++ .../DocumentUpdaterHandlerTests.coffee | 98 ++++++++++++++----- 2 files changed, 84 insertions(+), 26 deletions(-) diff --git a/services/web/app/coffee/Features/DocumentUpdater/DocumentUpdaterHandler.coffee b/services/web/app/coffee/Features/DocumentUpdater/DocumentUpdaterHandler.coffee index d8613680a8..77cbe379b9 100644 --- a/services/web/app/coffee/Features/DocumentUpdater/DocumentUpdaterHandler.coffee +++ b/services/web/app/coffee/Features/DocumentUpdater/DocumentUpdaterHandler.coffee @@ -244,6 +244,18 @@ module.exports = DocumentUpdaterHandler = pathname: oldEntity.path newPathname: newEntity.path + for newEntity in newEntities + id = newEntity[entityType]._id + oldEntity = _.find oldEntities, (oldEntity) -> + oldEntity[entityType]._id.toString() == id.toString() + + if !oldEntity? + updates.push + id: id + pathname: newEntity.path + docLines: newEntity.docLines + url: newEntity.url + updates PENDINGUPDATESKEY = "PendingUpdates" diff --git a/services/web/test/unit/coffee/DocumentUpdater/DocumentUpdaterHandlerTests.coffee b/services/web/test/unit/coffee/DocumentUpdater/DocumentUpdaterHandlerTests.coffee index ec81ae1c2e..d0b9106ede 100644 --- a/services/web/test/unit/coffee/DocumentUpdater/DocumentUpdaterHandlerTests.coffee +++ b/services/web/test/unit/coffee/DocumentUpdater/DocumentUpdaterHandlerTests.coffee @@ -390,19 +390,6 @@ describe 'DocumentUpdaterHandler', -> describe "updateProjectStructure ", -> beforeEach -> @user_id = 1234 - @docIdA = new ObjectId() - @docIdB = new ObjectId() - @oldDocs = [ - { path: '/old_a', doc: _id: @docIdA } - { path: '/old_b', doc: _id: @docIdB } - ] - # create new instances of the same ObjectIds so that == doens't pass - @newDocs = [ - { path: '/old_a', doc: _id: new ObjectId(@docIdA.toString()) } - { path: '/new_b', doc: _id: new ObjectId(@docIdB.toString()) } - ] - @oldFiles = [] - @newFiles = [] describe "with project history disabled", -> beforeEach -> @@ -420,20 +407,79 @@ describe 'DocumentUpdaterHandler', -> describe "with project history enabled", -> beforeEach -> @settings.apis.project_history.enabled = true + @url = "#{@settings.apis.documentupdater.url}/project/#{@project_id}" @request.post = sinon.stub().callsArgWith(1, null, {statusCode: 204}, "") - @handler.updateProjectStructure @project_id, @user_id, @oldDocs, @newDocs, @oldFiles, @newFiles, @callback - it 'should send the structure update to the document updater', -> - docUpdates = [ - id: @docIdB, - pathname: "/old_b" - newPathname: "/new_b" - ] + describe "when an entity has changed name", -> + it 'should send the structure update to the document updater', (done) -> + @docIdA = new ObjectId() + @docIdB = new ObjectId() + @oldDocs = [ + { path: '/old_a', doc: _id: @docIdA } + { path: '/old_b', doc: _id: @docIdB } + ] + # create new instances of the same ObjectIds so that == doens't pass + @newDocs = [ + { path: '/old_a', doc: _id: new ObjectId(@docIdA.toString()) } + { path: '/new_b', doc: _id: new ObjectId(@docIdB.toString()) } + ] + @oldFiles = [] + @newFiles = [] - url = "#{@settings.apis.documentupdater.url}/project/#{@project_id}" - @request.post - .calledWith(url: url, json: {docUpdates, fileUpdates: [], userId: @user_id}) - .should.equal true + docUpdates = [ + id: @docIdB, + pathname: "/old_b" + newPathname: "/new_b" + ] - it "should call the callback with no error", -> - @callback.calledWith(null).should.equal true + @handler.updateProjectStructure @project_id, @user_id, @oldDocs, @newDocs, @oldFiles, @newFiles, () => + @request.post + .calledWith(url: @url, json: {docUpdates, fileUpdates: [], userId: @user_id}) + .should.equal true + done() + + describe "when a doc has been added", -> + it 'should send the structure update to the document updater', (done) -> + @docId = new ObjectId() + @oldDocs = [] + @newDocs = [ + { path: '/foo', docLines: 'a\nb', doc: _id: @docId } + ] + @oldFiles = [] + @newFiles = [] + + docUpdates = [ + id: @docId, + pathname: "/foo" + docLines: 'a\nb' + url: undefined + ] + + @handler.updateProjectStructure @project_id, @user_id, @oldDocs, @newDocs, @oldFiles, @newFiles, () => + @request.post + .calledWith(url: @url, json: {docUpdates, fileUpdates: [], userId: @user_id}) + .should.equal true + done() + + describe "when a file has been added", -> + it 'should send the structure update to the document updater', (done) -> + @fileId = new ObjectId() + @oldDocs = [] + @newDocs = [] + @oldFiles = [] + @newFiles = [ + { path: '/bar', url: 'filestore.example.com/file', file: _id: @fileId } + ] + + fileUpdates = [ + id: @fileId, + pathname: "/bar" + url: 'filestore.example.com/file' + docLines: undefined + ] + + @handler.updateProjectStructure @project_id, @user_id, @oldDocs, @newDocs, @oldFiles, @newFiles, () => + @request.post + .calledWith(url: @url, json: {docUpdates: [], fileUpdates, userId: @user_id}) + .should.equal true + done() From 6e84c635cf332e37dfbd17d54e1aea5c7bcfbb7e Mon Sep 17 00:00:00 2001 From: Hayden Faulds Date: Fri, 10 Nov 2017 15:47:47 +0000 Subject: [PATCH 68/98] return url from FileStoreHandler.uploadFileFromDisk --- .../FileStore/FileStoreHandler.coffee | 9 ++++---- .../FileStore/FileStoreHandlerTests.coffee | 21 ++++++++++--------- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/services/web/app/coffee/Features/FileStore/FileStoreHandler.coffee b/services/web/app/coffee/Features/FileStore/FileStoreHandler.coffee index 92efe7bfcb..88f55bc698 100644 --- a/services/web/app/coffee/Features/FileStore/FileStoreHandler.coffee +++ b/services/web/app/coffee/Features/FileStore/FileStoreHandler.coffee @@ -21,9 +21,9 @@ module.exports = FileStoreHandler = return callback(new Error("can not upload symlink")) _cb = callback - callback = (err) -> + callback = (err, url) -> callback = -> # avoid double callbacks - _cb(err) + _cb(err, url) logger.log project_id:project_id, file_id:file_id, fsPath:fsPath, "uploading file from disk" readStream = fs.createReadStream(fsPath) @@ -31,9 +31,10 @@ module.exports = FileStoreHandler = logger.err err:err, project_id:project_id, file_id:file_id, fsPath:fsPath, "something went wrong on the read stream of uploadFileFromDisk" callback err readStream.on "open", () -> + url = FileStoreHandler._buildUrl(project_id, file_id) opts = method: "post" - uri: FileStoreHandler._buildUrl(project_id, file_id) + uri: url timeout:fiveMinsInMs writeStream = request(opts) writeStream.on "error", (err)-> @@ -45,7 +46,7 @@ module.exports = FileStoreHandler = logger.err {err, statusCode: response.statusCode}, "error uploading to filestore" callback(err) else - callback(null) + callback(null, url) readStream.pipe writeStream getFileStream: (project_id, file_id, query, callback)-> diff --git a/services/web/test/unit/coffee/FileStore/FileStoreHandlerTests.coffee b/services/web/test/unit/coffee/FileStore/FileStoreHandlerTests.coffee index 73f32f3a60..ea7c7a4cbb 100644 --- a/services/web/test/unit/coffee/FileStore/FileStoreHandlerTests.coffee +++ b/services/web/test/unit/coffee/FileStore/FileStoreHandlerTests.coffee @@ -16,7 +16,7 @@ describe "FileStoreHandler", -> }) @writeStream = my:"writeStream" - on: (type, cb)-> + on: (type, cb)-> if type == "response" cb({statusCode: 200}) @readStream = {my:"readStream", on: sinon.stub()} @@ -38,7 +38,7 @@ describe "FileStoreHandler", -> @isSafeOnFileSystem = true it "should create read stream", (done)-> - @fs.createReadStream.returns + @fs.createReadStream.returns pipe:-> on: (type, cb)-> if type == "open" @@ -49,8 +49,8 @@ describe "FileStoreHandler", -> it "should pipe the read stream to request", (done)-> @request.returns(@writeStream) - @fs.createReadStream.returns - on: (type, cb)-> + @fs.createReadStream.returns + on: (type, cb)-> if type == "open" cb() pipe:(o)=> @@ -59,9 +59,9 @@ describe "FileStoreHandler", -> @handler.uploadFileFromDisk @project_id, @file_id, @fsPath, => it "should pass the correct options to request", (done)-> - @fs.createReadStream.returns + @fs.createReadStream.returns pipe:-> - on: (type, cb)-> + on: (type, cb)-> if type == "open" cb() @handler.uploadFileFromDisk @project_id, @file_id, @fsPath, => @@ -70,23 +70,24 @@ describe "FileStoreHandler", -> done() it "builds the correct url", (done)-> - @fs.createReadStream.returns + @fs.createReadStream.returns pipe:-> - on: (type, cb)-> + on: (type, cb)-> if type == "open" cb() @handler.uploadFileFromDisk @project_id, @file_id, @fsPath, => @handler._buildUrl.calledWith(@project_id, @file_id).should.equal true done() - it 'should callback with null', (done) -> + it 'should callback with the url', (done) -> @fs.createReadStream.returns pipe:-> on: (type, cb)-> if type == "open" cb() - @handler.uploadFileFromDisk @project_id, @file_id, @fsPath, (err) => + @handler.uploadFileFromDisk @project_id, @file_id, @fsPath, (err, url) => expect(err).to.not.exist + expect(url).to.equal(@handler._buildUrl()) done() describe "symlink", -> From c1481d596f362ad22fd8f2df0050c1f12cb3162c Mon Sep 17 00:00:00 2001 From: Hayden Faulds Date: Fri, 10 Nov 2017 16:32:19 +0000 Subject: [PATCH 69/98] call DocumentUpdaterHandle.updateProjectStructure from ProjectEntityHandler.addDoc and addFile pass in user_id as null --- .../Project/ProjectEntityHandler.coffee | 24 +++++++++--- .../Project/ProjectEntityHandlerTests.coffee | 37 +++++++++++++++---- 2 files changed, 49 insertions(+), 12 deletions(-) diff --git a/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee b/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee index abd5c7f3d8..4bfbc0e30c 100644 --- a/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee +++ b/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee @@ -177,7 +177,14 @@ module.exports = ProjectEntityHandler = rev: 0 }, (err) -> return callback(err) if err? - callback(null, doc, folder_id) + newDoc = + doc: doc + path: result?.path?.fileSystem + docLines: docLines.join('\n') + documentUpdaterHandler = require('../../Features/DocumentUpdater/DocumentUpdaterHandler') + documentUpdaterHandler.updateProjectStructure project_id, null, [], [newDoc], [], [], (error) -> + return callback(error) if error? + callback null, doc, folder_id restoreDoc: (project_id, doc_id, name, callback = (error, doc, folder_id) ->) -> # getDoc will return the deleted doc's lines, but we don't actually remove @@ -191,15 +198,15 @@ module.exports = ProjectEntityHandler = if err? logger.err project_id:project_id, err:err, "error getting project for add file" return callback(err) - ProjectEntityHandler.addFileWithProject project, folder_id, fileName, path, callback + ProjectEntityHandler.addFileWithProject project, folder_id, fileName, path, null, callback - addFileWithProject: (project, folder_id, fileName, path, callback = (error, fileRef, folder_id) ->)-> + addFileWithProject: (project, folder_id, fileName, path, userId, callback = (error, fileRef, folder_id) ->)-> project_id = project._id logger.log project_id: project._id, folder_id: folder_id, file_name: fileName, path:path, "adding file" return callback(err) if err? confirmFolder project, folder_id, (folder_id)-> fileRef = new File name : fileName - FileStoreHandler.uploadFileFromDisk project._id, fileRef._id, path, (err)-> + FileStoreHandler.uploadFileFromDisk project._id, fileRef._id, path, (err, fileStoreUrl)-> if err? logger.err err:err, project_id: project._id, folder_id: folder_id, file_name: fileName, fileRef:fileRef, "error uploading image to s3" return callback(err) @@ -209,7 +216,14 @@ module.exports = ProjectEntityHandler = return callback(err) tpdsUpdateSender.addFile {project_id:project._id, file_id:fileRef._id, path:result?.path?.fileSystem, project_name:project.name, rev:fileRef.rev}, (err) -> return callback(err) if err? - callback(null, fileRef, folder_id) + newFile = + file: fileRef + path: result?.path?.fileSystem + url: fileStoreUrl + documentUpdaterHandler = require('../../Features/DocumentUpdater/DocumentUpdaterHandler') + documentUpdaterHandler.updateProjectStructure project_id, userId, [], [], [], [newFile], (error) -> + return callback(error) if error? + callback null, fileRef, folder_id replaceFile: (project_id, file_id, fsPath, callback)-> ProjectGetter.getProject project_id, {name:true}, (err, project) -> diff --git a/services/web/test/unit/coffee/Project/ProjectEntityHandlerTests.coffee b/services/web/test/unit/coffee/Project/ProjectEntityHandlerTests.coffee index dc723bf8bd..ca968f10a0 100644 --- a/services/web/test/unit/coffee/Project/ProjectEntityHandlerTests.coffee +++ b/services/web/test/unit/coffee/Project/ProjectEntityHandlerTests.coffee @@ -17,8 +17,9 @@ describe 'ProjectEntityHandler', -> userId = 1234 beforeEach -> + @fileUrl = 'filestore.example.com/file' @FileStoreHandler = - uploadFileFromDisk:(project_id, fileRef, localImagePath, callback)->callback() + uploadFileFromDisk: sinon.stub().callsArgWith(3, null, @fileUrl) copyFile: sinon.stub().callsArgWith(4, null) @tpdsUpdateSender = addDoc:sinon.stub().callsArg(1) @@ -67,6 +68,9 @@ describe 'ProjectEntityHandler', -> findElement : sinon.stub() @settings = maxEntitiesPerProject:200 + @documentUpdaterHandler = + updateProjectStructure: sinon.stub().callsArg(6) + deleteDoc: sinon.stub().callsArg(2) @ProjectEntityHandler = SandboxedModule.require modulePath, requires: '../../models/Project': Project:@ProjectModel '../../models/Doc': Doc:@DocModel @@ -75,7 +79,7 @@ describe 'ProjectEntityHandler', -> '../FileStore/FileStoreHandler':@FileStoreHandler '../ThirdPartyDataStore/TpdsUpdateSender':@tpdsUpdateSender './ProjectLocator': @projectLocator - '../../Features/DocumentUpdater/DocumentUpdaterHandler':@documentUpdaterHandler = {} + '../../Features/DocumentUpdater/DocumentUpdaterHandler':@documentUpdaterHandler '../Docstore/DocstoreManager': @DocstoreManager = {} 'logger-sharelatex': @logger = {log:sinon.stub(), error: sinon.stub(), err:->} './ProjectUpdateHandler': @projectUpdater @@ -184,7 +188,6 @@ describe 'ProjectEntityHandler', -> describe "_cleanUpEntity", -> beforeEach -> @entity_id = "4eecaffcbffa66588e000009" - @documentUpdaterHandler.deleteDoc = sinon.stub().callsArg(2) @FileStoreHandler.deleteFile = sinon.stub().callsArg(2) @ProjectEntityHandler.unsetRootDoc = sinon.stub().callsArg(1) @@ -240,7 +243,6 @@ describe 'ProjectEntityHandler', -> @ProjectEntityHandler._putElement = sinon.stub().callsArgWith(4, null, path: @pathAfterMove) @ProjectGetter.getProject.callsArgWith(2, null, @project) @tpdsUpdateSender.moveEntity = sinon.stub() - @documentUpdaterHandler.updateProjectStructure = sinon.stub().callsArg(6) @ProjectEntityHandler.getAllEntitiesFromProject = sinon.stub() @ProjectEntityHandler.getAllEntitiesFromProject .onFirstCall() @@ -484,6 +486,15 @@ describe 'ProjectEntityHandler', -> .calledWith(project_id, @doc._id.toString(), @lines) .should.equal true + it "should should send the change in project structure to the doc updater", () -> + newDoc = + doc: @doc + path: @path + docLines: @lines.join('\n') + @documentUpdaterHandler.updateProjectStructure + .calledWith(project_id, null, [], [newDoc], [], []) + .should.equal true + describe "restoreDoc", -> beforeEach -> @name = "doc-name" @@ -512,7 +523,10 @@ describe 'ProjectEntityHandler', -> describe 'addFile', -> fileName = "something.jpg" beforeEach -> + @fileSystemPath = "somehintg" + @ProjectEntityHandler._putElement = sinon.stub().callsArgWith(4, null, {path:{fileSystem: @fileSystemPath}}) @filePath = "somewhere" + it 'should upload it via the FileStoreHandler', (done)-> @FileStoreHandler.uploadFileFromDisk = (passedProject_id, file_id, filePath, callback)=> file_id.should.equal "file_id" @@ -533,7 +547,6 @@ describe 'ProjectEntityHandler', -> @ProjectEntityHandler.addFile project_id, folder_id, fileName, {}, (err, fileRef, parentFolder)-> it 'should return doc and parent folder', (done)-> - @ProjectEntityHandler._putElement = sinon.stub().callsArgWith(4, null, {path:{fileSystem:"somehintg"}}) @ProjectEntityHandler.addFile project_id, folder_id, fileName, {}, (err, fileRef, parentFolder)-> parentFolder.should.equal folder_id fileRef.name.should.equal fileName @@ -556,6 +569,18 @@ describe 'ProjectEntityHandler', -> @ProjectEntityHandler.addFile project_id, folder_id, fileName, {}, (err, fileRef, parentFolder)-> + it "should should send the change in project structure to the doc updater", (done) -> + @documentUpdaterHandler.updateProjectStructure = (project_id, user_id, oldDocs, newDocs, oldFiles, newFiles) => + project_id.should.equal project_id + newFiles.length.should.equal 1 + newFile = newFiles[0] + newFile.file.name.should.equal fileName + newFile.path.should.equal @fileSystemPath + newFile.url.should.equal @fileUrl + done() + + @ProjectEntityHandler.addFile project_id, folder_id, fileName, {}, () -> + describe 'replacing a file', -> beforeEach -> @@ -575,12 +600,10 @@ describe 'ProjectEntityHandler', -> done() it 'should tell the file store handler to upload the file from disk', (done)-> - @FileStoreHandler.uploadFileFromDisk = sinon.stub().callsArgWith(3) @ProjectEntityHandler.replaceFile project_id, @file_id, @fsPath, => @FileStoreHandler.uploadFileFromDisk.calledWith(project_id, @file_id, @fsPath).should.equal true done() - it 'should send the file to the tpds with an incremented rev', (done)-> @tpdsUpdateSender.addFile = (options)=> options.project_id.should.equal project_id From 42a1deaacad320a7b269236f539e334f78225d9c Mon Sep 17 00:00:00 2001 From: Hayden Faulds Date: Mon, 13 Nov 2017 11:16:54 +0000 Subject: [PATCH 70/98] pass userId into ProjectEntityHandler.addFile --- .../coffee/Features/Editor/EditorController.coffee | 2 +- .../Features/Project/ProjectCreationHandler.coffee | 2 +- .../Features/Project/ProjectEntityHandler.coffee | 4 ++-- .../unit/coffee/Editor/EditorControllerTests.coffee | 6 +++--- .../coffee/Project/ProjectCreationHandlerTests.coffee | 5 +++-- .../coffee/Project/ProjectEntityHandlerTests.coffee | 11 ++++++----- 6 files changed, 16 insertions(+), 14 deletions(-) diff --git a/services/web/app/coffee/Features/Editor/EditorController.coffee b/services/web/app/coffee/Features/Editor/EditorController.coffee index a43d6f0447..438711105e 100644 --- a/services/web/app/coffee/Features/Editor/EditorController.coffee +++ b/services/web/app/coffee/Features/Editor/EditorController.coffee @@ -52,7 +52,7 @@ module.exports = EditorController = fileName = fileName.trim() logger.log {project_id, folder_id, fileName, path}, "sending new file to project" Metrics.inc "editor.add-file" - ProjectEntityHandler.addFile project_id, folder_id, fileName, path, (err, fileRef, folder_id)=> + ProjectEntityHandler.addFile project_id, folder_id, fileName, path, null, (err, fileRef, folder_id)=> if err? logger.err err:err, project_id:project_id, folder_id:folder_id, fileName:fileName, "error adding file without lock" return callback(err) diff --git a/services/web/app/coffee/Features/Project/ProjectCreationHandler.coffee b/services/web/app/coffee/Features/Project/ProjectCreationHandler.coffee index a38b214a9e..7fef1a8a54 100644 --- a/services/web/app/coffee/Features/Project/ProjectCreationHandler.coffee +++ b/services/web/app/coffee/Features/Project/ProjectCreationHandler.coffee @@ -77,7 +77,7 @@ module.exports = ProjectCreationHandler = callback(error) (callback) -> universePath = Path.resolve(__dirname + "/../../../templates/project_files/universe.jpg") - ProjectEntityHandler.addFile project._id, project.rootFolder[0]._id, "universe.jpg", universePath, callback + ProjectEntityHandler.addFile project._id, project.rootFolder[0]._id, "universe.jpg", universePath, owner_id, callback ], (error) -> callback(error, project) diff --git a/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee b/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee index 4bfbc0e30c..db88b44ba4 100644 --- a/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee +++ b/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee @@ -193,12 +193,12 @@ module.exports = ProjectEntityHandler = return callback(error) if error? ProjectEntityHandler.addDoc project_id, null, name, lines, callback - addFile: (project_id, folder_id, fileName, path, callback = (error, fileRef, folder_id) ->)-> + addFile: (project_id, folder_id, fileName, path, userId, callback = (error, fileRef, folder_id) ->)-> ProjectGetter.getProjectWithOnlyFolders project_id, (err, project) -> if err? logger.err project_id:project_id, err:err, "error getting project for add file" return callback(err) - ProjectEntityHandler.addFileWithProject project, folder_id, fileName, path, null, callback + ProjectEntityHandler.addFileWithProject project, folder_id, fileName, path, userId, callback addFileWithProject: (project, folder_id, fileName, path, userId, callback = (error, fileRef, folder_id) ->)-> project_id = project._id diff --git a/services/web/test/unit/coffee/Editor/EditorControllerTests.coffee b/services/web/test/unit/coffee/Editor/EditorControllerTests.coffee index ae5e8d79e3..f84dc04c10 100644 --- a/services/web/test/unit/coffee/Editor/EditorControllerTests.coffee +++ b/services/web/test/unit/coffee/Editor/EditorControllerTests.coffee @@ -196,13 +196,13 @@ describe "EditorController", -> @stream = new ArrayBuffer() it 'should add the folder using the project entity handler', (done)-> - @ProjectEntityHandler.addFile = sinon.stub().callsArgWith(4) + @ProjectEntityHandler.addFile = sinon.stub().callsArgWith(5) @EditorController.addFileWithoutLock @project_id, @folder_id, @fileName, @stream, @source, => @ProjectEntityHandler.addFile.calledWith(@project_id, @folder_id).should.equal true done() it 'should send the update of a new folder out to the users in the project', (done)-> - @ProjectEntityHandler.addFile = sinon.stub().callsArgWith(4, null, @file, @folder_id) + @ProjectEntityHandler.addFile = sinon.stub().callsArgWith(5, null, @file, @folder_id) @EditorController.addFileWithoutLock @project_id, @folder_id, @fileName, @stream, @source, => @EditorRealTimeController.emitToRoom @@ -211,7 +211,7 @@ describe "EditorController", -> done() it "should return the file in the callback", (done) -> - @ProjectEntityHandler.addFile = sinon.stub().callsArgWith(4, null, @file, @folder_id) + @ProjectEntityHandler.addFile = sinon.stub().callsArgWith(5, null, @file, @folder_id) @EditorController.addFileWithoutLock @project_id, @folder_id, @fileName, @stream, @source, (error, file) => file.should.equal @file done() diff --git a/services/web/test/unit/coffee/Project/ProjectCreationHandlerTests.coffee b/services/web/test/unit/coffee/Project/ProjectCreationHandlerTests.coffee index 5c32db63d3..1b010a035c 100644 --- a/services/web/test/unit/coffee/Project/ProjectCreationHandlerTests.coffee +++ b/services/web/test/unit/coffee/Project/ProjectCreationHandlerTests.coffee @@ -34,7 +34,7 @@ describe 'ProjectCreationHandler', -> {@name} = options @ProjectEntityHandler = addDoc: sinon.stub().callsArgWith(4, null, {_id: docId}) - addFile: sinon.stub().callsArg(4) + addFile: sinon.stub().callsArg(5) setRootDoc: sinon.stub().callsArg(2) @ProjectDetailsHandler = validateProjectName: sinon.stub().yields() @@ -192,7 +192,8 @@ describe 'ProjectCreationHandler', -> @ProjectEntityHandler.addFile .calledWith( project_id, rootFolderId, "universe.jpg", - Path.resolve(__dirname + "/../../../../app/templates/project_files/universe.jpg") + Path.resolve(__dirname + "/../../../../app/templates/project_files/universe.jpg"), + ownerId ) .should.equal true diff --git a/services/web/test/unit/coffee/Project/ProjectEntityHandlerTests.coffee b/services/web/test/unit/coffee/Project/ProjectEntityHandlerTests.coffee index ca968f10a0..22e2926934 100644 --- a/services/web/test/unit/coffee/Project/ProjectEntityHandlerTests.coffee +++ b/services/web/test/unit/coffee/Project/ProjectEntityHandlerTests.coffee @@ -534,7 +534,7 @@ describe 'ProjectEntityHandler', -> filePath.should.equal @filePath done() - @ProjectEntityHandler.addFile project_id, folder_id, fileName, @filePath, (err, fileRef, parentFolder)-> + @ProjectEntityHandler.addFile project_id, folder_id, fileName, @filePath, userId, (err, fileRef, parentFolder)-> it 'should put file into folder by calling put element', (done)-> @ProjectEntityHandler._putElement = (passedProject, passedFolder_id, passedFileRef, passedType, callback)-> @@ -544,10 +544,10 @@ describe 'ProjectEntityHandler', -> passedType.should.equal 'file' done() - @ProjectEntityHandler.addFile project_id, folder_id, fileName, {}, (err, fileRef, parentFolder)-> + @ProjectEntityHandler.addFile project_id, folder_id, fileName, {}, userId, (err, fileRef, parentFolder)-> it 'should return doc and parent folder', (done)-> - @ProjectEntityHandler.addFile project_id, folder_id, fileName, {}, (err, fileRef, parentFolder)-> + @ProjectEntityHandler.addFile project_id, folder_id, fileName, {}, userId, (err, fileRef, parentFolder)-> parentFolder.should.equal folder_id fileRef.name.should.equal fileName done() @@ -567,11 +567,12 @@ describe 'ProjectEntityHandler', -> options.rev.should.equal 0 done() - @ProjectEntityHandler.addFile project_id, folder_id, fileName, {}, (err, fileRef, parentFolder)-> + @ProjectEntityHandler.addFile project_id, folder_id, fileName, {}, userId, (err, fileRef, parentFolder)-> it "should should send the change in project structure to the doc updater", (done) -> @documentUpdaterHandler.updateProjectStructure = (project_id, user_id, oldDocs, newDocs, oldFiles, newFiles) => project_id.should.equal project_id + user_id.should.equal user_id newFiles.length.should.equal 1 newFile = newFiles[0] newFile.file.name.should.equal fileName @@ -579,7 +580,7 @@ describe 'ProjectEntityHandler', -> newFile.url.should.equal @fileUrl done() - @ProjectEntityHandler.addFile project_id, folder_id, fileName, {}, () -> + @ProjectEntityHandler.addFile project_id, folder_id, fileName, {}, userId, () -> describe 'replacing a file', -> From 953dba0eded80ad0d6524e9e74df398974f6382b Mon Sep 17 00:00:00 2001 From: Hayden Faulds Date: Mon, 13 Nov 2017 12:20:14 +0000 Subject: [PATCH 71/98] pass userId into Editorcontroller.addFile / addFileWithoutLock --- .../Features/Editor/EditorController.coffee | 8 +++---- .../ThirdPartyDataStore/UpdateMerger.coffee | 6 ++--- .../Uploads/FileSystemImportManager.coffee | 4 ++-- .../Editor/EditorControllerTests.coffee | 24 +++++++++---------- .../UpdateMergerTests.coffee | 12 +++++----- .../FileSystemImportManagerTests.coffee | 8 +++---- 6 files changed, 31 insertions(+), 31 deletions(-) diff --git a/services/web/app/coffee/Features/Editor/EditorController.coffee b/services/web/app/coffee/Features/Editor/EditorController.coffee index 438711105e..d2fa43ad62 100644 --- a/services/web/app/coffee/Features/Editor/EditorController.coffee +++ b/services/web/app/coffee/Features/Editor/EditorController.coffee @@ -39,20 +39,20 @@ module.exports = EditorController = EditorRealTimeController.emitToRoom(project_id, 'reciveNewDoc', folder_id, doc, source) callback(err, doc) - addFile: (project_id, folder_id, fileName, path, source, callback = (error, file)->)-> + addFile: (project_id, folder_id, fileName, path, source, user_id, callback = (error, file)->)-> LockManager.getLock project_id, (err)-> if err? logger.err err:err, project_id:project_id, source:source, "could not get lock to addFile" return callback(err) - EditorController.addFileWithoutLock project_id, folder_id, fileName, path, source, (error, file)-> + EditorController.addFileWithoutLock project_id, folder_id, fileName, path, source, user_id, (error, file)-> LockManager.releaseLock project_id, -> callback(error, file) - addFileWithoutLock: (project_id, folder_id, fileName, path, source, callback = (error, file)->)-> + addFileWithoutLock: (project_id, folder_id, fileName, path, source, user_id, callback = (error, file)->)-> fileName = fileName.trim() logger.log {project_id, folder_id, fileName, path}, "sending new file to project" Metrics.inc "editor.add-file" - ProjectEntityHandler.addFile project_id, folder_id, fileName, path, null, (err, fileRef, folder_id)=> + ProjectEntityHandler.addFile project_id, folder_id, fileName, path, user_id, (err, fileRef, folder_id)=> if err? logger.err err:err, project_id:project_id, folder_id:folder_id, fileName:fileName, "error adding file without lock" return callback(err) diff --git a/services/web/app/coffee/Features/ThirdPartyDataStore/UpdateMerger.coffee b/services/web/app/coffee/Features/ThirdPartyDataStore/UpdateMerger.coffee index bce0befe22..e78c1bd1e4 100644 --- a/services/web/app/coffee/Features/ThirdPartyDataStore/UpdateMerger.coffee +++ b/services/web/app/coffee/Features/ThirdPartyDataStore/UpdateMerger.coffee @@ -28,7 +28,7 @@ module.exports = FileTypeManager.isBinary path, fsPath, (err, isFile)-> return callback(err) if err? if isFile - self.p.processFile project_id, elementId, fsPath, path, source, callback + self.p.processFile project_id, elementId, fsPath, path, source, user_id, callback else self.p.processDoc project_id, elementId, user_id, fsPath, path, source, callback @@ -59,7 +59,7 @@ module.exports = return callback(err) editorController.addDoc project_id, folder._id, fileName, docLines, source, callback - processFile: (project_id, file_id, fsPath, path, source, callback)-> + processFile: (project_id, file_id, fsPath, path, source, user_id, callback)-> finish = (err)-> logger.log project_id:project_id, file_id:file_id, path:path, "completed processing file update from tpds" callback(err) @@ -71,7 +71,7 @@ module.exports = else if file_id? editorController.replaceFile project_id, file_id, fsPath, source, finish else - editorController.addFile project_id, folder?._id, fileName, fsPath, source, finish + editorController.addFile project_id, folder?._id, fileName, fsPath, source, user_id, finish writeStreamToDisk: (project_id, file_id, stream, callback = (err, fsPath)->)-> if !file_id? diff --git a/services/web/app/coffee/Features/Uploads/FileSystemImportManager.coffee b/services/web/app/coffee/Features/Uploads/FileSystemImportManager.coffee index f491807e9d..a1ddc2e59b 100644 --- a/services/web/app/coffee/Features/Uploads/FileSystemImportManager.coffee +++ b/services/web/app/coffee/Features/Uploads/FileSystemImportManager.coffee @@ -39,7 +39,7 @@ module.exports = FileSystemImportManager = return callback("path is symlink") if !replace - EditorController.addFileWithoutLock project_id, folder_id, name, path, "upload", callback + EditorController.addFileWithoutLock project_id, folder_id, name, path, "upload", user_id, callback else ProjectLocator.findElement project_id: project_id, element_id: folder_id, type: "folder", (error, folder) -> return callback(error) if error? @@ -52,7 +52,7 @@ module.exports = FileSystemImportManager = if existingFile? EditorController.replaceFile project_id, existingFile._id, path, "upload", callback else - EditorController.addFileWithoutLock project_id, folder_id, name, path, "upload", callback + EditorController.addFileWithoutLock project_id, folder_id, name, path, "upload", user_id, callback addFolder: (user_id, project_id, folder_id, name, path, replace, callback = (error)-> ) -> FileSystemImportManager._isSafeOnFileSystem path, (err, isSafe)-> diff --git a/services/web/test/unit/coffee/Editor/EditorControllerTests.coffee b/services/web/test/unit/coffee/Editor/EditorControllerTests.coffee index f84dc04c10..7738d11b3b 100644 --- a/services/web/test/unit/coffee/Editor/EditorControllerTests.coffee +++ b/services/web/test/unit/coffee/Editor/EditorControllerTests.coffee @@ -197,14 +197,14 @@ describe "EditorController", -> it 'should add the folder using the project entity handler', (done)-> @ProjectEntityHandler.addFile = sinon.stub().callsArgWith(5) - @EditorController.addFileWithoutLock @project_id, @folder_id, @fileName, @stream, @source, => - @ProjectEntityHandler.addFile.calledWith(@project_id, @folder_id).should.equal true + @EditorController.addFileWithoutLock @project_id, @folder_id, @fileName, @stream, @source, @user_id, => + @ProjectEntityHandler.addFile.calledWith(@project_id, @folder_id, @fileName, @stream, @user_id).should.equal true done() it 'should send the update of a new folder out to the users in the project', (done)-> @ProjectEntityHandler.addFile = sinon.stub().callsArgWith(5, null, @file, @folder_id) - @EditorController.addFileWithoutLock @project_id, @folder_id, @fileName, @stream, @source, => + @EditorController.addFileWithoutLock @project_id, @folder_id, @fileName, @stream, @source, @user_id, => @EditorRealTimeController.emitToRoom .calledWith(@project_id, "reciveNewFile", @folder_id, @file, @source) .should.equal true @@ -212,7 +212,7 @@ describe "EditorController", -> it "should return the file in the callback", (done) -> @ProjectEntityHandler.addFile = sinon.stub().callsArgWith(5, null, @file, @folder_id) - @EditorController.addFileWithoutLock @project_id, @folder_id, @fileName, @stream, @source, (error, file) => + @EditorController.addFileWithoutLock @project_id, @folder_id, @fileName, @stream, @source, @user_id, (error, file) => file.should.equal @file done() @@ -222,28 +222,28 @@ describe "EditorController", -> beforeEach -> @LockManager.getLock.callsArgWith(1) @LockManager.releaseLock.callsArgWith(1) - @EditorController.addFileWithoutLock = sinon.stub().callsArgWith(5) + @EditorController.addFileWithoutLock = sinon.stub().callsArgWith(6) it "should call addFileWithoutLock", (done)-> - @EditorController.addFile @project_id, @folder_id, @fileName, @stream, @source, (error, file) => - @EditorController.addFileWithoutLock.calledWith(@project_id, @folder_id, @fileName, @stream, @source).should.equal true + @EditorController.addFile @project_id, @folder_id, @fileName, @stream, @source, @user_id, (error, file) => + @EditorController.addFileWithoutLock.calledWith(@project_id, @folder_id, @fileName, @stream, @source, @user_id).should.equal true done() it "should take the lock", (done)-> - @EditorController.addFile @project_id, @folder_id, @fileName, @stream, @source, (error, file) => + @EditorController.addFile @project_id, @folder_id, @fileName, @stream, @source, @user_id, (error, file) => @LockManager.getLock.calledWith(@project_id).should.equal true done() it "should release the lock", (done)-> - @EditorController.addFile @project_id, @folder_id, @fileName, @stream, @source, (error, file) => + @EditorController.addFile @project_id, @folder_id, @fileName, @stream, @source, @user_id, (error, file) => @LockManager.releaseLock.calledWith(@project_id).should.equal true done() it "should error if it can't cat the lock", (done)-> @LockManager.getLock = sinon.stub().callsArgWith(1, "timed out") - @EditorController.addFile @project_id, @folder_id, @fileName, @stream, @source, (err, file) => - expect(err).to.exist - err.should.equal "timed out" + @EditorController.addFile @project_id, @folder_id, @fileName, @stream, @source, @user_id, (error, file) => + expect(error).to.exist + error.should.equal "timed out" done() diff --git a/services/web/test/unit/coffee/ThirdPartyDataStore/UpdateMergerTests.coffee b/services/web/test/unit/coffee/ThirdPartyDataStore/UpdateMergerTests.coffee index 1b00f20bde..c2d3816a5c 100644 --- a/services/web/test/unit/coffee/ThirdPartyDataStore/UpdateMergerTests.coffee +++ b/services/web/test/unit/coffee/ThirdPartyDataStore/UpdateMergerTests.coffee @@ -63,11 +63,11 @@ describe 'UpdateMerger :', -> file_id = "1231" @projectLocator.findElementByPath = (_, __, cb)->cb(null, {_id:file_id}) @FileTypeManager.isBinary.callsArgWith(2, null, true) - @updateMerger.p.processFile = sinon.stub().callsArgWith(5) + @updateMerger.p.processFile = sinon.stub().callsArgWith(6) filePath = "/folder/file1.png" @updateMerger.mergeUpdate @user_id, @project_id, filePath, @update, @source, => - @updateMerger.p.processFile.calledWith(@project_id, file_id, @fsPath, filePath, @source).should.equal true + @updateMerger.p.processFile.calledWith(@project_id, file_id, @fsPath, filePath, @source, @user_id).should.equal true @FileTypeManager.isBinary.calledWith(filePath, @fsPath).should.equal true @fs.unlink.calledWith(@fsPath).should.equal true done() @@ -114,22 +114,22 @@ describe 'UpdateMerger :', -> @folder = _id: @folder_id @fileName = "file.png" @fsPath = "fs/path.tex" - @editorController.addFile = sinon.stub().callsArg(5) + @editorController.addFile = sinon.stub().callsArg(6) @editorController.replaceFile = sinon.stub().callsArg(4) @editorController.deleteEntity = sinon.stub() @editorController.mkdirp = sinon.stub().withArgs(@project_id).callsArgWith(2, null, [@folder], @folder) @updateMerger.p.writeStreamToDisk = sinon.stub().withArgs(@project_id, @file_id, @update).callsArgWith(3, null, @fsPath) it 'should replace file if the file already exists', (done)-> - @updateMerger.p.processFile @project_id, @file_id, @fsPath, @path, @source, => + @updateMerger.p.processFile @project_id, @file_id, @fsPath, @path, @source, @user_id, => @editorController.addFile.called.should.equal false @editorController.replaceFile.calledWith(@project_id, @file_id, @fsPath, @source).should.equal true done() it 'should call add file if the file does not exist', (done)-> - @updateMerger.p.processFile @project_id, undefined, @fsPath, @path, @source, => + @updateMerger.p.processFile @project_id, undefined, @fsPath, @path, @source, @user_id, => @editorController.mkdirp.calledWith(@project_id, "folder/").should.equal true - @editorController.addFile.calledWith(@project_id, @folder_id, @fileName, @fsPath, @source).should.equal true + @editorController.addFile.calledWith(@project_id, @folder_id, @fileName, @fsPath, @source, @user_id).should.equal true @editorController.replaceFile.called.should.equal false done() diff --git a/services/web/test/unit/coffee/Uploads/FileSystemImportManagerTests.coffee b/services/web/test/unit/coffee/Uploads/FileSystemImportManagerTests.coffee index a33775446f..032b60b261 100644 --- a/services/web/test/unit/coffee/Uploads/FileSystemImportManagerTests.coffee +++ b/services/web/test/unit/coffee/Uploads/FileSystemImportManagerTests.coffee @@ -114,12 +114,12 @@ describe "FileSystemImportManager", -> describe "addFile with replace set to false", -> beforeEach -> - @EditorController.addFileWithoutLock = sinon.stub().callsArg(5) + @EditorController.addFileWithoutLock = sinon.stub().callsArg(6) @FileSystemImportManager._isSafeOnFileSystem = sinon.stub().callsArgWith(1, null, true) @FileSystemImportManager.addFile @user_id, @project_id, @folder_id, @name, @path_on_disk, false, @callback it "should add the file", -> - @EditorController.addFileWithoutLock.calledWith(@project_id, @folder_id, @name, @path_on_disk, "upload") + @EditorController.addFileWithoutLock.calledWith(@project_id, @folder_id, @name, @path_on_disk, "upload", @user_id) .should.equal true describe "addFile with symlink", -> @@ -144,7 +144,7 @@ describe "FileSystemImportManager", -> } @FileSystemImportManager._isSafeOnFileSystem = sinon.stub().callsArgWith(1, null, true) @ProjectLocator.findElement = sinon.stub().callsArgWith(1, null, @folder) - @EditorController.addFileWithoutLock = sinon.stub().callsArg(5) + @EditorController.addFileWithoutLock = sinon.stub().callsArg(6) @FileSystemImportManager.addFile @user_id, @project_id, @folder_id, @name, @path_on_disk, true, @callback it "should look up the folder", -> @@ -153,7 +153,7 @@ describe "FileSystemImportManager", -> .should.equal true it "should add the file", -> - @EditorController.addFileWithoutLock.calledWith(@project_id, @folder_id, @name, @path_on_disk, "upload") + @EditorController.addFileWithoutLock.calledWith(@project_id, @folder_id, @name, @path_on_disk, "upload", @user_id) .should.equal true describe "when the file does exist", -> From ef68c6a5312f136087fa9914d03224c133776e00 Mon Sep 17 00:00:00 2001 From: Hayden Faulds Date: Tue, 14 Nov 2017 16:47:15 +0000 Subject: [PATCH 72/98] avoid N+1 lookups in DocumentUpdaterHandler --- .../DocumentUpdaterHandler.coffee | 17 +++++++++-------- .../DocumentUpdaterHandlerTests.coffee | 6 +++--- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/services/web/app/coffee/Features/DocumentUpdater/DocumentUpdaterHandler.coffee b/services/web/app/coffee/Features/DocumentUpdater/DocumentUpdaterHandler.coffee index 77cbe379b9..3f5972bdc7 100644 --- a/services/web/app/coffee/Features/DocumentUpdater/DocumentUpdaterHandler.coffee +++ b/services/web/app/coffee/Features/DocumentUpdater/DocumentUpdaterHandler.coffee @@ -233,22 +233,23 @@ module.exports = DocumentUpdaterHandler = _getRenameUpdates: (entityType, oldEntities, newEntities) -> updates = [] - for oldEntity in oldEntities - id = oldEntity[entityType]._id - newEntity = _.find newEntities, (newEntity) -> - newEntity[entityType]._id.toString() == id.toString() + oldEntitiesHash = _.indexBy oldEntities, (entity) -> entity[entityType]._id.toString() + newEntitiesHash = _.indexBy newEntities, (entity) -> entity[entityType]._id.toString() + for id, oldEntity of oldEntitiesHash + newEntity = newEntitiesHash[id] + + # renamed entities if newEntity.path != oldEntity.path updates.push id: id pathname: oldEntity.path newPathname: newEntity.path - for newEntity in newEntities - id = newEntity[entityType]._id - oldEntity = _.find oldEntities, (oldEntity) -> - oldEntity[entityType]._id.toString() == id.toString() + for id, newEntity of newEntitiesHash + oldEntity = oldEntitiesHash[id] + # removed entities if !oldEntity? updates.push id: id diff --git a/services/web/test/unit/coffee/DocumentUpdater/DocumentUpdaterHandlerTests.coffee b/services/web/test/unit/coffee/DocumentUpdater/DocumentUpdaterHandlerTests.coffee index d0b9106ede..9f1e292b2c 100644 --- a/services/web/test/unit/coffee/DocumentUpdater/DocumentUpdaterHandlerTests.coffee +++ b/services/web/test/unit/coffee/DocumentUpdater/DocumentUpdaterHandlerTests.coffee @@ -427,7 +427,7 @@ describe 'DocumentUpdaterHandler', -> @newFiles = [] docUpdates = [ - id: @docIdB, + id: @docIdB.toString(), pathname: "/old_b" newPathname: "/new_b" ] @@ -449,7 +449,7 @@ describe 'DocumentUpdaterHandler', -> @newFiles = [] docUpdates = [ - id: @docId, + id: @docId.toString(), pathname: "/foo" docLines: 'a\nb' url: undefined @@ -472,7 +472,7 @@ describe 'DocumentUpdaterHandler', -> ] fileUpdates = [ - id: @fileId, + id: @fileId.toString(), pathname: "/bar" url: 'filestore.example.com/file' docLines: undefined From acda4c2295fe922c63da47d6ee990f21ba170067 Mon Sep 17 00:00:00 2001 From: Hayden Faulds Date: Tue, 14 Nov 2017 16:52:53 +0000 Subject: [PATCH 73/98] remove inline requires from ProjectEntityHandler --- .../coffee/Features/Project/ProjectEntityHandler.coffee | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee b/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee index db88b44ba4..09c64c7ca9 100644 --- a/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee +++ b/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee @@ -16,7 +16,7 @@ projectUpdateHandler = require('./ProjectUpdateHandler') DocstoreManager = require "../Docstore/DocstoreManager" ProjectGetter = require "./ProjectGetter" CooldownManager = require '../Cooldown/CooldownManager' - +documentUpdaterHandler = require('../../Features/DocumentUpdater/DocumentUpdaterHandler') module.exports = ProjectEntityHandler = getAllFolders: (project_id, callback) -> @@ -106,7 +106,6 @@ module.exports = ProjectEntityHandler = flushProjectToThirdPartyDataStore: (project_id, callback) -> self = @ logger.log project_id:project_id, "flushing project to tpds" - documentUpdaterHandler = require('../../Features/DocumentUpdater/DocumentUpdaterHandler') documentUpdaterHandler.flushProjectToMongo project_id, (error) -> return callback(error) if error? ProjectGetter.getProject project_id, {name:true}, (error, project) -> @@ -181,7 +180,6 @@ module.exports = ProjectEntityHandler = doc: doc path: result?.path?.fileSystem docLines: docLines.join('\n') - documentUpdaterHandler = require('../../Features/DocumentUpdater/DocumentUpdaterHandler') documentUpdaterHandler.updateProjectStructure project_id, null, [], [newDoc], [], [], (error) -> return callback(error) if error? callback null, doc, folder_id @@ -220,7 +218,6 @@ module.exports = ProjectEntityHandler = file: fileRef path: result?.path?.fileSystem url: fileStoreUrl - documentUpdaterHandler = require('../../Features/DocumentUpdater/DocumentUpdaterHandler') documentUpdaterHandler.updateProjectStructure project_id, userId, [], [], [], [newFile], (error) -> return callback(error) if error? callback null, fileRef, folder_id @@ -398,7 +395,6 @@ module.exports = ProjectEntityHandler = self.getAllEntitiesFromProject newProject, (error, newDocs, newFiles ) => return callback(error) if error? - documentUpdaterHandler = require('../../Features/DocumentUpdater/DocumentUpdaterHandler') documentUpdaterHandler.updateProjectStructure project_id, userId, oldDocs, newDocs, oldFiles, newFiles, callback _checkValidMove: (project, entityType, entityPath, destFolderId, callback = (error) ->) -> @@ -456,7 +452,6 @@ module.exports = ProjectEntityHandler = return callback(error) if error? ProjectEntityHandler.getAllEntitiesFromProject newProject, (error, newDocs, newFiles) => return callback(error) if error? - documentUpdaterHandler = require('../../Features/DocumentUpdater/DocumentUpdaterHandler') documentUpdaterHandler.updateProjectStructure project_id, userId, oldDocs, newDocs, oldFiles, newFiles, callback _cleanUpEntity: (project, entity, entityType, callback = (error) ->) -> @@ -480,7 +475,7 @@ module.exports = ProjectEntityHandler = unsetRootDocIfRequired (error) -> return callback(error) if error? - require('../../Features/DocumentUpdater/DocumentUpdaterHandler').deleteDoc project_id, doc_id, (error) -> + documentUpdaterHandler.deleteDoc project_id, doc_id, (error) -> return callback(error) if error? ProjectEntityHandler._insertDeletedDocReference project._id, doc, (error) -> return callback(error) if error? From 62f88402e86b5d85d0a8d2b0ce324c245924acb3 Mon Sep 17 00:00:00 2001 From: Hayden Faulds Date: Wed, 15 Nov 2017 15:07:55 +0000 Subject: [PATCH 74/98] fix DocumentUpdaterHandler casing --- .../Features/Project/ProjectEntityHandler.coffee | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee b/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee index 09c64c7ca9..ee55252761 100644 --- a/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee +++ b/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee @@ -16,7 +16,7 @@ projectUpdateHandler = require('./ProjectUpdateHandler') DocstoreManager = require "../Docstore/DocstoreManager" ProjectGetter = require "./ProjectGetter" CooldownManager = require '../Cooldown/CooldownManager' -documentUpdaterHandler = require('../../Features/DocumentUpdater/DocumentUpdaterHandler') +DocumentUpdaterHandler = require('../../Features/DocumentUpdater/DocumentUpdaterHandler') module.exports = ProjectEntityHandler = getAllFolders: (project_id, callback) -> @@ -106,7 +106,7 @@ module.exports = ProjectEntityHandler = flushProjectToThirdPartyDataStore: (project_id, callback) -> self = @ logger.log project_id:project_id, "flushing project to tpds" - documentUpdaterHandler.flushProjectToMongo project_id, (error) -> + DocumentUpdaterHandler.flushProjectToMongo project_id, (error) -> return callback(error) if error? ProjectGetter.getProject project_id, {name:true}, (error, project) -> return callback(error) if error? @@ -180,7 +180,7 @@ module.exports = ProjectEntityHandler = doc: doc path: result?.path?.fileSystem docLines: docLines.join('\n') - documentUpdaterHandler.updateProjectStructure project_id, null, [], [newDoc], [], [], (error) -> + DocumentUpdaterHandler.updateProjectStructure project_id, null, [], [newDoc], [], [], (error) -> return callback(error) if error? callback null, doc, folder_id @@ -218,7 +218,7 @@ module.exports = ProjectEntityHandler = file: fileRef path: result?.path?.fileSystem url: fileStoreUrl - documentUpdaterHandler.updateProjectStructure project_id, userId, [], [], [], [newFile], (error) -> + DocumentUpdaterHandler.updateProjectStructure project_id, userId, [], [], [], [newFile], (error) -> return callback(error) if error? callback null, fileRef, folder_id @@ -395,7 +395,7 @@ module.exports = ProjectEntityHandler = self.getAllEntitiesFromProject newProject, (error, newDocs, newFiles ) => return callback(error) if error? - documentUpdaterHandler.updateProjectStructure project_id, userId, oldDocs, newDocs, oldFiles, newFiles, callback + DocumentUpdaterHandler.updateProjectStructure project_id, userId, oldDocs, newDocs, oldFiles, newFiles, callback _checkValidMove: (project, entityType, entityPath, destFolderId, callback = (error) ->) -> return callback() if !entityType.match(/folder/) @@ -452,7 +452,7 @@ module.exports = ProjectEntityHandler = return callback(error) if error? ProjectEntityHandler.getAllEntitiesFromProject newProject, (error, newDocs, newFiles) => return callback(error) if error? - documentUpdaterHandler.updateProjectStructure project_id, userId, oldDocs, newDocs, oldFiles, newFiles, callback + DocumentUpdaterHandler.updateProjectStructure project_id, userId, oldDocs, newDocs, oldFiles, newFiles, callback _cleanUpEntity: (project, entity, entityType, callback = (error) ->) -> if(entityType.indexOf("file") != -1) @@ -475,7 +475,7 @@ module.exports = ProjectEntityHandler = unsetRootDocIfRequired (error) -> return callback(error) if error? - documentUpdaterHandler.deleteDoc project_id, doc_id, (error) -> + DocumentUpdaterHandler.deleteDoc project_id, doc_id, (error) -> return callback(error) if error? ProjectEntityHandler._insertDeletedDocReference project._id, doc, (error) -> return callback(error) if error? From bfd22899a02d6db1e370dec70408ae263dd31a0b Mon Sep 17 00:00:00 2001 From: Hayden Faulds Date: Wed, 15 Nov 2017 15:12:59 +0000 Subject: [PATCH 75/98] fix DocumentUpdaterHandler.updateProjectStructure --- .../DocumentUpdaterHandler.coffee | 18 +++++++----------- .../DocumentUpdaterHandlerTests.coffee | 15 +++++++++++++++ 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/services/web/app/coffee/Features/DocumentUpdater/DocumentUpdaterHandler.coffee b/services/web/app/coffee/Features/DocumentUpdater/DocumentUpdaterHandler.coffee index 3f5972bdc7..fc4b5c452b 100644 --- a/services/web/app/coffee/Features/DocumentUpdater/DocumentUpdaterHandler.coffee +++ b/services/web/app/coffee/Features/DocumentUpdater/DocumentUpdaterHandler.coffee @@ -236,26 +236,22 @@ module.exports = DocumentUpdaterHandler = oldEntitiesHash = _.indexBy oldEntities, (entity) -> entity[entityType]._id.toString() newEntitiesHash = _.indexBy newEntities, (entity) -> entity[entityType]._id.toString() - for id, oldEntity of oldEntitiesHash - newEntity = newEntitiesHash[id] - - # renamed entities - if newEntity.path != oldEntity.path - updates.push - id: id - pathname: oldEntity.path - newPathname: newEntity.path - for id, newEntity of newEntitiesHash oldEntity = oldEntitiesHash[id] - # removed entities if !oldEntity? + # entity added updates.push id: id pathname: newEntity.path docLines: newEntity.docLines url: newEntity.url + else if newEntity.path != oldEntity.path + # entity renamed + updates.push + id: id + pathname: oldEntity.path + newPathname: newEntity.path updates diff --git a/services/web/test/unit/coffee/DocumentUpdater/DocumentUpdaterHandlerTests.coffee b/services/web/test/unit/coffee/DocumentUpdater/DocumentUpdaterHandlerTests.coffee index 9f1e292b2c..99214749d6 100644 --- a/services/web/test/unit/coffee/DocumentUpdater/DocumentUpdaterHandlerTests.coffee +++ b/services/web/test/unit/coffee/DocumentUpdater/DocumentUpdaterHandlerTests.coffee @@ -483,3 +483,18 @@ describe 'DocumentUpdaterHandler', -> .calledWith(url: @url, json: {docUpdates: [], fileUpdates, userId: @user_id}) .should.equal true done() + + describe "when a doc has been deleted", -> + it 'should do nothing', (done) -> + @docId = new ObjectId() + @oldDocs = [ + { path: '/foo', docLines: 'a\nb', doc: _id: @docId } + ] + @newDocs = [] + @oldFiles = [] + @newFiles = [] + + @handler.updateProjectStructure @project_id, @user_id, @oldDocs, @newDocs, @oldFiles, @newFiles, () => + @request.post.called.should.equal false + done() + From 300f76bc5c12a720b021018fd3265f6948b04499 Mon Sep 17 00:00:00 2001 From: Hayden Faulds Date: Thu, 23 Nov 2017 15:18:43 +0000 Subject: [PATCH 76/98] pass user_id into ProjectEntityHandler.addDoc / addDocWithProject --- .../app/coffee/Features/Editor/EditorController.coffee | 2 +- .../coffee/Features/Project/ProjectCreationHandler.coffee | 6 +++--- .../app/coffee/Features/Project/ProjectDuplicator.coffee | 2 +- .../coffee/Features/Project/ProjectEntityHandler.coffee | 8 ++++---- .../test/unit/coffee/Editor/EditorControllerTests.coffee | 6 +++--- .../coffee/Project/ProjectCreationHandlerTests.coffee | 8 ++++---- .../unit/coffee/Project/ProjectDuplicatorTests.coffee | 6 +++--- .../unit/coffee/Project/ProjectEntityHandlerTests.coffee | 4 ++-- 8 files changed, 21 insertions(+), 21 deletions(-) diff --git a/services/web/app/coffee/Features/Editor/EditorController.coffee b/services/web/app/coffee/Features/Editor/EditorController.coffee index d2fa43ad62..6bafac08da 100644 --- a/services/web/app/coffee/Features/Editor/EditorController.coffee +++ b/services/web/app/coffee/Features/Editor/EditorController.coffee @@ -32,7 +32,7 @@ module.exports = EditorController = docName = docName.trim() logger.log {project_id, folder_id, docName, source}, "sending new doc to project" Metrics.inc "editor.add-doc" - ProjectEntityHandler.addDoc project_id, folder_id, docName, docLines, (err, doc, folder_id)=> + ProjectEntityHandler.addDoc project_id, folder_id, docName, docLines, null, (err, doc, folder_id)=> if err? logger.err err:err, project_id:project_id, docName:docName, "error adding doc without lock" return callback(err) diff --git a/services/web/app/coffee/Features/Project/ProjectCreationHandler.coffee b/services/web/app/coffee/Features/Project/ProjectCreationHandler.coffee index 7fef1a8a54..cd78cddc09 100644 --- a/services/web/app/coffee/Features/Project/ProjectCreationHandler.coffee +++ b/services/web/app/coffee/Features/Project/ProjectCreationHandler.coffee @@ -52,7 +52,7 @@ module.exports = ProjectCreationHandler = return callback(error) if error? self._buildTemplate "mainbasic.tex", owner_id, projectName, (error, docLines)-> return callback(error) if error? - ProjectEntityHandler.addDoc project._id, project.rootFolder[0]._id, "main.tex", docLines, (error, doc)-> + ProjectEntityHandler.addDoc project._id, project.rootFolder[0]._id, "main.tex", docLines, owner_id, (error, doc)-> if error? logger.err err:error, "error adding doc when creating basic project" return callback(error) @@ -67,13 +67,13 @@ module.exports = ProjectCreationHandler = (callback) -> self._buildTemplate "main.tex", owner_id, projectName, (error, docLines)-> return callback(error) if error? - ProjectEntityHandler.addDoc project._id, project.rootFolder[0]._id, "main.tex", docLines, (error, doc)-> + ProjectEntityHandler.addDoc project._id, project.rootFolder[0]._id, "main.tex", docLines, owner_id, (error, doc)-> return callback(error) if error? ProjectEntityHandler.setRootDoc project._id, doc._id, callback (callback) -> self._buildTemplate "references.bib", owner_id, projectName, (error, docLines)-> return callback(error) if error? - ProjectEntityHandler.addDoc project._id, project.rootFolder[0]._id, "references.bib", docLines, (error, doc)-> + ProjectEntityHandler.addDoc project._id, project.rootFolder[0]._id, "references.bib", docLines, owner_id, (error, doc)-> callback(error) (callback) -> universePath = Path.resolve(__dirname + "/../../../templates/project_files/universe.jpg") diff --git a/services/web/app/coffee/Features/Project/ProjectDuplicator.coffee b/services/web/app/coffee/Features/Project/ProjectDuplicator.coffee index d945326395..6f7f551bc6 100644 --- a/services/web/app/coffee/Features/Project/ProjectDuplicator.coffee +++ b/services/web/app/coffee/Features/Project/ProjectDuplicator.coffee @@ -21,7 +21,7 @@ module.exports = ProjectDuplicator = if !doc?._id? return callback() content = docContents[doc._id.toString()] - projectEntityHandler.addDocWithProject newProject, desFolder._id, doc.name, content.lines, (err, newDoc)-> + projectEntityHandler.addDocWithProject newProject, desFolder._id, doc.name, content.lines, null, (err, newDoc)-> if err? logger.err err:err, "error copying doc" return callback(err) diff --git a/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee b/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee index ee55252761..d004ad0cce 100644 --- a/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee +++ b/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee @@ -149,14 +149,14 @@ module.exports = ProjectEntityHandler = else DocstoreManager.getDoc project_id, doc_id, options, callback - addDoc: (project_id, folder_id, docName, docLines, callback = (error, doc, folder_id) ->)=> + addDoc: (project_id, folder_id, docName, docLines, userId, callback = (error, doc, folder_id) ->)=> ProjectGetter.getProjectWithOnlyFolders project_id, (err, project) -> if err? logger.err project_id:project_id, err:err, "error getting project for add doc" return callback(err) - ProjectEntityHandler.addDocWithProject project, folder_id, docName, docLines, callback + ProjectEntityHandler.addDocWithProject project, folder_id, docName, docLines, userId, callback - addDocWithProject: (project, folder_id, docName, docLines, callback = (error, doc, folder_id) ->)=> + addDocWithProject: (project, folder_id, docName, docLines, userId, callback = (error, doc, folder_id) ->)=> project_id = project._id logger.log project_id: project_id, folder_id: folder_id, doc_name: docName, "adding doc to project with project" confirmFolder project, folder_id, (folder_id)=> @@ -180,7 +180,7 @@ module.exports = ProjectEntityHandler = doc: doc path: result?.path?.fileSystem docLines: docLines.join('\n') - DocumentUpdaterHandler.updateProjectStructure project_id, null, [], [newDoc], [], [], (error) -> + DocumentUpdaterHandler.updateProjectStructure project_id, userId, [], [newDoc], [], [], (error) -> return callback(error) if error? callback null, doc, folder_id diff --git a/services/web/test/unit/coffee/Editor/EditorControllerTests.coffee b/services/web/test/unit/coffee/Editor/EditorControllerTests.coffee index 7738d11b3b..cc2d1e9c69 100644 --- a/services/web/test/unit/coffee/Editor/EditorControllerTests.coffee +++ b/services/web/test/unit/coffee/Editor/EditorControllerTests.coffee @@ -131,14 +131,14 @@ describe "EditorController", -> @docLines = ["1234","dskl"] it 'should add the doc using the project entity handler', (done)-> - mock = sinon.mock(@ProjectEntityHandler).expects("addDoc").withArgs(@project_id, @folder_id, @docName, @docLines).callsArg(4) + mock = sinon.mock(@ProjectEntityHandler).expects("addDoc").withArgs(@project_id, @folder_id, @docName, @docLines).callsArg(5) @EditorController.addDocWithoutLock @project_id, @folder_id, @docName, @docLines, @source, -> mock.verify() done() it 'should send the update out to the users in the project', (done)-> - @ProjectEntityHandler.addDoc = sinon.stub().callsArgWith(4, null, @doc, @folder_id) + @ProjectEntityHandler.addDoc = sinon.stub().callsArgWith(5, null, @doc, @folder_id) @EditorController.addDocWithoutLock @project_id, @folder_id, @docName, @docLines, @source, => @EditorRealTimeController.emitToRoom @@ -147,7 +147,7 @@ describe "EditorController", -> done() it 'should return the doc to the callback', (done) -> - @ProjectEntityHandler.addDoc = sinon.stub().callsArgWith(4, null, @doc, @folder_id) + @ProjectEntityHandler.addDoc = sinon.stub().callsArgWith(5, null, @doc, @folder_id) @EditorController.addDocWithoutLock @project_id, @folder_id, @docName, @docLines, @source, (error, doc) => doc.should.equal @doc done() diff --git a/services/web/test/unit/coffee/Project/ProjectCreationHandlerTests.coffee b/services/web/test/unit/coffee/Project/ProjectCreationHandlerTests.coffee index 1b010a035c..470b538bd9 100644 --- a/services/web/test/unit/coffee/Project/ProjectCreationHandlerTests.coffee +++ b/services/web/test/unit/coffee/Project/ProjectCreationHandlerTests.coffee @@ -33,7 +33,7 @@ describe 'ProjectCreationHandler', -> constructor:(options)-> {@name} = options @ProjectEntityHandler = - addDoc: sinon.stub().callsArgWith(4, null, {_id: docId}) + addDoc: sinon.stub().callsArgWith(5, null, {_id: docId}) addFile: sinon.stub().callsArg(5) setRootDoc: sinon.stub().callsArg(2) @ProjectDetailsHandler = @@ -149,7 +149,7 @@ describe 'ProjectCreationHandler', -> .should.equal true it 'should insert main.tex', -> - @ProjectEntityHandler.addDoc.calledWith(project_id, rootFolderId, "main.tex", ["mainbasic.tex", "lines"]) + @ProjectEntityHandler.addDoc.calledWith(project_id, rootFolderId, "main.tex", ["mainbasic.tex", "lines"], ownerId) .should.equal true it 'should set the main doc id', -> @@ -180,12 +180,12 @@ describe 'ProjectCreationHandler', -> it 'should insert main.tex', -> @ProjectEntityHandler.addDoc - .calledWith(project_id, rootFolderId, "main.tex", ["main.tex", "lines"]) + .calledWith(project_id, rootFolderId, "main.tex", ["main.tex", "lines"], ownerId) .should.equal true it 'should insert references.bib', -> @ProjectEntityHandler.addDoc - .calledWith(project_id, rootFolderId, "references.bib", ["references.bib", "lines"]) + .calledWith(project_id, rootFolderId, "references.bib", ["references.bib", "lines"], ownerId) .should.equal true it 'should insert universe.jpg', -> diff --git a/services/web/test/unit/coffee/Project/ProjectDuplicatorTests.coffee b/services/web/test/unit/coffee/Project/ProjectDuplicatorTests.coffee index a1101cfbe0..ce6b8b42e3 100644 --- a/services/web/test/unit/coffee/Project/ProjectDuplicatorTests.coffee +++ b/services/web/test/unit/coffee/Project/ProjectDuplicatorTests.coffee @@ -64,7 +64,7 @@ describe 'ProjectDuplicator', -> @projectOptionsHandler = setCompiler : sinon.stub() @entityHandler = - addDocWithProject: sinon.stub().callsArgWith(4, null, {name:"somDoc"}) + addDocWithProject: sinon.stub().callsArgWith(5, null, {name:"somDoc"}) copyFileFromExistingProjectWithProject: sinon.stub().callsArgWith(4) setRootDoc: sinon.stub() addFolderWithProject: sinon.stub().callsArgWith(3, null, @newFolder) @@ -112,13 +112,13 @@ describe 'ProjectDuplicator', -> done() it 'should use the same compiler', (done)-> - @entityHandler.addDocWithProject.callsArgWith(4, null, @rootFolder.docs[0]) + @entityHandler.addDocWithProject.callsArgWith(5, null, @rootFolder.docs[0]) @duplicator.duplicate @owner, @old_project_id, "", (err, newProject)=> @projectOptionsHandler.setCompiler.calledWith(@stubbedNewProject._id, @project.compiler).should.equal true done() it 'should use the same root doc', (done)-> - @entityHandler.addDocWithProject.callsArgWith(4, null, @rootFolder.docs[0]) + @entityHandler.addDocWithProject.callsArgWith(5, null, @rootFolder.docs[0]) @duplicator.duplicate @owner, @old_project_id, "", (err, newProject)=> @entityHandler.setRootDoc.calledWith(@stubbedNewProject._id, @rootFolder.docs[0]._id).should.equal true done() diff --git a/services/web/test/unit/coffee/Project/ProjectEntityHandlerTests.coffee b/services/web/test/unit/coffee/Project/ProjectEntityHandlerTests.coffee index 22e2926934..506ff0cdb2 100644 --- a/services/web/test/unit/coffee/Project/ProjectEntityHandlerTests.coffee +++ b/services/web/test/unit/coffee/Project/ProjectEntityHandlerTests.coffee @@ -457,7 +457,7 @@ describe 'ProjectEntityHandler', -> @tpdsUpdateSender.addDoc = sinon.stub().callsArg(1) @DocstoreManager.updateDoc = sinon.stub().yields(null, true, 0) - @ProjectEntityHandler.addDoc project_id, folder_id, @name, @lines, @callback + @ProjectEntityHandler.addDoc project_id, folder_id, @name, @lines, userId, @callback # Created doc @doc = @ProjectEntityHandler._putElement.args[0][2] @@ -492,7 +492,7 @@ describe 'ProjectEntityHandler', -> path: @path docLines: @lines.join('\n') @documentUpdaterHandler.updateProjectStructure - .calledWith(project_id, null, [], [newDoc], [], []) + .calledWith(project_id, userId, [], [newDoc], [], []) .should.equal true describe "restoreDoc", -> From 7d63939f79a2b48546cc4de0cda5056e3ef45e49 Mon Sep 17 00:00:00 2001 From: Hayden Faulds Date: Thu, 23 Nov 2017 15:23:29 +0000 Subject: [PATCH 77/98] pass owner_id to ProjectEntityHandler.addDoc from ProjectDuplicator --- .../Features/Project/ProjectDuplicator.coffee | 12 ++++++------ .../coffee/Project/ProjectDuplicatorTests.coffee | 16 +++++++++++----- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/services/web/app/coffee/Features/Project/ProjectDuplicator.coffee b/services/web/app/coffee/Features/Project/ProjectDuplicator.coffee index 6f7f551bc6..730e1e2a1a 100644 --- a/services/web/app/coffee/Features/Project/ProjectDuplicator.coffee +++ b/services/web/app/coffee/Features/Project/ProjectDuplicator.coffee @@ -12,7 +12,7 @@ logger = require("logger-sharelatex") module.exports = ProjectDuplicator = - _copyDocs: (newProject, originalRootDoc, originalFolder, desFolder, docContents, callback)-> + _copyDocs: (owner_id, newProject, originalRootDoc, originalFolder, desFolder, docContents, callback)-> setRootDoc = _.once (doc_id)-> projectEntityHandler.setRootDoc newProject._id, doc_id docs = originalFolder.docs or [] @@ -21,7 +21,7 @@ module.exports = ProjectDuplicator = if !doc?._id? return callback() content = docContents[doc._id.toString()] - projectEntityHandler.addDocWithProject newProject, desFolder._id, doc.name, content.lines, null, (err, newDoc)-> + projectEntityHandler.addDocWithProject newProject, desFolder._id, doc.name, content.lines, owner_id, (err, newDoc)-> if err? logger.err err:err, "error copying doc" return callback(err) @@ -39,7 +39,7 @@ module.exports = ProjectDuplicator = async.parallelLimit jobs, 5, callback - _copyFolderRecursivly: (newProject_id, originalProject_id, originalRootDoc, originalFolder, desFolder, docContents, callback)-> + _copyFolderRecursivly: (owner_id, newProject_id, originalProject_id, originalRootDoc, originalFolder, desFolder, docContents, callback)-> ProjectGetter.getProject newProject_id, {rootFolder:true, name:true}, (err, newProject)-> if err? logger.err project_id:newProject_id, "could not get project" @@ -53,12 +53,12 @@ module.exports = ProjectDuplicator = return cb() projectEntityHandler.addFolderWithProject newProject, desFolder?._id, childFolder.name, (err, newFolder)-> return cb(err) if err? - ProjectDuplicator._copyFolderRecursivly newProject_id, originalProject_id, originalRootDoc, childFolder, newFolder, docContents, cb + ProjectDuplicator._copyFolderRecursivly owner_id, newProject_id, originalProject_id, originalRootDoc, childFolder, newFolder, docContents, cb jobs.push (cb)-> ProjectDuplicator._copyFiles newProject, originalProject_id, originalFolder, desFolder, cb jobs.push (cb)-> - ProjectDuplicator._copyDocs newProject, originalRootDoc, originalFolder, desFolder, docContents, cb + ProjectDuplicator._copyDocs owner_id, newProject, originalRootDoc, originalFolder, desFolder, docContents, cb async.series jobs, callback @@ -90,7 +90,7 @@ module.exports = ProjectDuplicator = projectOptionsHandler.setCompiler newProject._id, originalProject.compiler, -> - ProjectDuplicator._copyFolderRecursivly newProject._id, originalProject_id, originalRootDoc, originalProject.rootFolder[0], newProject.rootFolder[0], docContents, -> + ProjectDuplicator._copyFolderRecursivly owner._id, newProject._id, originalProject_id, originalRootDoc, originalProject.rootFolder[0], newProject.rootFolder[0], docContents, -> if err? logger.err err:err, originalProject_id:originalProject_id, newProjectName:newProjectName, "error cloning project" callback(err, newProject) diff --git a/services/web/test/unit/coffee/Project/ProjectDuplicatorTests.coffee b/services/web/test/unit/coffee/Project/ProjectDuplicatorTests.coffee index ce6b8b42e3..c4be897f9a 100644 --- a/services/web/test/unit/coffee/Project/ProjectDuplicatorTests.coffee +++ b/services/web/test/unit/coffee/Project/ProjectDuplicatorTests.coffee @@ -112,13 +112,13 @@ describe 'ProjectDuplicator', -> done() it 'should use the same compiler', (done)-> - @entityHandler.addDocWithProject.callsArgWith(5, null, @rootFolder.docs[0]) + @entityHandler.addDocWithProject.callsArgWith(5, null, @rootFolder.docs[0], @owner._id) @duplicator.duplicate @owner, @old_project_id, "", (err, newProject)=> @projectOptionsHandler.setCompiler.calledWith(@stubbedNewProject._id, @project.compiler).should.equal true done() it 'should use the same root doc', (done)-> - @entityHandler.addDocWithProject.callsArgWith(5, null, @rootFolder.docs[0]) + @entityHandler.addDocWithProject.callsArgWith(5, null, @rootFolder.docs[0], @owner._id) @duplicator.duplicate @owner, @old_project_id, "", (err, newProject)=> @entityHandler.setRootDoc.calledWith(@stubbedNewProject._id, @rootFolder.docs[0]._id).should.equal true done() @@ -139,9 +139,15 @@ describe 'ProjectDuplicator', -> it 'should copy all the docs', (done)-> @duplicator.duplicate @owner, @old_project_id, "", (err, newProject)=> @DocstoreManager.getAllDocs.calledWith(@old_project_id).should.equal true - @entityHandler.addDocWithProject.calledWith(@stubbedNewProject, @stubbedNewProject.rootFolder[0]._id, @doc0.name, @doc0_lines).should.equal true - @entityHandler.addDocWithProject.calledWith(@stubbedNewProject, @newFolder._id, @doc1.name, @doc1_lines).should.equal true - @entityHandler.addDocWithProject.calledWith(@stubbedNewProject, @newFolder._id, @doc2.name, @doc2_lines).should.equal true + @entityHandler.addDocWithProject + .calledWith(@stubbedNewProject, @stubbedNewProject.rootFolder[0]._id, @doc0.name, @doc0_lines, @owner._id) + .should.equal true + @entityHandler.addDocWithProject + .calledWith(@stubbedNewProject, @newFolder._id, @doc1.name, @doc1_lines, @owner._id) + .should.equal true + @entityHandler.addDocWithProject + .calledWith(@stubbedNewProject, @newFolder._id, @doc2.name, @doc2_lines, @owner._id) + .should.equal true done() it 'should copy all the files', (done)-> From e4ca3bc5ad6d50b1baa6f5d15d316b6205cacbee Mon Sep 17 00:00:00 2001 From: Hayden Faulds Date: Thu, 23 Nov 2017 15:34:24 +0000 Subject: [PATCH 78/98] pass user_id into EditorController.addDocWithoutLock --- .../coffee/Features/Editor/EditorController.coffee | 6 +++--- .../Features/Uploads/FileSystemImportManager.coffee | 4 ++-- .../unit/coffee/Editor/EditorControllerTests.coffee | 8 ++++---- .../Uploads/FileSystemImportManagerTests.coffee | 12 ++++++------ 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/services/web/app/coffee/Features/Editor/EditorController.coffee b/services/web/app/coffee/Features/Editor/EditorController.coffee index 6bafac08da..0cc1b8fa11 100644 --- a/services/web/app/coffee/Features/Editor/EditorController.coffee +++ b/services/web/app/coffee/Features/Editor/EditorController.coffee @@ -24,15 +24,15 @@ module.exports = EditorController = if err? logger.err err:err, project_id:project_id, source:source, "could not get lock to addDoc" return callback(err) - EditorController.addDocWithoutLock project_id, folder_id, docName, docLines, source, (error, doc)-> + EditorController.addDocWithoutLock project_id, folder_id, docName, docLines, source, null, (error, doc)-> LockManager.releaseLock project_id, -> callback(error, doc) - addDocWithoutLock: (project_id, folder_id, docName, docLines, source, callback = (error, doc)->)-> + addDocWithoutLock: (project_id, folder_id, docName, docLines, source, user_id, callback = (error, doc)->)-> docName = docName.trim() logger.log {project_id, folder_id, docName, source}, "sending new doc to project" Metrics.inc "editor.add-doc" - ProjectEntityHandler.addDoc project_id, folder_id, docName, docLines, null, (err, doc, folder_id)=> + ProjectEntityHandler.addDoc project_id, folder_id, docName, docLines, user_id, (err, doc, folder_id)=> if err? logger.err err:err, project_id:project_id, docName:docName, "error adding doc without lock" return callback(err) diff --git a/services/web/app/coffee/Features/Uploads/FileSystemImportManager.coffee b/services/web/app/coffee/Features/Uploads/FileSystemImportManager.coffee index a1ddc2e59b..cc52b50dc4 100644 --- a/services/web/app/coffee/Features/Uploads/FileSystemImportManager.coffee +++ b/services/web/app/coffee/Features/Uploads/FileSystemImportManager.coffee @@ -28,9 +28,9 @@ module.exports = FileSystemImportManager = if existingDoc? EditorController.setDoc project_id, existingDoc._id, user_id, lines, "upload", callback else - EditorController.addDocWithoutLock project_id, folder_id, name, lines, "upload", callback + EditorController.addDocWithoutLock project_id, folder_id, name, lines, "upload", user_id, callback else - EditorController.addDocWithoutLock project_id, folder_id, name, lines, "upload", callback + EditorController.addDocWithoutLock project_id, folder_id, name, lines, "upload", user_id, callback addFile: (user_id, project_id, folder_id, name, path, replace, callback = (error, file)-> )-> FileSystemImportManager._isSafeOnFileSystem path, (err, isSafe)-> diff --git a/services/web/test/unit/coffee/Editor/EditorControllerTests.coffee b/services/web/test/unit/coffee/Editor/EditorControllerTests.coffee index cc2d1e9c69..3f34c84b1f 100644 --- a/services/web/test/unit/coffee/Editor/EditorControllerTests.coffee +++ b/services/web/test/unit/coffee/Editor/EditorControllerTests.coffee @@ -133,14 +133,14 @@ describe "EditorController", -> it 'should add the doc using the project entity handler', (done)-> mock = sinon.mock(@ProjectEntityHandler).expects("addDoc").withArgs(@project_id, @folder_id, @docName, @docLines).callsArg(5) - @EditorController.addDocWithoutLock @project_id, @folder_id, @docName, @docLines, @source, -> + @EditorController.addDocWithoutLock @project_id, @folder_id, @docName, @docLines, @source, @user_id, -> mock.verify() done() it 'should send the update out to the users in the project', (done)-> @ProjectEntityHandler.addDoc = sinon.stub().callsArgWith(5, null, @doc, @folder_id) - @EditorController.addDocWithoutLock @project_id, @folder_id, @docName, @docLines, @source, => + @EditorController.addDocWithoutLock @project_id, @folder_id, @docName, @docLines, @source, @user_id, => @EditorRealTimeController.emitToRoom .calledWith(@project_id, "reciveNewDoc", @folder_id, @doc, @source) .should.equal true @@ -148,7 +148,7 @@ describe "EditorController", -> it 'should return the doc to the callback', (done) -> @ProjectEntityHandler.addDoc = sinon.stub().callsArgWith(5, null, @doc, @folder_id) - @EditorController.addDocWithoutLock @project_id, @folder_id, @docName, @docLines, @source, (error, doc) => + @EditorController.addDocWithoutLock @project_id, @folder_id, @docName, @docLines, @source, @user_id, (error, doc) => doc.should.equal @doc done() @@ -157,7 +157,7 @@ describe "EditorController", -> beforeEach -> @LockManager.getLock.callsArgWith(1) @LockManager.releaseLock.callsArgWith(1) - @EditorController.addDocWithoutLock = sinon.stub().callsArgWith(5) + @EditorController.addDocWithoutLock = sinon.stub().callsArgWith(6) it "should call addDocWithoutLock", (done)-> @EditorController.addDoc @project_id, @folder_id, @docName, @docLines, @source, => diff --git a/services/web/test/unit/coffee/Uploads/FileSystemImportManagerTests.coffee b/services/web/test/unit/coffee/Uploads/FileSystemImportManagerTests.coffee index 032b60b261..cbb4fbfa92 100644 --- a/services/web/test/unit/coffee/Uploads/FileSystemImportManagerTests.coffee +++ b/services/web/test/unit/coffee/Uploads/FileSystemImportManagerTests.coffee @@ -44,14 +44,14 @@ describe "FileSystemImportManager", -> describe "with replace set to false", -> beforeEach -> - @EditorController.addDocWithoutLock = sinon.stub().callsArg(5) + @EditorController.addDocWithoutLock = sinon.stub().callsArg(6) @FileSystemImportManager.addDoc @user_id, @project_id, @folder_id, @name, @path_on_disk, false, @callback it "should read the file from disk", -> @fs.readFile.calledWith(@path_on_disk, "utf8").should.equal true it "should insert the doc", -> - @EditorController.addDocWithoutLock.calledWith(@project_id, @folder_id, @name, @docLines, "upload") + @EditorController.addDocWithoutLock.calledWith(@project_id, @folder_id, @name, @docLines, "upload", @user_id) .should.equal true describe "with windows line ending", -> @@ -59,11 +59,11 @@ describe "FileSystemImportManager", -> @docContent = "one\r\ntwo\r\nthree" @docLines = ["one", "two", "three"] @fs.readFile = sinon.stub().callsArgWith(2, null, @docContent) - @EditorController.addDocWithoutLock = sinon.stub().callsArg(5) + @EditorController.addDocWithoutLock = sinon.stub().callsArg(6) @FileSystemImportManager.addDoc @user_id, @project_id, @folder_id, @name, @path_on_disk, false, @callback it "should strip the \\r characters before adding", -> - @EditorController.addDocWithoutLock.calledWith(@project_id, @folder_id, @name, @docLines, "upload") + @EditorController.addDocWithoutLock.calledWith(@project_id, @folder_id, @name, @docLines, "upload", @user_id) .should.equal true describe "with replace set to true", -> @@ -76,7 +76,7 @@ describe "FileSystemImportManager", -> }] } @ProjectLocator.findElement = sinon.stub().callsArgWith(1, null, @folder) - @EditorController.addDocWithoutLock = sinon.stub().callsArg(5) + @EditorController.addDocWithoutLock = sinon.stub().callsArg(6) @FileSystemImportManager.addDoc @user_id, @project_id, @folder_id, @name, @path_on_disk, true, @callback it "should look up the folder", -> @@ -85,7 +85,7 @@ describe "FileSystemImportManager", -> .should.equal true it "should insert the doc", -> - @EditorController.addDocWithoutLock.calledWith(@project_id, @folder_id, @name, @docLines, "upload") + @EditorController.addDocWithoutLock.calledWith(@project_id, @folder_id, @name, @docLines, "upload", @user_id) .should.equal true describe "when the doc does exist", -> From 980e43e47804b51cfce4a3110f6ce99226c56e8c Mon Sep 17 00:00:00 2001 From: Hayden Faulds Date: Thu, 23 Nov 2017 15:40:14 +0000 Subject: [PATCH 79/98] pass user_id into EditorController.addDoc --- .../Features/Editor/EditorController.coffee | 4 ++-- .../Features/Editor/EditorHttpController.coffee | 5 +++-- .../coffee/Editor/EditorControllerTests.coffee | 15 ++++++--------- .../Editor/EditorHttpControllerTests.coffee | 4 ++-- 4 files changed, 13 insertions(+), 15 deletions(-) diff --git a/services/web/app/coffee/Features/Editor/EditorController.coffee b/services/web/app/coffee/Features/Editor/EditorController.coffee index 0cc1b8fa11..79191cd91e 100644 --- a/services/web/app/coffee/Features/Editor/EditorController.coffee +++ b/services/web/app/coffee/Features/Editor/EditorController.coffee @@ -19,12 +19,12 @@ module.exports = EditorController = DocumentUpdaterHandler.flushDocToMongo project_id, doc_id, callback - addDoc: (project_id, folder_id, docName, docLines, source, callback = (error, doc)->)-> + addDoc: (project_id, folder_id, docName, docLines, source, user_id, callback = (error, doc)->)-> LockManager.getLock project_id, (err)-> if err? logger.err err:err, project_id:project_id, source:source, "could not get lock to addDoc" return callback(err) - EditorController.addDocWithoutLock project_id, folder_id, docName, docLines, source, null, (error, doc)-> + EditorController.addDocWithoutLock project_id, folder_id, docName, docLines, source, user_id, (error, doc)-> LockManager.releaseLock project_id, -> callback(error, doc) diff --git a/services/web/app/coffee/Features/Editor/EditorHttpController.coffee b/services/web/app/coffee/Features/Editor/EditorHttpController.coffee index e9bac7300d..cbc296b69f 100644 --- a/services/web/app/coffee/Features/Editor/EditorHttpController.coffee +++ b/services/web/app/coffee/Features/Editor/EditorHttpController.coffee @@ -81,10 +81,11 @@ module.exports = EditorHttpController = project_id = req.params.Project_id name = req.body.name parent_folder_id = req.body.parent_folder_id + user_id = AuthenticationController.getLoggedInUserId(req) logger.log project_id:project_id, name:name, parent_folder_id:parent_folder_id, "getting request to add doc to project" if !EditorHttpController._nameIsAcceptableLength(name) return res.sendStatus 400 - EditorController.addDoc project_id, parent_folder_id, name, [], "editor", (error, doc) -> + EditorController.addDoc project_id, parent_folder_id, name, [], "editor", user_id, (error, doc) -> if error == "project_has_to_many_files" res.status(400).json(req.i18n.translate("project_has_to_many_files")) else if error? @@ -113,9 +114,9 @@ module.exports = EditorHttpController = entity_id = req.params.entity_id entity_type = req.params.entity_type name = req.body.name - user_id = AuthenticationController.getLoggedInUserId(req) if !EditorHttpController._nameIsAcceptableLength(name) return res.sendStatus 400 + user_id = AuthenticationController.getLoggedInUserId(req) EditorController.renameEntity project_id, entity_id, entity_type, name, user_id, (error) -> return next(error) if error? res.sendStatus 204 diff --git a/services/web/test/unit/coffee/Editor/EditorControllerTests.coffee b/services/web/test/unit/coffee/Editor/EditorControllerTests.coffee index 3f34c84b1f..3b702f2b78 100644 --- a/services/web/test/unit/coffee/Editor/EditorControllerTests.coffee +++ b/services/web/test/unit/coffee/Editor/EditorControllerTests.coffee @@ -160,29 +160,26 @@ describe "EditorController", -> @EditorController.addDocWithoutLock = sinon.stub().callsArgWith(6) it "should call addDocWithoutLock", (done)-> - @EditorController.addDoc @project_id, @folder_id, @docName, @docLines, @source, => - @EditorController.addDocWithoutLock.calledWith(@project_id, @folder_id, @docName, @docLines, @source).should.equal true + @EditorController.addDoc @project_id, @folder_id, @docName, @docLines, @source, @user_id, => + @EditorController.addDocWithoutLock.calledWith(@project_id, @folder_id, @docName, @docLines, @source, @user_id).should.equal true done() it "should take the lock", (done)-> - @EditorController.addDoc @project_id, @folder_id, @docName, @docLines, @source, => + @EditorController.addDoc @project_id, @folder_id, @docName, @docLines, @source, @user_id, => @LockManager.getLock.calledWith(@project_id).should.equal true done() it "should release the lock", (done)-> - @EditorController.addDoc @project_id, @folder_id, @docName, @docLines, @source, => + @EditorController.addDoc @project_id, @folder_id, @docName, @docLines, @source, @user_id, => @LockManager.releaseLock.calledWith(@project_id).should.equal true done() it "should error if it can't cat the lock", (done)-> @LockManager.getLock = sinon.stub().callsArgWith(1, "timed out") - @EditorController.addDoc @project_id, @folder_id, @docName, @docLines, @source, (err)=> + @EditorController.addDoc @project_id, @folder_id, @docName, @docLines, @source, @user_id, (err)=> expect(err).to.exist err.should.equal "timed out" - done() - - - + done() describe 'addFileWithoutLock:', -> beforeEach -> diff --git a/services/web/test/unit/coffee/Editor/EditorHttpControllerTests.coffee b/services/web/test/unit/coffee/Editor/EditorHttpControllerTests.coffee index 2df4af72ec..e05846c5d5 100644 --- a/services/web/test/unit/coffee/Editor/EditorHttpControllerTests.coffee +++ b/services/web/test/unit/coffee/Editor/EditorHttpControllerTests.coffee @@ -201,7 +201,7 @@ describe "EditorHttpController", -> @req.body = name: @name = "doc-name" parent_folder_id: @parent_folder_id - @EditorController.addDoc = sinon.stub().callsArgWith(5, null, @doc) + @EditorController.addDoc = sinon.stub().callsArgWith(6, null, @doc) describe "successfully", -> beforeEach -> @@ -209,7 +209,7 @@ describe "EditorHttpController", -> it "should call EditorController.addDoc", -> @EditorController.addDoc - .calledWith(@project_id, @parent_folder_id, @name, [], "editor") + .calledWith(@project_id, @parent_folder_id, @name, [], "editor", @userId) .should.equal true it "should send the doc back as JSON", -> From 90e3b71c1c458852ddef4509aa2909bd247b1cb2 Mon Sep 17 00:00:00 2001 From: Hayden Faulds Date: Thu, 23 Nov 2017 16:40:15 +0000 Subject: [PATCH 80/98] version replacing files --- .../Project/ProjectEntityHandler.coffee | 37 ++++++++++--------- .../Project/ProjectEntityHandlerTests.coffee | 27 +++++++++++--- 2 files changed, 40 insertions(+), 24 deletions(-) diff --git a/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee b/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee index d004ad0cce..57e319afcc 100644 --- a/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee +++ b/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee @@ -223,12 +223,9 @@ module.exports = ProjectEntityHandler = callback null, fileRef, folder_id replaceFile: (project_id, file_id, fsPath, callback)-> + self = ProjectEntityHandler ProjectGetter.getProject project_id, {name:true}, (err, project) -> return callback(err) if err? - findOpts = - project_id:project._id - element_id:file_id - type:"file" FileStoreHandler.uploadFileFromDisk project._id, file_id, fsPath, (err)-> return callback(err) if err? # Note there is a potential race condition here (and elsewhere) @@ -236,20 +233,25 @@ module.exports = ProjectEntityHandler = # then the path to the file element will be out of date. In practice # this is not a problem so long as we do not do anything longer running # between them (like waiting for the file to upload.) - projectLocator.findElement findOpts, (err, fileRef, path)=> + self.getAllEntitiesFromProject project, (err, oldDocs, oldFiles) => return callback(err) if err? - tpdsUpdateSender.addFile {project_id:project._id, file_id:fileRef._id, path:path.fileSystem, rev:fileRef.rev+1, project_name:project.name}, (error) -> + projectLocator.findElement {project:project, element_id: file_id, type: 'file'}, (err, fileRef, path)=> return callback(err) if err? - conditons = _id:project._id - inc = {} - inc["#{path.mongo}.rev"] = 1 - set = {} - set["#{path.mongo}.created"] = new Date() - update = - "$inc": inc - "$set": set - Project.update conditons, update, {}, (err, second)-> - callback() + tpdsUpdateSender.addFile {project_id:project._id, file_id:fileRef._id, path:path.fileSystem, rev:fileRef.rev+1, project_name:project.name}, (err) -> + return callback(err) if err? + conditions = _id:project._id + inc = {} + inc["#{path.mongo}.rev"] = 1 + set = {} + set["#{path.mongo}.created"] = new Date() + update = + "$inc": inc + "$set": set + Project.findOneAndUpdate conditions, update, { "new": true}, (err, newProject) -> + return callback(err) if err? + self.getAllEntitiesFromProject newProject, (err, newDocs, newFiles) => + return callback(err) if err? + DocumentUpdaterHandler.updateProjectStructure project_id, null, oldDocs, newDocs, oldFiles, newFiles, callback copyFileFromExistingProject: (project_id, folder_id, originalProject_id, origonalFileRef, callback = (error, fileRef, folder_id) ->)-> logger.log project_id:project_id, folder_id:folder_id, originalProject_id:originalProject_id, origonalFileRef:origonalFileRef, "copying file in s3" @@ -392,8 +394,7 @@ module.exports = ProjectEntityHandler = endPath: result.path.fileSystem, rev: entity.rev tpdsUpdateSender.moveEntity opts - self.getAllEntitiesFromProject newProject, (error, newDocs, newFiles - ) => + self.getAllEntitiesFromProject newProject, (error, newDocs, newFiles) => return callback(error) if error? DocumentUpdaterHandler.updateProjectStructure project_id, userId, oldDocs, newDocs, oldFiles, newFiles, callback diff --git a/services/web/test/unit/coffee/Project/ProjectEntityHandlerTests.coffee b/services/web/test/unit/coffee/Project/ProjectEntityHandlerTests.coffee index 506ff0cdb2..1b629f2257 100644 --- a/services/web/test/unit/coffee/Project/ProjectEntityHandlerTests.coffee +++ b/services/web/test/unit/coffee/Project/ProjectEntityHandlerTests.coffee @@ -583,7 +583,6 @@ describe 'ProjectEntityHandler', -> @ProjectEntityHandler.addFile project_id, folder_id, fileName, {}, userId, () -> describe 'replacing a file', -> - beforeEach -> @projectLocator @file_id = "file_id_here" @@ -591,13 +590,23 @@ describe 'ProjectEntityHandler', -> @fileRef = {rev:3, _id:@file_id} @filePaths = {fileSystem:"/folder1/file.png", mongo:"folder.1.files.somewhere"} @projectLocator.findElement = sinon.stub().callsArgWith(1, null, @fileRef, @filePaths) - @ProjectModel.update = (_, __, ___, cb)-> cb() + + @ProjectEntityHandler.getAllEntitiesFromProject = sinon.stub() + @ProjectEntityHandler.getAllEntitiesFromProject + .onFirstCall() + .callsArgWith(1, null, [], @oldFiles = ['old-file']) + @ProjectEntityHandler.getAllEntitiesFromProject + .onSecondCall() + .callsArgWith(1, null, [], @newFiles = ['new-file']) + @ProjectModel.findOneAndUpdate = sinon.stub().callsArgWith(3, null, @project) + @ProjectGetter.getProject = sinon.stub().callsArgWith(2, null, @project) it 'should find the file', (done)-> - @ProjectEntityHandler.replaceFile project_id, @file_id, @fsPath, => - @projectLocator.findElement.calledWith({element_id:@file_id, type:"file", project_id:project_id}).should.equal true + @projectLocator.findElement + .calledWith({element_id:@file_id, type:"file", project: @project}) + .should.equal true done() it 'should tell the file store handler to upload the file from disk', (done)-> @@ -617,7 +626,7 @@ describe 'ProjectEntityHandler', -> @ProjectEntityHandler.replaceFile project_id, @file_id, @fsPath, => it 'should inc the rev id', (done)-> - @ProjectModel.update = (conditions, update, options, callback)=> + @ProjectModel.findOneAndUpdate = (conditions, update, options, callback)=> conditions._id.should.equal project_id update.$inc["#{@filePaths.mongo}.rev"].should.equal 1 done() @@ -626,7 +635,7 @@ describe 'ProjectEntityHandler', -> it 'should update the created at date', (done)-> d = new Date() - @ProjectModel.update = (conditions, update, options, callback)=> + @ProjectModel.findOneAndUpdate = (conditions, update, options, callback)=> conditions._id.should.equal project_id differenceInMs = update.$set["#{@filePaths.mongo}.created"].getTime() - d.getTime() differenceInMs.should.be.below(20) @@ -634,6 +643,12 @@ describe 'ProjectEntityHandler', -> @ProjectEntityHandler.replaceFile project_id, @file_id, @fsPath, => + it "should should send the old and new project structure to the doc updater", (done) -> + @ProjectEntityHandler.replaceFile project_id, @file_id, @fsPath, => + @documentUpdaterHandler.updateProjectStructure + .calledWith(project_id, null, [], [], @oldFiles, @newFiles) + .should.equal true + done() describe 'addFolder', -> folderName = "folder1234" From 4ec0d07aae17abe7b1800fb0bdae4e04459c54db Mon Sep 17 00:00:00 2001 From: Hayden Faulds Date: Fri, 24 Nov 2017 10:39:06 +0000 Subject: [PATCH 81/98] remove unused ProjectEntityHandler.copyFileFromExistingProject --- .../Features/Project/ProjectEntityHandler.coffee | 9 --------- .../Project/ProjectEntityHandlerTests.coffee | 16 +++++++--------- 2 files changed, 7 insertions(+), 18 deletions(-) diff --git a/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee b/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee index 57e319afcc..d06ca0714a 100644 --- a/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee +++ b/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee @@ -253,15 +253,6 @@ module.exports = ProjectEntityHandler = return callback(err) if err? DocumentUpdaterHandler.updateProjectStructure project_id, null, oldDocs, newDocs, oldFiles, newFiles, callback - copyFileFromExistingProject: (project_id, folder_id, originalProject_id, origonalFileRef, callback = (error, fileRef, folder_id) ->)-> - logger.log project_id:project_id, folder_id:folder_id, originalProject_id:originalProject_id, origonalFileRef:origonalFileRef, "copying file in s3" - ProjectGetter.getProject project_id, {name:true}, (err, project) -> - if err? - logger.err project_id:project_id, err:err, "error getting project for copy file from existing project" - return callback(err) - ProjectEntityHandler.copyFileFromExistingProjectWithProject project, folder_id, originalProject_id, origonalFileRef, callback - - copyFileFromExistingProjectWithProject: (project, folder_id, originalProject_id, origonalFileRef, callback = (error, fileRef, folder_id) ->)-> project_id = project._id logger.log project_id:project_id, folder_id:folder_id, originalProject_id:originalProject_id, origonalFileRef:origonalFileRef, "copying file in s3 with project" diff --git a/services/web/test/unit/coffee/Project/ProjectEntityHandlerTests.coffee b/services/web/test/unit/coffee/Project/ProjectEntityHandlerTests.coffee index 1b629f2257..73a2b83090 100644 --- a/services/web/test/unit/coffee/Project/ProjectEntityHandlerTests.coffee +++ b/services/web/test/unit/coffee/Project/ProjectEntityHandlerTests.coffee @@ -982,19 +982,18 @@ describe 'ProjectEntityHandler', -> @ProjectModel.update.calledWith({_id : @project_id}, {$unset : {rootDoc_id: true}}) .should.equal true - describe 'copyFileFromExistingProject', -> + describe 'copyFileFromExistingProjectWithProject', -> fileName = "something.jpg" filePath = "dumpFolder/somewhere/image.jpeg" oldProject_id = "123kljadas" oldFileRef = {name:fileName, _id:"oldFileRef"} - beforeEach -> - @ProjectGetter.getProject = (project_id, fields, callback)=> callback(null, {name:@project.name, _id:@project._id}) - @ProjectEntityHandler._putElement = sinon.stub().callsArgWith(4, null, {path:{fileSystem:"somehintg"}}) + beforeEach -> + @ProjectEntityHandler._putElement = sinon.stub().callsArgWith(4, null, {path:{fileSystem:"somehintg"}}) it 'should copy the file in FileStoreHandler', (done)-> @ProjectEntityHandler._putElement = sinon.stub().callsArgWith(4, null, {path:{fileSystem:"somehintg"}}) - @ProjectEntityHandler.copyFileFromExistingProject project_id, folder_id, oldProject_id, oldFileRef, (err, fileRef, parentFolder)=> + @ProjectEntityHandler.copyFileFromExistingProjectWithProject @project, folder_id, oldProject_id, oldFileRef, (err, fileRef, parentFolder)=> @FileStoreHandler.copyFile.calledWith(oldProject_id, oldFileRef._id, project_id, fileRef._id).should.equal true done() @@ -1006,10 +1005,10 @@ describe 'ProjectEntityHandler', -> passedType.should.equal 'file' done() - @ProjectEntityHandler.copyFileFromExistingProject project_id, folder_id, oldProject_id, oldFileRef, (err, fileRef, parentFolder)-> + @ProjectEntityHandler.copyFileFromExistingProjectWithProject @project, folder_id, oldProject_id, oldFileRef, (err, fileRef, parentFolder)-> it 'should return doc and parent folder', (done)-> - @ProjectEntityHandler.copyFileFromExistingProject project_id, folder_id, oldProject_id, oldFileRef, (err, fileRef, parentFolder)-> + @ProjectEntityHandler.copyFileFromExistingProjectWithProject @project, folder_id, oldProject_id, oldFileRef, (err, fileRef, parentFolder)-> parentFolder.should.equal folder_id fileRef.name.should.equal fileName done() @@ -1029,8 +1028,7 @@ describe 'ProjectEntityHandler', -> options.rev.should.equal 0 done() - @ProjectEntityHandler.copyFileFromExistingProject project_id, folder_id, oldProject_id, oldFileRef, (err, fileRef, parentFolder)-> - + @ProjectEntityHandler.copyFileFromExistingProjectWithProject @project, folder_id, oldProject_id, oldFileRef, (err, fileRef, parentFolder)-> describe "renameEntity", -> beforeEach -> From 60ee78704a0f60e29e4ea2efd1e62188895eda62 Mon Sep 17 00:00:00 2001 From: Hayden Faulds Date: Fri, 24 Nov 2017 11:13:44 +0000 Subject: [PATCH 82/98] return url from FileStoreHandler.copyFile --- .../app/coffee/Features/FileStore/FileStoreHandler.coffee | 2 +- .../test/unit/coffee/FileStore/FileStoreHandlerTests.coffee | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/services/web/app/coffee/Features/FileStore/FileStoreHandler.coffee b/services/web/app/coffee/Features/FileStore/FileStoreHandler.coffee index 88f55bc698..ef363fbbc7 100644 --- a/services/web/app/coffee/Features/FileStore/FileStoreHandler.coffee +++ b/services/web/app/coffee/Features/FileStore/FileStoreHandler.coffee @@ -92,7 +92,7 @@ module.exports = FileStoreHandler = request opts, (err)-> if err? logger.err err:err, oldProject_id:oldProject_id, oldFile_id:oldFile_id, newProject_id:newProject_id, newFile_id:newFile_id, "something went wrong telling filestore api to copy file" - callback(err) + callback(err, opts.uri) _buildUrl: (project_id, file_id)-> return "#{settings.apis.filestore.url}/project/#{project_id}/file/#{file_id}" diff --git a/services/web/test/unit/coffee/FileStore/FileStoreHandlerTests.coffee b/services/web/test/unit/coffee/FileStore/FileStoreHandlerTests.coffee index ea7c7a4cbb..90a3e870d1 100644 --- a/services/web/test/unit/coffee/FileStore/FileStoreHandlerTests.coffee +++ b/services/web/test/unit/coffee/FileStore/FileStoreHandlerTests.coffee @@ -219,6 +219,11 @@ describe "FileStoreHandler", -> @handler._buildUrl.calledWith(@newProject_id, @newFile_id).should.equal true done() + it "returns the url", (done)-> + @request.callsArgWith(1, null) + @handler.copyFile @project_id, @file_id, @newProject_id, @newFile_id, (err, url) => + url.should.equal "http://filestore.stubbedBuilder.com" + done() it "should return the err", (done)-> error = "errrror" From 088ce33cbc4cebbb6f276a0745fb12e23b9fa2fa Mon Sep 17 00:00:00 2001 From: Hayden Faulds Date: Fri, 24 Nov 2017 11:14:15 +0000 Subject: [PATCH 83/98] version ProjectEntityHandler.copyFileFromExistingProjectWithProject --- .../Project/ProjectEntityHandler.coffee | 22 ++++++++++------- .../Project/ProjectEntityHandlerTests.coffee | 24 +++++++++++++++---- 2 files changed, 33 insertions(+), 13 deletions(-) diff --git a/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee b/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee index d06ca0714a..2e3f67b760 100644 --- a/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee +++ b/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee @@ -255,25 +255,31 @@ module.exports = ProjectEntityHandler = copyFileFromExistingProjectWithProject: (project, folder_id, originalProject_id, origonalFileRef, callback = (error, fileRef, folder_id) ->)-> project_id = project._id - logger.log project_id:project_id, folder_id:folder_id, originalProject_id:originalProject_id, origonalFileRef:origonalFileRef, "copying file in s3 with project" + logger.log { project_id, folder_id, originalProject_id, origonalFileRef }, "copying file in s3 with project" return callback(err) if err? confirmFolder project, folder_id, (folder_id)=> if !origonalFileRef? - logger.err project_id:project._id, folder_id:folder_id, originalProject_id:originalProject_id, origonalFileRef:origonalFileRef, "file trying to copy is null" + logger.err { project_id, folder_id, originalProject_id, origonalFileRef }, "file trying to copy is null" return callback() fileRef = new File name : origonalFileRef.name - FileStoreHandler.copyFile originalProject_id, origonalFileRef._id, project._id, fileRef._id, (err)-> + FileStoreHandler.copyFile originalProject_id, origonalFileRef._id, project._id, fileRef._id, (err, fileStoreUrl)-> if err? - logger.err err:err, project_id:project._id, folder_id:folder_id, originalProject_id:originalProject_id, origonalFileRef:origonalFileRef, "error coping file in s3" + logger.err { err, project_id, folder_id, originalProject_id, origonalFileRef }, "error coping file in s3" return callback(err) ProjectEntityHandler._putElement project, folder_id, fileRef, "file", (err, result)=> if err? - logger.err err:err, project_id:project._id, folder_id:folder_id, "error putting element as part of copy" + logger.err { err, project_id, folder_id }, "error putting element as part of copy" return callback(err) - tpdsUpdateSender.addFile {project_id:project._id, file_id:fileRef._id, path:result?.path?.fileSystem, rev:fileRef.rev, project_name:project.name}, (err) -> + tpdsUpdateSender.addFile { project_id, file_id:fileRef._id, path:result?.path?.fileSystem, rev:fileRef.rev, project_name:project.name}, (err) -> if err? - logger.err err:err, project_id:project._id, folder_id:folder_id, originalProject_id:originalProject_id, origonalFileRef:origonalFileRef, "error sending file to tpds worker" - callback(null, fileRef, folder_id) + logger.err { err, project_id, folder_id, originalProject_id, origonalFileRef }, "error sending file to tpds worker" + newFile = + file: fileRef + path: result?.path?.fileSystem + url: fileStoreUrl + DocumentUpdaterHandler.updateProjectStructure project_id, null, [], [], [], [newFile], (error) -> + return callback(error) if error? + callback null, fileRef, folder_id mkdirp: (project_id, path, callback = (err, newlyCreatedFolders, lastFolderInPath)->)-> self = @ diff --git a/services/web/test/unit/coffee/Project/ProjectEntityHandlerTests.coffee b/services/web/test/unit/coffee/Project/ProjectEntityHandlerTests.coffee index 73a2b83090..ff953b676c 100644 --- a/services/web/test/unit/coffee/Project/ProjectEntityHandlerTests.coffee +++ b/services/web/test/unit/coffee/Project/ProjectEntityHandlerTests.coffee @@ -20,7 +20,7 @@ describe 'ProjectEntityHandler', -> @fileUrl = 'filestore.example.com/file' @FileStoreHandler = uploadFileFromDisk: sinon.stub().callsArgWith(3, null, @fileUrl) - copyFile: sinon.stub().callsArgWith(4, null) + copyFile: sinon.stub().callsArgWith(4, null, @fileUrl) @tpdsUpdateSender = addDoc:sinon.stub().callsArg(1) addFile:sinon.stub().callsArg(1) @@ -570,9 +570,9 @@ describe 'ProjectEntityHandler', -> @ProjectEntityHandler.addFile project_id, folder_id, fileName, {}, userId, (err, fileRef, parentFolder)-> it "should should send the change in project structure to the doc updater", (done) -> - @documentUpdaterHandler.updateProjectStructure = (project_id, user_id, oldDocs, newDocs, oldFiles, newFiles) => - project_id.should.equal project_id - user_id.should.equal user_id + @documentUpdaterHandler.updateProjectStructure = (passed_project_id, passed_user_id, oldDocs, newDocs, oldFiles, newFiles) => + passed_project_id.should.equal project_id + passed_user_id.should.equal userId newFiles.length.should.equal 1 newFile = newFiles[0] newFile.file.name.should.equal fileName @@ -989,7 +989,8 @@ describe 'ProjectEntityHandler', -> oldFileRef = {name:fileName, _id:"oldFileRef"} beforeEach -> - @ProjectEntityHandler._putElement = sinon.stub().callsArgWith(4, null, {path:{fileSystem:"somehintg"}}) + @fileSystemPath = "somehintg" + @ProjectEntityHandler._putElement = sinon.stub().callsArgWith(4, null, {path:{fileSystem: @fileSystemPath}}) it 'should copy the file in FileStoreHandler', (done)-> @ProjectEntityHandler._putElement = sinon.stub().callsArgWith(4, null, {path:{fileSystem:"somehintg"}}) @@ -1030,6 +1031,19 @@ describe 'ProjectEntityHandler', -> @ProjectEntityHandler.copyFileFromExistingProjectWithProject @project, folder_id, oldProject_id, oldFileRef, (err, fileRef, parentFolder)-> + it "should should send the change in project structure to the doc updater", (done) -> + @documentUpdaterHandler.updateProjectStructure = (passed_project_id, passed_user_id, oldDocs, newDocs, oldFiles, newFiles) => + passed_project_id.should.equal project_id + #passed_user_id.should.equal userId + newFiles.length.should.equal 1 + newFile = newFiles[0] + newFile.file.name.should.equal fileName + newFile.path.should.equal @fileSystemPath + newFile.url.should.equal @fileUrl + done() + + @ProjectEntityHandler.copyFileFromExistingProjectWithProject @project, folder_id, oldProject_id, oldFileRef, (err, fileRef, parentFolder)-> + describe "renameEntity", -> beforeEach -> @entity_id = "4eecaffcbffa66588e000009" From 08891c7a7f534b70918b8265db28400318a32b6d Mon Sep 17 00:00:00 2001 From: Hayden Faulds Date: Fri, 24 Nov 2017 11:26:43 +0000 Subject: [PATCH 84/98] pass userId into ProjectEntityHandler.replaceFile --- .../coffee/Features/Editor/EditorController.coffee | 2 +- .../Features/Project/ProjectEntityHandler.coffee | 4 ++-- .../coffee/Editor/EditorControllerTests.coffee | 2 +- .../Project/ProjectEntityHandlerTests.coffee | 14 +++++++------- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/services/web/app/coffee/Features/Editor/EditorController.coffee b/services/web/app/coffee/Features/Editor/EditorController.coffee index 79191cd91e..f0530edd7f 100644 --- a/services/web/app/coffee/Features/Editor/EditorController.coffee +++ b/services/web/app/coffee/Features/Editor/EditorController.coffee @@ -60,7 +60,7 @@ module.exports = EditorController = callback(err, fileRef) replaceFile: (project_id, file_id, fsPath, source, callback = (error) ->)-> - ProjectEntityHandler.replaceFile project_id, file_id, fsPath, callback + ProjectEntityHandler.replaceFile project_id, file_id, fsPath, null, callback addFolder : (project_id, folder_id, folderName, source, callback = (error, folder)->)-> LockManager.getLock project_id, (err)-> diff --git a/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee b/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee index 2e3f67b760..42f70b0a7f 100644 --- a/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee +++ b/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee @@ -222,7 +222,7 @@ module.exports = ProjectEntityHandler = return callback(error) if error? callback null, fileRef, folder_id - replaceFile: (project_id, file_id, fsPath, callback)-> + replaceFile: (project_id, file_id, fsPath, userId, callback)-> self = ProjectEntityHandler ProjectGetter.getProject project_id, {name:true}, (err, project) -> return callback(err) if err? @@ -251,7 +251,7 @@ module.exports = ProjectEntityHandler = return callback(err) if err? self.getAllEntitiesFromProject newProject, (err, newDocs, newFiles) => return callback(err) if err? - DocumentUpdaterHandler.updateProjectStructure project_id, null, oldDocs, newDocs, oldFiles, newFiles, callback + DocumentUpdaterHandler.updateProjectStructure project_id, userId, oldDocs, newDocs, oldFiles, newFiles, callback copyFileFromExistingProjectWithProject: (project, folder_id, originalProject_id, origonalFileRef, callback = (error, fileRef, folder_id) ->)-> project_id = project._id diff --git a/services/web/test/unit/coffee/Editor/EditorControllerTests.coffee b/services/web/test/unit/coffee/Editor/EditorControllerTests.coffee index 3b702f2b78..63d44a8c20 100644 --- a/services/web/test/unit/coffee/Editor/EditorControllerTests.coffee +++ b/services/web/test/unit/coffee/Editor/EditorControllerTests.coffee @@ -253,7 +253,7 @@ describe "EditorController", -> @fsPath = "/folder/file.png" it 'should send the replace file message to the editor controller', (done)-> - @ProjectEntityHandler.replaceFile = sinon.stub().callsArgWith(3) + @ProjectEntityHandler.replaceFile = sinon.stub().callsArgWith(4) @EditorController.replaceFile @project_id, @file_id, @fsPath, @source, => @ProjectEntityHandler.replaceFile.calledWith(@project_id, @file_id, @fsPath).should.equal true done() diff --git a/services/web/test/unit/coffee/Project/ProjectEntityHandlerTests.coffee b/services/web/test/unit/coffee/Project/ProjectEntityHandlerTests.coffee index ff953b676c..1eef82cbc5 100644 --- a/services/web/test/unit/coffee/Project/ProjectEntityHandlerTests.coffee +++ b/services/web/test/unit/coffee/Project/ProjectEntityHandlerTests.coffee @@ -603,14 +603,14 @@ describe 'ProjectEntityHandler', -> @ProjectGetter.getProject = sinon.stub().callsArgWith(2, null, @project) it 'should find the file', (done)-> - @ProjectEntityHandler.replaceFile project_id, @file_id, @fsPath, => + @ProjectEntityHandler.replaceFile project_id, @file_id, @fsPath, userId, => @projectLocator.findElement .calledWith({element_id:@file_id, type:"file", project: @project}) .should.equal true done() it 'should tell the file store handler to upload the file from disk', (done)-> - @ProjectEntityHandler.replaceFile project_id, @file_id, @fsPath, => + @ProjectEntityHandler.replaceFile project_id, @file_id, @fsPath, userId, => @FileStoreHandler.uploadFileFromDisk.calledWith(project_id, @file_id, @fsPath).should.equal true done() @@ -623,7 +623,7 @@ describe 'ProjectEntityHandler', -> options.rev.should.equal @fileRef.rev + 1 done() - @ProjectEntityHandler.replaceFile project_id, @file_id, @fsPath, => + @ProjectEntityHandler.replaceFile project_id, @file_id, @fsPath, userId, => it 'should inc the rev id', (done)-> @ProjectModel.findOneAndUpdate = (conditions, update, options, callback)=> @@ -631,7 +631,7 @@ describe 'ProjectEntityHandler', -> update.$inc["#{@filePaths.mongo}.rev"].should.equal 1 done() - @ProjectEntityHandler.replaceFile project_id, @file_id, @fsPath, => + @ProjectEntityHandler.replaceFile project_id, @file_id, @fsPath, userId, => it 'should update the created at date', (done)-> d = new Date() @@ -641,12 +641,12 @@ describe 'ProjectEntityHandler', -> differenceInMs.should.be.below(20) done() - @ProjectEntityHandler.replaceFile project_id, @file_id, @fsPath, => + @ProjectEntityHandler.replaceFile project_id, @file_id, @fsPath, userId, => it "should should send the old and new project structure to the doc updater", (done) -> - @ProjectEntityHandler.replaceFile project_id, @file_id, @fsPath, => + @ProjectEntityHandler.replaceFile project_id, @file_id, @fsPath, userId, => @documentUpdaterHandler.updateProjectStructure - .calledWith(project_id, null, [], [], @oldFiles, @newFiles) + .calledWith(project_id, userId, [], [], @oldFiles, @newFiles) .should.equal true done() From feee5823689a8d5fe10bbeb4dcfca88d0b277a04 Mon Sep 17 00:00:00 2001 From: Hayden Faulds Date: Fri, 24 Nov 2017 11:32:34 +0000 Subject: [PATCH 85/98] pass user_id into EditorController.replaceFile --- .../web/app/coffee/Features/Editor/EditorController.coffee | 4 ++-- .../coffee/Features/ThirdPartyDataStore/UpdateMerger.coffee | 2 +- .../coffee/Features/Uploads/FileSystemImportManager.coffee | 2 +- .../web/test/unit/coffee/Editor/EditorControllerTests.coffee | 4 ++-- .../unit/coffee/ThirdPartyDataStore/UpdateMergerTests.coffee | 4 ++-- .../unit/coffee/Uploads/FileSystemImportManagerTests.coffee | 4 ++-- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/services/web/app/coffee/Features/Editor/EditorController.coffee b/services/web/app/coffee/Features/Editor/EditorController.coffee index f0530edd7f..f66094f9e8 100644 --- a/services/web/app/coffee/Features/Editor/EditorController.coffee +++ b/services/web/app/coffee/Features/Editor/EditorController.coffee @@ -59,8 +59,8 @@ module.exports = EditorController = EditorRealTimeController.emitToRoom(project_id, 'reciveNewFile', folder_id, fileRef, source) callback(err, fileRef) - replaceFile: (project_id, file_id, fsPath, source, callback = (error) ->)-> - ProjectEntityHandler.replaceFile project_id, file_id, fsPath, null, callback + replaceFile: (project_id, file_id, fsPath, source, user_id, callback = (error) ->)-> + ProjectEntityHandler.replaceFile project_id, file_id, fsPath, user_id, callback addFolder : (project_id, folder_id, folderName, source, callback = (error, folder)->)-> LockManager.getLock project_id, (err)-> diff --git a/services/web/app/coffee/Features/ThirdPartyDataStore/UpdateMerger.coffee b/services/web/app/coffee/Features/ThirdPartyDataStore/UpdateMerger.coffee index e78c1bd1e4..9671524f67 100644 --- a/services/web/app/coffee/Features/ThirdPartyDataStore/UpdateMerger.coffee +++ b/services/web/app/coffee/Features/ThirdPartyDataStore/UpdateMerger.coffee @@ -69,7 +69,7 @@ module.exports = logger.err err:err, project_id:project_id, file_id:file_id, path:path, "error processing file" return callback(err) else if file_id? - editorController.replaceFile project_id, file_id, fsPath, source, finish + editorController.replaceFile project_id, file_id, fsPath, source, user_id, finish else editorController.addFile project_id, folder?._id, fileName, fsPath, source, user_id, finish diff --git a/services/web/app/coffee/Features/Uploads/FileSystemImportManager.coffee b/services/web/app/coffee/Features/Uploads/FileSystemImportManager.coffee index cc52b50dc4..0cec7cfc9d 100644 --- a/services/web/app/coffee/Features/Uploads/FileSystemImportManager.coffee +++ b/services/web/app/coffee/Features/Uploads/FileSystemImportManager.coffee @@ -50,7 +50,7 @@ module.exports = FileSystemImportManager = existingFile = fileRef break if existingFile? - EditorController.replaceFile project_id, existingFile._id, path, "upload", callback + EditorController.replaceFile project_id, existingFile._id, path, "upload", user_id, callback else EditorController.addFileWithoutLock project_id, folder_id, name, path, "upload", user_id, callback diff --git a/services/web/test/unit/coffee/Editor/EditorControllerTests.coffee b/services/web/test/unit/coffee/Editor/EditorControllerTests.coffee index 63d44a8c20..4e1af79b46 100644 --- a/services/web/test/unit/coffee/Editor/EditorControllerTests.coffee +++ b/services/web/test/unit/coffee/Editor/EditorControllerTests.coffee @@ -254,8 +254,8 @@ describe "EditorController", -> it 'should send the replace file message to the editor controller', (done)-> @ProjectEntityHandler.replaceFile = sinon.stub().callsArgWith(4) - @EditorController.replaceFile @project_id, @file_id, @fsPath, @source, => - @ProjectEntityHandler.replaceFile.calledWith(@project_id, @file_id, @fsPath).should.equal true + @EditorController.replaceFile @project_id, @file_id, @fsPath, @source, @user_id, => + @ProjectEntityHandler.replaceFile.calledWith(@project_id, @file_id, @fsPath, @user_id).should.equal true done() describe 'addFolderWithoutLock :', -> diff --git a/services/web/test/unit/coffee/ThirdPartyDataStore/UpdateMergerTests.coffee b/services/web/test/unit/coffee/ThirdPartyDataStore/UpdateMergerTests.coffee index c2d3816a5c..196c502dbf 100644 --- a/services/web/test/unit/coffee/ThirdPartyDataStore/UpdateMergerTests.coffee +++ b/services/web/test/unit/coffee/ThirdPartyDataStore/UpdateMergerTests.coffee @@ -115,7 +115,7 @@ describe 'UpdateMerger :', -> @fileName = "file.png" @fsPath = "fs/path.tex" @editorController.addFile = sinon.stub().callsArg(6) - @editorController.replaceFile = sinon.stub().callsArg(4) + @editorController.replaceFile = sinon.stub().callsArg(5) @editorController.deleteEntity = sinon.stub() @editorController.mkdirp = sinon.stub().withArgs(@project_id).callsArgWith(2, null, [@folder], @folder) @updateMerger.p.writeStreamToDisk = sinon.stub().withArgs(@project_id, @file_id, @update).callsArgWith(3, null, @fsPath) @@ -123,7 +123,7 @@ describe 'UpdateMerger :', -> it 'should replace file if the file already exists', (done)-> @updateMerger.p.processFile @project_id, @file_id, @fsPath, @path, @source, @user_id, => @editorController.addFile.called.should.equal false - @editorController.replaceFile.calledWith(@project_id, @file_id, @fsPath, @source).should.equal true + @editorController.replaceFile.calledWith(@project_id, @file_id, @fsPath, @source, @user_id).should.equal true done() it 'should call add file if the file does not exist', (done)-> diff --git a/services/web/test/unit/coffee/Uploads/FileSystemImportManagerTests.coffee b/services/web/test/unit/coffee/Uploads/FileSystemImportManagerTests.coffee index cbb4fbfa92..0eb17c0d02 100644 --- a/services/web/test/unit/coffee/Uploads/FileSystemImportManagerTests.coffee +++ b/services/web/test/unit/coffee/Uploads/FileSystemImportManagerTests.coffee @@ -169,7 +169,7 @@ describe "FileSystemImportManager", -> } @FileSystemImportManager._isSafeOnFileSystem = sinon.stub().callsArgWith(1, null, true) @ProjectLocator.findElement = sinon.stub().callsArgWith(1, null, @folder) - @EditorController.replaceFile = sinon.stub().callsArg(4) + @EditorController.replaceFile = sinon.stub().callsArg(5) @FileSystemImportManager.addFile @user_id, @project_id, @folder_id, @name, @path_on_disk, true, @callback it "should look up the folder", -> @@ -178,7 +178,7 @@ describe "FileSystemImportManager", -> .should.equal true it "should replace the file", -> - @EditorController.replaceFile.calledWith(@project_id, @file_id, @path_on_disk, "upload") + @EditorController.replaceFile.calledWith(@project_id, @file_id, @path_on_disk, "upload", @user_id) .should.equal true describe "addFolder", -> From 61a1336ec60af80789edf48b679781b0161566eb Mon Sep 17 00:00:00 2001 From: Hayden Faulds Date: Fri, 24 Nov 2017 11:37:19 +0000 Subject: [PATCH 86/98] pass userId into ProjectEntiyHandler.copyFileFromExistingProjectWithProject --- .../Features/Project/ProjectDuplicator.coffee | 6 +++--- .../Features/Project/ProjectEntityHandler.coffee | 4 ++-- .../coffee/Project/ProjectDuplicatorTests.coffee | 14 ++++++++++---- .../Project/ProjectEntityHandlerTests.coffee | 12 ++++++------ 4 files changed, 21 insertions(+), 15 deletions(-) diff --git a/services/web/app/coffee/Features/Project/ProjectDuplicator.coffee b/services/web/app/coffee/Features/Project/ProjectDuplicator.coffee index 730e1e2a1a..815e31eb27 100644 --- a/services/web/app/coffee/Features/Project/ProjectDuplicator.coffee +++ b/services/web/app/coffee/Features/Project/ProjectDuplicator.coffee @@ -31,11 +31,11 @@ module.exports = ProjectDuplicator = async.series jobs, callback - _copyFiles: (newProject, originalProject_id, originalFolder, desFolder, callback)-> + _copyFiles: (owner_id, newProject, originalProject_id, originalFolder, desFolder, callback)-> fileRefs = originalFolder.fileRefs or [] jobs = fileRefs.map (file)-> return (cb)-> - projectEntityHandler.copyFileFromExistingProjectWithProject newProject, desFolder._id, originalProject_id, file, cb + projectEntityHandler.copyFileFromExistingProjectWithProject newProject, desFolder._id, originalProject_id, file, owner_id, cb async.parallelLimit jobs, 5, callback @@ -56,7 +56,7 @@ module.exports = ProjectDuplicator = ProjectDuplicator._copyFolderRecursivly owner_id, newProject_id, originalProject_id, originalRootDoc, childFolder, newFolder, docContents, cb jobs.push (cb)-> - ProjectDuplicator._copyFiles newProject, originalProject_id, originalFolder, desFolder, cb + ProjectDuplicator._copyFiles owner_id, newProject, originalProject_id, originalFolder, desFolder, cb jobs.push (cb)-> ProjectDuplicator._copyDocs owner_id, newProject, originalRootDoc, originalFolder, desFolder, docContents, cb diff --git a/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee b/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee index 42f70b0a7f..5e19e4afa5 100644 --- a/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee +++ b/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee @@ -253,7 +253,7 @@ module.exports = ProjectEntityHandler = return callback(err) if err? DocumentUpdaterHandler.updateProjectStructure project_id, userId, oldDocs, newDocs, oldFiles, newFiles, callback - copyFileFromExistingProjectWithProject: (project, folder_id, originalProject_id, origonalFileRef, callback = (error, fileRef, folder_id) ->)-> + copyFileFromExistingProjectWithProject: (project, folder_id, originalProject_id, origonalFileRef, userId, callback = (error, fileRef, folder_id) ->)-> project_id = project._id logger.log { project_id, folder_id, originalProject_id, origonalFileRef }, "copying file in s3 with project" return callback(err) if err? @@ -277,7 +277,7 @@ module.exports = ProjectEntityHandler = file: fileRef path: result?.path?.fileSystem url: fileStoreUrl - DocumentUpdaterHandler.updateProjectStructure project_id, null, [], [], [], [newFile], (error) -> + DocumentUpdaterHandler.updateProjectStructure project_id, userId, [], [], [], [newFile], (error) -> return callback(error) if error? callback null, fileRef, folder_id diff --git a/services/web/test/unit/coffee/Project/ProjectDuplicatorTests.coffee b/services/web/test/unit/coffee/Project/ProjectDuplicatorTests.coffee index c4be897f9a..b489014e7e 100644 --- a/services/web/test/unit/coffee/Project/ProjectDuplicatorTests.coffee +++ b/services/web/test/unit/coffee/Project/ProjectDuplicatorTests.coffee @@ -65,7 +65,7 @@ describe 'ProjectDuplicator', -> setCompiler : sinon.stub() @entityHandler = addDocWithProject: sinon.stub().callsArgWith(5, null, {name:"somDoc"}) - copyFileFromExistingProjectWithProject: sinon.stub().callsArgWith(4) + copyFileFromExistingProjectWithProject: sinon.stub().callsArgWith(5) setRootDoc: sinon.stub() addFolderWithProject: sinon.stub().callsArgWith(3, null, @newFolder) @@ -152,7 +152,13 @@ describe 'ProjectDuplicator', -> it 'should copy all the files', (done)-> @duplicator.duplicate @owner, @old_project_id, "", (err, newProject)=> - @entityHandler.copyFileFromExistingProjectWithProject.calledWith(@stubbedNewProject, @stubbedNewProject.rootFolder[0]._id, @project._id, @rootFolder.fileRefs[0]).should.equal true - @entityHandler.copyFileFromExistingProjectWithProject.calledWith(@stubbedNewProject, @newFolder._id, @project._id, @level1folder.fileRefs[0]).should.equal true - @entityHandler.copyFileFromExistingProjectWithProject.calledWith(@stubbedNewProject, @newFolder._id, @project._id, @level2folder.fileRefs[0]).should.equal true + @entityHandler.copyFileFromExistingProjectWithProject + .calledWith(@stubbedNewProject, @stubbedNewProject.rootFolder[0]._id, @project._id, @rootFolder.fileRefs[0], @owner._id) + .should.equal true + @entityHandler.copyFileFromExistingProjectWithProject + .calledWith(@stubbedNewProject, @newFolder._id, @project._id, @level1folder.fileRefs[0], @owner._id) + .should.equal true + @entityHandler.copyFileFromExistingProjectWithProject + .calledWith(@stubbedNewProject, @newFolder._id, @project._id, @level2folder.fileRefs[0], @owner._id) + .should.equal true done() diff --git a/services/web/test/unit/coffee/Project/ProjectEntityHandlerTests.coffee b/services/web/test/unit/coffee/Project/ProjectEntityHandlerTests.coffee index 1eef82cbc5..881b579150 100644 --- a/services/web/test/unit/coffee/Project/ProjectEntityHandlerTests.coffee +++ b/services/web/test/unit/coffee/Project/ProjectEntityHandlerTests.coffee @@ -994,7 +994,7 @@ describe 'ProjectEntityHandler', -> it 'should copy the file in FileStoreHandler', (done)-> @ProjectEntityHandler._putElement = sinon.stub().callsArgWith(4, null, {path:{fileSystem:"somehintg"}}) - @ProjectEntityHandler.copyFileFromExistingProjectWithProject @project, folder_id, oldProject_id, oldFileRef, (err, fileRef, parentFolder)=> + @ProjectEntityHandler.copyFileFromExistingProjectWithProject @project, folder_id, oldProject_id, oldFileRef, userId, (err, fileRef, parentFolder)=> @FileStoreHandler.copyFile.calledWith(oldProject_id, oldFileRef._id, project_id, fileRef._id).should.equal true done() @@ -1006,10 +1006,10 @@ describe 'ProjectEntityHandler', -> passedType.should.equal 'file' done() - @ProjectEntityHandler.copyFileFromExistingProjectWithProject @project, folder_id, oldProject_id, oldFileRef, (err, fileRef, parentFolder)-> + @ProjectEntityHandler.copyFileFromExistingProjectWithProject @project, folder_id, oldProject_id, oldFileRef, userId, (err, fileRef, parentFolder)-> it 'should return doc and parent folder', (done)-> - @ProjectEntityHandler.copyFileFromExistingProjectWithProject @project, folder_id, oldProject_id, oldFileRef, (err, fileRef, parentFolder)-> + @ProjectEntityHandler.copyFileFromExistingProjectWithProject @project, folder_id, oldProject_id, oldFileRef, userId, (err, fileRef, parentFolder)-> parentFolder.should.equal folder_id fileRef.name.should.equal fileName done() @@ -1029,12 +1029,12 @@ describe 'ProjectEntityHandler', -> options.rev.should.equal 0 done() - @ProjectEntityHandler.copyFileFromExistingProjectWithProject @project, folder_id, oldProject_id, oldFileRef, (err, fileRef, parentFolder)-> + @ProjectEntityHandler.copyFileFromExistingProjectWithProject @project, folder_id, oldProject_id, oldFileRef, userId, (err, fileRef, parentFolder)-> it "should should send the change in project structure to the doc updater", (done) -> @documentUpdaterHandler.updateProjectStructure = (passed_project_id, passed_user_id, oldDocs, newDocs, oldFiles, newFiles) => passed_project_id.should.equal project_id - #passed_user_id.should.equal userId + passed_user_id.should.equal userId newFiles.length.should.equal 1 newFile = newFiles[0] newFile.file.name.should.equal fileName @@ -1042,7 +1042,7 @@ describe 'ProjectEntityHandler', -> newFile.url.should.equal @fileUrl done() - @ProjectEntityHandler.copyFileFromExistingProjectWithProject @project, folder_id, oldProject_id, oldFileRef, (err, fileRef, parentFolder)-> + @ProjectEntityHandler.copyFileFromExistingProjectWithProject @project, folder_id, oldProject_id, oldFileRef, userId, (err, fileRef, parentFolder)-> describe "renameEntity", -> beforeEach -> From 3466db0aae8aba8d4410ef7cf6aa6e0c1b8c2c83 Mon Sep 17 00:00:00 2001 From: Hayden Faulds Date: Mon, 27 Nov 2017 17:09:51 +0000 Subject: [PATCH 87/98] add acceptance tests --- services/web/config/settings.defaults.coffee | 2 +- .../coffee/ProjectStructureTests.coffee | 200 ++++++++++++++++++ .../coffee/helpers/MockDocUpdaterApi.coffee | 29 +++ .../coffee/helpers/MockDocstoreApi.coffee | 1 + .../coffee/helpers/MockFileStoreApi.coffee | 20 ++ .../helpers/MockProjectHistoryApi.coffee | 18 ++ .../acceptance/coffee/helpers/User.coffee | 9 +- services/web/test/acceptance/files/1pixel.png | Bin 0 -> 3690 bytes services/web/test/acceptance/files/2pixel.png | Bin 0 -> 3694 bytes .../test/acceptance/files/test_project.zip | Bin 0 -> 3820 bytes .../web/test/acceptance/scripts/full-test.sh | 2 +- 11 files changed, 276 insertions(+), 5 deletions(-) create mode 100644 services/web/test/acceptance/coffee/ProjectStructureTests.coffee create mode 100644 services/web/test/acceptance/coffee/helpers/MockFileStoreApi.coffee create mode 100644 services/web/test/acceptance/coffee/helpers/MockProjectHistoryApi.coffee create mode 100644 services/web/test/acceptance/files/1pixel.png create mode 100644 services/web/test/acceptance/files/2pixel.png create mode 100644 services/web/test/acceptance/files/test_project.zip diff --git a/services/web/config/settings.defaults.coffee b/services/web/config/settings.defaults.coffee index 98d1c9e031..65a8bf1e91 100644 --- a/services/web/config/settings.defaults.coffee +++ b/services/web/config/settings.defaults.coffee @@ -111,7 +111,7 @@ module.exports = settings = trackchanges: url : "http://localhost:3015" project_history: - enabled: false + enabled: process.env.PROJECT_HISTORY_ENABLED == 'true' or false url : "http://localhost:3054" docstore: url : "http://#{process.env['DOCSTORE_HOST'] or 'localhost'}:3016" diff --git a/services/web/test/acceptance/coffee/ProjectStructureTests.coffee b/services/web/test/acceptance/coffee/ProjectStructureTests.coffee new file mode 100644 index 0000000000..c7f429ac40 --- /dev/null +++ b/services/web/test/acceptance/coffee/ProjectStructureTests.coffee @@ -0,0 +1,200 @@ +async = require("async") +expect = require("chai").expect +Path = require "path" +fs = require "fs" +_ = require "underscore" + +ProjectGetter = require "../../../app/js/Features/Project/ProjectGetter.js" + +MockDocUpdaterApi = require './helpers/MockDocUpdaterApi' +MockFileStoreApi = require './helpers/MockFileStoreApi' +MockProjectHistoryApi = require './helpers/MockProjectHistoryApi' +request = require "./helpers/request" +User = require "./helpers/User" + +describe "ProjectStructureChanges", -> + before (done) -> + @owner = new User() + @owner.login done + + describe "creating a project from the example template", -> + before (done) -> + MockDocUpdaterApi.clearProjectStructureUpdates() + @owner.createProject "project", {template: "example"}, (error, project_id) => + throw error if error? + @example_project_id = project_id + done() + + it "should version creating a doc", -> + updates = MockDocUpdaterApi.getProjectStructureUpdates(@example_project_id).docUpdates + expect(updates.length).to.equal(2) + _.each updates, (update) => + expect(update.userId).to.equal(@owner._id) + expect(update.docLines).to.be.a('string') + expect(_.where(updates, pathname: "/main.tex").length).to.equal 1 + expect(_.where(updates, pathname: "/references.bib").length).to.equal 1 + + it "should version creating a file", -> + updates = MockDocUpdaterApi.getProjectStructureUpdates(@example_project_id).fileUpdates + expect(updates.length).to.equal(1) + update = updates[0] + expect(update.userId).to.equal(@owner._id) + expect(update.pathname).to.equal("/universe.jpg") + expect(update.url).to.be.a('string'); + + describe "duplicating a project", -> + before (done) -> + MockDocUpdaterApi.clearProjectStructureUpdates() + @owner.request.post { + uri: "/Project/#{@example_project_id}/clone", + json: + projectName: 'new.tex' + }, (error, res, body) => + throw error if error? + if res.statusCode < 200 || res.statusCode >= 300 + throw new Error("failed to add doc #{res.statusCode}") + @dup_project_id = body.project_id + done() + + it "should version the dosc created", -> + updates = MockDocUpdaterApi.getProjectStructureUpdates(@dup_project_id).docUpdates + expect(updates.length).to.equal(2) + _.each updates, (update) => + expect(update.userId).to.equal(@owner._id) + expect(update.docLines).to.be.a('string') + expect(_.where(updates, pathname: "/main.tex").length).to.equal(1) + expect(_.where(updates, pathname: "/references.bib").length).to.equal(1) + + it "should version the files created", -> + updates = MockDocUpdaterApi.getProjectStructureUpdates(@dup_project_id).fileUpdates + expect(updates.length).to.equal(1) + update = updates[0] + expect(update.userId).to.equal(@owner._id) + expect(update.pathname).to.equal("/universe.jpg") + expect(update.url).to.be.a('string'); + + describe "adding a doc", -> + before (done) -> + MockDocUpdaterApi.clearProjectStructureUpdates() + + ProjectGetter.getProject @example_project_id, (error, projects) => + throw error if error? + @owner.request.post { + uri: "project/#{@example_project_id}/doc", + json: + name: 'new.tex' + parent_folder_id: projects[0].rootFolder[0]._id + }, (error, res, body) => + throw error if error? + if res.statusCode < 200 || res.statusCode >= 300 + throw new Error("failed to add doc #{res.statusCode}") + done() + + it "should version the doc added", -> + updates = MockDocUpdaterApi.getProjectStructureUpdates(@example_project_id).docUpdates + expect(updates.length).to.equal(1) + update = updates[0] + expect(update.userId).to.equal(@owner._id) + expect(update.pathname).to.equal("/new.tex") + expect(update.docLines).to.be.a('string'); + + describe "uploading a project", -> + before (done) -> + MockDocUpdaterApi.clearProjectStructureUpdates() + + zip_file = fs.createReadStream(Path.resolve(__dirname + '/../files/test_project.zip')) + + req = @owner.request.post { + uri: "project/new/upload", + formData: + qqfile: zip_file + }, (error, res, body) => + throw error if error? + if res.statusCode < 200 || res.statusCode >= 300 + throw new Error("failed to upload project #{res.statusCode}") + @uploaded_project_id = JSON.parse(body).project_id + done() + + it "should version the dosc created", -> + updates = MockDocUpdaterApi.getProjectStructureUpdates(@uploaded_project_id).docUpdates + expect(updates.length).to.equal(1) + update = updates[0] + expect(update.userId).to.equal(@owner._id) + expect(update.pathname).to.equal("/main.tex") + expect(update.docLines).to.equal("Test") + + it "should version the files created", -> + updates = MockDocUpdaterApi.getProjectStructureUpdates(@uploaded_project_id).fileUpdates + expect(updates.length).to.equal(1) + update = updates[0] + expect(update.userId).to.equal(@owner._id) + expect(update.pathname).to.equal("/1pixel.png") + expect(update.url).to.be.a('string'); + + describe "uploading a file", -> + before (done) -> + MockDocUpdaterApi.clearProjectStructureUpdates() + ProjectGetter.getProject @example_project_id, (error, projects) => + throw error if error? + @root_folder_id = projects[0].rootFolder[0]._id.toString() + done() + + it "should version a newly uploaded file", (done) -> + image_file = fs.createReadStream(Path.resolve(__dirname + '/../files/1pixel.png')) + + req = @owner.request.post { + uri: "project/#{@example_project_id}/upload", + qs: + folder_id: @root_folder_id + formData: + qqfile: + value: image_file + options: + filename: '1pixel.png', + contentType: 'image/png' + }, (error, res, body) => + throw error if error? + if res.statusCode < 200 || res.statusCode >= 300 + throw new Error("failed to upload file #{res.statusCode}") + + updates = MockDocUpdaterApi.getProjectStructureUpdates(@example_project_id).fileUpdates + expect(updates.length).to.equal(1) + update = updates[0] + expect(update.userId).to.equal(@owner._id) + expect(update.pathname).to.equal("/1pixel.png") + expect(update.url).to.be.a('string'); + @original_file_url = update.url + + done() + + it "should version a replacement file", (done) -> + image_file = fs.createReadStream(Path.resolve(__dirname + '/../files/2pixel.png')) + + req = @owner.request.post { + uri: "project/#{@example_project_id}/upload", + qs: + folder_id: @root_folder_id + formData: + qqfile: + value: image_file + options: + filename: '1pixel.png', + contentType: 'image/png' + }, (error, res, body) => + throw error if error? + if res.statusCode < 200 || res.statusCode >= 300 + throw new Error("failed to upload file #{res.statusCode}") + + updates = MockDocUpdaterApi.getProjectStructureUpdates(@example_project_id).fileUpdates + expect(updates.length).to.equal(1) + update = updates[0] + expect(update.userId).to.equal(@owner._id) + expect(update.pathname).to.equal("/1pixel.png") + expect(update.url).to.be.a('string'); + + done() + + describe "tpds", -> + it "should version add a doc" + it "should version add a new file" + it "should version replacing a file" diff --git a/services/web/test/acceptance/coffee/helpers/MockDocUpdaterApi.coffee b/services/web/test/acceptance/coffee/helpers/MockDocUpdaterApi.coffee index aefcd4513a..2147ac2691 100644 --- a/services/web/test/acceptance/coffee/helpers/MockDocUpdaterApi.coffee +++ b/services/web/test/acceptance/coffee/helpers/MockDocUpdaterApi.coffee @@ -1,11 +1,40 @@ express = require("express") app = express() +bodyParser = require "body-parser" +jsonParser = bodyParser.json() module.exports = MockDocUpdaterApi = + project_structures_updates: {} + + clearProjectStructureUpdates: () -> + @project_structures_updates = {} + + getProjectStructureUpdates: (project_id) -> + @project_structures_updates[project_id] + + addProjectStructureUpdates: (project_id, userId, docUpdates, fileUpdates) -> + @project_structures_updates[project_id] ||= { + docUpdates: [] + fileUpdates: [] + } + for update in docUpdates + update.userId = userId + @project_structures_updates[project_id].docUpdates.push(update) + + for update in fileUpdates + update.userId = userId + @project_structures_updates[project_id].fileUpdates.push(update) + run: () -> app.post "/project/:project_id/flush", (req, res, next) => res.sendStatus 200 + app.post "/project/:project_id", jsonParser, (req, res, next) => + project_id = req.params.project_id + {userId, docUpdates, fileUpdates} = req.body + @addProjectStructureUpdates(project_id, userId, docUpdates, fileUpdates) + res.sendStatus 200 + app.listen 3003, (error) -> throw error if error? .on "error", (error) -> diff --git a/services/web/test/acceptance/coffee/helpers/MockDocstoreApi.coffee b/services/web/test/acceptance/coffee/helpers/MockDocstoreApi.coffee index 2133d40b9f..c5b003ac75 100644 --- a/services/web/test/acceptance/coffee/helpers/MockDocstoreApi.coffee +++ b/services/web/test/acceptance/coffee/helpers/MockDocstoreApi.coffee @@ -13,6 +13,7 @@ module.exports = MockDocStoreApi = @docs[project_id][doc_id] = {lines, version, ranges} @docs[project_id][doc_id].rev ?= 0 @docs[project_id][doc_id].rev += 1 + @docs[project_id][doc_id]._id = doc_id res.json { modified: true rev: @docs[project_id][doc_id].rev diff --git a/services/web/test/acceptance/coffee/helpers/MockFileStoreApi.coffee b/services/web/test/acceptance/coffee/helpers/MockFileStoreApi.coffee new file mode 100644 index 0000000000..f3022302f4 --- /dev/null +++ b/services/web/test/acceptance/coffee/helpers/MockFileStoreApi.coffee @@ -0,0 +1,20 @@ +express = require("express") +app = express() + +module.exports = MockFileStoreApi = + files: {} + + run: () -> + app.post "/project/:project_id/file/:file_id", (req, res, next) => + req.on 'data', -> + + req.on 'end', -> + res.send 200 + + app.listen 3009, (error) -> + throw error if error? + .on "error", (error) -> + console.error "error starting MockFileStoreApi:", error.message + process.exit(1) + +MockFileStoreApi.run() diff --git a/services/web/test/acceptance/coffee/helpers/MockProjectHistoryApi.coffee b/services/web/test/acceptance/coffee/helpers/MockProjectHistoryApi.coffee new file mode 100644 index 0000000000..9027e22468 --- /dev/null +++ b/services/web/test/acceptance/coffee/helpers/MockProjectHistoryApi.coffee @@ -0,0 +1,18 @@ +express = require("express") +app = express() + +module.exports = MockProjectHistoryApi = + docs: {} + + run: () -> + app.post "/project", (req, res, next) => + res.json project: id: 1 + + app.listen 3054, (error) -> + throw error if error? + .on "error", (error) -> + console.error "error starting MockProjectHistoryApi:", error.message + process.exit(1) + + +MockProjectHistoryApi.run() diff --git a/services/web/test/acceptance/coffee/helpers/User.coffee b/services/web/test/acceptance/coffee/helpers/User.coffee index 6cd7edd7d4..ad728fb263 100644 --- a/services/web/test/acceptance/coffee/helpers/User.coffee +++ b/services/web/test/acceptance/coffee/helpers/User.coffee @@ -99,11 +99,14 @@ class User getProject: (project_id, callback = (error, project)->) -> db.projects.findOne {_id: ObjectId(project_id.toString())}, callback - createProject: (name, callback = (error, project_id) ->) -> + createProject: (name, options, callback = (error, oroject_id) ->) -> + if typeof options == "function" + callback = options + options = {} + @request.post { url: "/project/new", - json: - projectName: name + json: Object.assign({projectName: name}, options) }, (error, response, body) -> return callback(error) if error? if !body?.project_id? diff --git a/services/web/test/acceptance/files/1pixel.png b/services/web/test/acceptance/files/1pixel.png new file mode 100644 index 0000000000000000000000000000000000000000..a97e79570bd0b360a61a563aa899ef747c985654 GIT binary patch literal 3690 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 literal 0 HcmV?d00001 diff --git a/services/web/test/acceptance/files/2pixel.png b/services/web/test/acceptance/files/2pixel.png new file mode 100644 index 0000000000000000000000000000000000000000..dea591ec2490b22ecfce64453f367f8bf7e5ea07 GIT binary patch literal 3694 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 literal 0 HcmV?d00001 diff --git a/services/web/test/acceptance/scripts/full-test.sh b/services/web/test/acceptance/scripts/full-test.sh index ca10c32a0a..9351df81e3 100755 --- a/services/web/test/acceptance/scripts/full-test.sh +++ b/services/web/test/acceptance/scripts/full-test.sh @@ -3,7 +3,7 @@ # If you're running on OS X, you probably need to rebuild # some dependencies in the docker container, before it will start. # -# npm rebuild --update-binary +#npm rebuild --update-binary echo ">> Starting server..." From 9736513f59b38865af3243fac08c4a0a66a9d6e2 Mon Sep 17 00:00:00 2001 From: Hayden Faulds Date: Mon, 27 Nov 2017 17:10:20 +0000 Subject: [PATCH 88/98] fix ProjectEntityHandler.replaceFile --- .../app/coffee/Features/Project/ProjectEntityHandler.coffee | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee b/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee index 5e19e4afa5..372e7e3469 100644 --- a/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee +++ b/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee @@ -224,9 +224,9 @@ module.exports = ProjectEntityHandler = replaceFile: (project_id, file_id, fsPath, userId, callback)-> self = ProjectEntityHandler - ProjectGetter.getProject project_id, {name:true}, (err, project) -> + FileStoreHandler.uploadFileFromDisk project_id, file_id, fsPath, (err)-> return callback(err) if err? - FileStoreHandler.uploadFileFromDisk project._id, file_id, fsPath, (err)-> + ProjectGetter.getProject project_id, {rootFolder: true, name:true}, (err, project) -> return callback(err) if err? # Note there is a potential race condition here (and elsewhere) # If the file tree changes between findElement and the Project.update From 77eac423c7e80a70a355a72fee1af9a2667d3b1b Mon Sep 17 00:00:00 2001 From: Hayden Faulds Date: Wed, 29 Nov 2017 11:07:49 +0000 Subject: [PATCH 89/98] add tpds project structure acceptance tests --- .../coffee/ProjectStructureTests.coffee | 119 +++++++++++++++++- .../coffee/helpers/MockDocUpdaterApi.coffee | 16 ++- services/web/test/acceptance/files/test.tex | 1 + 3 files changed, 121 insertions(+), 15 deletions(-) create mode 100644 services/web/test/acceptance/files/test.tex diff --git a/services/web/test/acceptance/coffee/ProjectStructureTests.coffee b/services/web/test/acceptance/coffee/ProjectStructureTests.coffee index c7f429ac40..e54d4fac9d 100644 --- a/services/web/test/acceptance/coffee/ProjectStructureTests.coffee +++ b/services/web/test/acceptance/coffee/ProjectStructureTests.coffee @@ -1,7 +1,10 @@ -async = require("async") +async = require "async" expect = require("chai").expect +mkdirp = require "mkdirp" +ObjectId = require("mongojs").ObjectId Path = require "path" fs = require "fs" +Settings = require "settings-sharelatex" _ = require "underscore" ProjectGetter = require "../../../app/js/Features/Project/ProjectGetter.js" @@ -20,7 +23,7 @@ describe "ProjectStructureChanges", -> describe "creating a project from the example template", -> before (done) -> MockDocUpdaterApi.clearProjectStructureUpdates() - @owner.createProject "project", {template: "example"}, (error, project_id) => + @owner.createProject "example-project", {template: "example"}, (error, project_id) => throw error if error? @example_project_id = project_id done() @@ -133,12 +136,14 @@ describe "ProjectStructureChanges", -> describe "uploading a file", -> before (done) -> - MockDocUpdaterApi.clearProjectStructureUpdates() ProjectGetter.getProject @example_project_id, (error, projects) => throw error if error? @root_folder_id = projects[0].rootFolder[0]._id.toString() done() + beforeEach () -> + MockDocUpdaterApi.clearProjectStructureUpdates() + it "should version a newly uploaded file", (done) -> image_file = fs.createReadStream(Path.resolve(__dirname + '/../files/1pixel.png')) @@ -195,6 +200,108 @@ describe "ProjectStructureChanges", -> done() describe "tpds", -> - it "should version add a doc" - it "should version add a new file" - it "should version replacing a file" + before (done) -> + @tpds_project_name = "tpds-project-#{new ObjectId().toString()}" + @owner.createProject @tpds_project_name, (error, project_id) => + throw error if error? + @tpds_project_id = project_id + mkdirp Settings.path.dumpFolder, done + + beforeEach () -> + MockDocUpdaterApi.clearProjectStructureUpdates() + + it "should version adding a doc", (done) -> + tex_file = fs.createReadStream(Path.resolve(__dirname + '/../files/test.tex')) + + req = @owner.request.post { + uri: "/user/#{@owner._id}/update/#{@tpds_project_name}/test.tex", + auth: + user: _.keys(Settings.httpAuthUsers)[0] + pass: _.values(Settings.httpAuthUsers)[0] + sendImmediately: true + } + + tex_file.on "error", (err) -> + throw err + + req.on "error", (err) -> + throw err + + req.on "response", (res) => + if res.statusCode < 200 || res.statusCode >= 300 + throw new Error("failed to upload file #{res.statusCode}") + + updates = MockDocUpdaterApi.getProjectStructureUpdates(@tpds_project_id).docUpdates + expect(updates.length).to.equal(1) + update = updates[0] + expect(update.userId).to.equal(@owner._id) + expect(update.pathname).to.equal("/test.tex") + expect(update.docLines).to.equal("Test") + + done() + + tex_file.pipe(req) + + it "should version adding a new file", (done) -> + image_file = fs.createReadStream(Path.resolve(__dirname + '/../files/1pixel.png')) + + req = @owner.request.post { + uri: "/user/#{@owner._id}/update/#{@tpds_project_name}/1pixel.png", + auth: + user: _.keys(Settings.httpAuthUsers)[0] + pass: _.values(Settings.httpAuthUsers)[0] + sendImmediately: true + } + + image_file.on "error", (err) -> + throw err + + req.on "error", (err) -> + throw err + + req.on "response", (res) => + if res.statusCode < 200 || res.statusCode >= 300 + throw new Error("failed to upload file #{res.statusCode}") + + updates = MockDocUpdaterApi.getProjectStructureUpdates(@tpds_project_id).fileUpdates + expect(updates.length).to.equal(1) + update = updates[0] + expect(update.userId).to.equal(@owner._id) + expect(update.pathname).to.equal("/1pixel.png") + expect(update.url).to.be.a('string'); + + done() + + image_file.pipe(req) + + it "should version replacing a file", (done) -> + image_file = fs.createReadStream(Path.resolve(__dirname + '/../files/2pixel.png')) + + req = @owner.request.post { + uri: "/user/#{@owner._id}/update/#{@tpds_project_name}/1pixel.png", + auth: + user: _.keys(Settings.httpAuthUsers)[0] + pass: _.values(Settings.httpAuthUsers)[0] + sendImmediately: true + } + + image_file.on "error", (err) -> + throw err + + req.on "error", (err) -> + throw err + + req.on "response", (res) => + if res.statusCode < 200 || res.statusCode >= 300 + throw new Error("failed to upload file #{res.statusCode}") + + updates = MockDocUpdaterApi.getProjectStructureUpdates(@tpds_project_id).fileUpdates + expect(updates.length).to.equal(1) + update = updates[0] + expect(update.userId).to.equal(@owner._id) + expect(update.pathname).to.equal("/1pixel.png") + expect(update.url).to.be.a('string'); + + done() + + image_file.pipe(req) diff --git a/services/web/test/acceptance/coffee/helpers/MockDocUpdaterApi.coffee b/services/web/test/acceptance/coffee/helpers/MockDocUpdaterApi.coffee index 2147ac2691..b00cd6b173 100644 --- a/services/web/test/acceptance/coffee/helpers/MockDocUpdaterApi.coffee +++ b/services/web/test/acceptance/coffee/helpers/MockDocUpdaterApi.coffee @@ -4,26 +4,24 @@ bodyParser = require "body-parser" jsonParser = bodyParser.json() module.exports = MockDocUpdaterApi = - project_structures_updates: {} + updates: {} clearProjectStructureUpdates: () -> - @project_structures_updates = {} + @updates = {} getProjectStructureUpdates: (project_id) -> - @project_structures_updates[project_id] + @updates[project_id] || { docUpdates: [], fileUpdates: [] } addProjectStructureUpdates: (project_id, userId, docUpdates, fileUpdates) -> - @project_structures_updates[project_id] ||= { - docUpdates: [] - fileUpdates: [] - } + @updates[project_id] ||= { docUpdates: [], fileUpdates: [] } + for update in docUpdates update.userId = userId - @project_structures_updates[project_id].docUpdates.push(update) + @updates[project_id].docUpdates.push(update) for update in fileUpdates update.userId = userId - @project_structures_updates[project_id].fileUpdates.push(update) + @updates[project_id].fileUpdates.push(update) run: () -> app.post "/project/:project_id/flush", (req, res, next) => diff --git a/services/web/test/acceptance/files/test.tex b/services/web/test/acceptance/files/test.tex new file mode 100644 index 0000000000..8318c86b35 --- /dev/null +++ b/services/web/test/acceptance/files/test.tex @@ -0,0 +1 @@ +Test \ No newline at end of file From 497c6facd50456eff5513e127ab8baa0a0c8a196 Mon Sep 17 00:00:00 2001 From: Hayden Faulds Date: Wed, 29 Nov 2017 11:37:47 +0000 Subject: [PATCH 90/98] fix ProjectEntityHandler.replaceFile --- .../Project/ProjectEntityHandler.coffee | 34 +++++++++---------- .../Project/ProjectEntityHandlerTests.coffee | 31 ++++++++--------- 2 files changed, 31 insertions(+), 34 deletions(-) diff --git a/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee b/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee index 372e7e3469..9549417403 100644 --- a/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee +++ b/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee @@ -224,7 +224,7 @@ module.exports = ProjectEntityHandler = replaceFile: (project_id, file_id, fsPath, userId, callback)-> self = ProjectEntityHandler - FileStoreHandler.uploadFileFromDisk project_id, file_id, fsPath, (err)-> + FileStoreHandler.uploadFileFromDisk project_id, file_id, fsPath, (err, fileStoreUrl)-> return callback(err) if err? ProjectGetter.getProject project_id, {rootFolder: true, name:true}, (err, project) -> return callback(err) if err? @@ -233,25 +233,25 @@ module.exports = ProjectEntityHandler = # then the path to the file element will be out of date. In practice # this is not a problem so long as we do not do anything longer running # between them (like waiting for the file to upload.) - self.getAllEntitiesFromProject project, (err, oldDocs, oldFiles) => + projectLocator.findElement {project:project, element_id: file_id, type: 'file'}, (err, fileRef, path)=> return callback(err) if err? - projectLocator.findElement {project:project, element_id: file_id, type: 'file'}, (err, fileRef, path)=> + tpdsUpdateSender.addFile {project_id:project._id, file_id:fileRef._id, path:path.fileSystem, rev:fileRef.rev+1, project_name:project.name}, (err) -> return callback(err) if err? - tpdsUpdateSender.addFile {project_id:project._id, file_id:fileRef._id, path:path.fileSystem, rev:fileRef.rev+1, project_name:project.name}, (err) -> + conditions = _id:project._id + inc = {} + inc["#{path.mongo}.rev"] = 1 + set = {} + set["#{path.mongo}.created"] = new Date() + update = + "$inc": inc + "$set": set + Project.findOneAndUpdate conditions, update, { "new": true}, (err) -> return callback(err) if err? - conditions = _id:project._id - inc = {} - inc["#{path.mongo}.rev"] = 1 - set = {} - set["#{path.mongo}.created"] = new Date() - update = - "$inc": inc - "$set": set - Project.findOneAndUpdate conditions, update, { "new": true}, (err, newProject) -> - return callback(err) if err? - self.getAllEntitiesFromProject newProject, (err, newDocs, newFiles) => - return callback(err) if err? - DocumentUpdaterHandler.updateProjectStructure project_id, userId, oldDocs, newDocs, oldFiles, newFiles, callback + newFile = + file: fileRef + path: path.fileSystem + url: fileStoreUrl + DocumentUpdaterHandler.updateProjectStructure project_id, userId, [], [], [], [newFile], callback copyFileFromExistingProjectWithProject: (project, folder_id, originalProject_id, origonalFileRef, userId, callback = (error, fileRef, folder_id) ->)-> project_id = project._id diff --git a/services/web/test/unit/coffee/Project/ProjectEntityHandlerTests.coffee b/services/web/test/unit/coffee/Project/ProjectEntityHandlerTests.coffee index 881b579150..18e44f46a6 100644 --- a/services/web/test/unit/coffee/Project/ProjectEntityHandlerTests.coffee +++ b/services/web/test/unit/coffee/Project/ProjectEntityHandlerTests.coffee @@ -582,24 +582,15 @@ describe 'ProjectEntityHandler', -> @ProjectEntityHandler.addFile project_id, folder_id, fileName, {}, userId, () -> - describe 'replacing a file', -> + describe 'replaceFile', -> beforeEach -> @projectLocator @file_id = "file_id_here" @fsPath = "fs_path_here.png" - @fileRef = {rev:3, _id:@file_id} - @filePaths = {fileSystem:"/folder1/file.png", mongo:"folder.1.files.somewhere"} + @fileRef = {rev:3, _id: @file_id, name: @fileName = "fileName"} + @filePaths = {fileSystem: @fileSystemPath="/folder1/file.png", mongo:"folder.1.files.somewhere"} @projectLocator.findElement = sinon.stub().callsArgWith(1, null, @fileRef, @filePaths) - - @ProjectEntityHandler.getAllEntitiesFromProject = sinon.stub() - @ProjectEntityHandler.getAllEntitiesFromProject - .onFirstCall() - .callsArgWith(1, null, [], @oldFiles = ['old-file']) - @ProjectEntityHandler.getAllEntitiesFromProject - .onSecondCall() - .callsArgWith(1, null, [], @newFiles = ['new-file']) - @ProjectModel.findOneAndUpdate = sinon.stub().callsArgWith(3, null, @project) - + @ProjectModel.findOneAndUpdate = sinon.stub().callsArgWith(3) @ProjectGetter.getProject = sinon.stub().callsArgWith(2, null, @project) it 'should find the file', (done)-> @@ -644,12 +635,18 @@ describe 'ProjectEntityHandler', -> @ProjectEntityHandler.replaceFile project_id, @file_id, @fsPath, userId, => it "should should send the old and new project structure to the doc updater", (done) -> - @ProjectEntityHandler.replaceFile project_id, @file_id, @fsPath, userId, => - @documentUpdaterHandler.updateProjectStructure - .calledWith(project_id, userId, [], [], @oldFiles, @newFiles) - .should.equal true + @documentUpdaterHandler.updateProjectStructure = (passed_project_id, passed_user_id, oldDocs, newDocs, oldFiles, newFiles) => + passed_project_id.should.equal project_id + passed_user_id.should.equal userId + newFiles.length.should.equal 1 + newFile = newFiles[0] + newFile.file.name.should.equal @fileName + newFile.path.should.equal @fileSystemPath + newFile.url.should.equal @fileUrl done() + @ProjectEntityHandler.replaceFile project_id, @file_id, @fsPath, userId, => + describe 'addFolder', -> folderName = "folder1234" beforeEach -> From 19643f4bb549b30810052c54a71a37c74c5b0861 Mon Sep 17 00:00:00 2001 From: Hayden Faulds Date: Wed, 29 Nov 2017 11:41:52 +0000 Subject: [PATCH 91/98] pass user_id into editorController.addDoc in ThirdPartyDataStore/UpdateMerger --- .../app/coffee/Features/ThirdPartyDataStore/UpdateMerger.coffee | 2 +- .../unit/coffee/ThirdPartyDataStore/UpdateMergerTests.coffee | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/services/web/app/coffee/Features/ThirdPartyDataStore/UpdateMerger.coffee b/services/web/app/coffee/Features/ThirdPartyDataStore/UpdateMerger.coffee index 9671524f67..010594d16a 100644 --- a/services/web/app/coffee/Features/ThirdPartyDataStore/UpdateMerger.coffee +++ b/services/web/app/coffee/Features/ThirdPartyDataStore/UpdateMerger.coffee @@ -57,7 +57,7 @@ module.exports = if err? logger.err err:err, project_id:project_id, doc_id:doc_id, path:path, "error processing file" return callback(err) - editorController.addDoc project_id, folder._id, fileName, docLines, source, callback + editorController.addDoc project_id, folder._id, fileName, docLines, source, user_id, callback processFile: (project_id, file_id, fsPath, path, source, user_id, callback)-> finish = (err)-> diff --git a/services/web/test/unit/coffee/ThirdPartyDataStore/UpdateMergerTests.coffee b/services/web/test/unit/coffee/ThirdPartyDataStore/UpdateMergerTests.coffee index 196c502dbf..e20419765f 100644 --- a/services/web/test/unit/coffee/ThirdPartyDataStore/UpdateMergerTests.coffee +++ b/services/web/test/unit/coffee/ThirdPartyDataStore/UpdateMergerTests.coffee @@ -97,7 +97,7 @@ describe 'UpdateMerger :', -> path = "folder1/folder2/#{docName}" @editorController.mkdirp = sinon.stub().withArgs(@project_id).callsArgWith(2, null, [folder], folder) @editorController.addDoc = -> - mock = sinon.mock(@editorController).expects("addDoc").withArgs(@project_id, folder._id, docName, @splitDocLines, @source).callsArg(5) + mock = sinon.mock(@editorController).expects("addDoc").withArgs(@project_id, folder._id, docName, @splitDocLines, @source, @user_id).callsArg(6) @update.write(@docLines) @update.end() From 1405b645f33506d2584bb3832918f3d75ba300f4 Mon Sep 17 00:00:00 2001 From: Hayden Faulds Date: Thu, 30 Nov 2017 13:49:08 +0000 Subject: [PATCH 92/98] fix acceptance tests after rebase --- services/web/docker-compose.yml | 1 + services/web/docker-shared.template.yml | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/services/web/docker-compose.yml b/services/web/docker-compose.yml index aaa8666a16..ed12abf0be 100644 --- a/services/web/docker-compose.yml +++ b/services/web/docker-compose.yml @@ -24,6 +24,7 @@ services: REDIS_HOST: redis MONGO_URL: "mongodb://mongo/sharelatex" SHARELATEX_ALLOW_PUBLIC_ACCESS: 'true' + PROJECT_HISTORY_ENABLED: 'true' depends_on: - redis - mongo diff --git a/services/web/docker-shared.template.yml b/services/web/docker-shared.template.yml index 48adb7a036..d697e59e96 100644 --- a/services/web/docker-shared.template.yml +++ b/services/web/docker-shared.template.yml @@ -25,6 +25,7 @@ services: - ./config:/app/config - ./test/unit/coffee:/app/test/unit/coffee:ro - ./test/acceptance/coffee:/app/test/acceptance/coffee:ro + - ./test/acceptance/files:/app/test/acceptance/files:ro - ./test/smoke/coffee:/app/test/smoke/coffee:ro MODULE_VOLUMES - working_dir: /app \ No newline at end of file + working_dir: /app From 4094801f187fbaa1fdb920e4e6667107e9820e2e Mon Sep 17 00:00:00 2001 From: Hayden Faulds Date: Thu, 30 Nov 2017 15:46:37 +0000 Subject: [PATCH 93/98] pass object to DocumentUpdater.updateProjectStructure instead of separate arrays --- .../DocumentUpdaterHandler.coffee | 8 ++-- .../Project/ProjectEntityHandler.coffee | 24 ++++++---- .../DocumentUpdaterHandlerTests.coffee | 47 ++++++++----------- .../Project/ProjectEntityHandlerTests.coffee | 24 ++++++---- 4 files changed, 52 insertions(+), 51 deletions(-) diff --git a/services/web/app/coffee/Features/DocumentUpdater/DocumentUpdaterHandler.coffee b/services/web/app/coffee/Features/DocumentUpdater/DocumentUpdaterHandler.coffee index fc4b5c452b..f7dd9f3e17 100644 --- a/services/web/app/coffee/Features/DocumentUpdater/DocumentUpdaterHandler.coffee +++ b/services/web/app/coffee/Features/DocumentUpdater/DocumentUpdaterHandler.coffee @@ -204,11 +204,11 @@ module.exports = DocumentUpdaterHandler = logger.error {project_id, doc_id, thread_id}, "doc updater returned a non-success status code: #{res.statusCode}" callback new Error("doc updater returned a non-success status code: #{res.statusCode}") - updateProjectStructure : (project_id, userId, oldDocs, newDocs, oldFiles, newFiles, callback = (error) ->)-> + updateProjectStructure : (project_id, userId, changes, callback = (error) ->)-> return callback() if !settings.apis.project_history?.enabled - docUpdates = DocumentUpdaterHandler._getRenameUpdates('doc', oldDocs, newDocs) - fileUpdates = DocumentUpdaterHandler._getRenameUpdates('file', oldFiles, newFiles) + docUpdates = DocumentUpdaterHandler._getRenameUpdates('doc', changes.oldDocs, changes.newDocs) + fileUpdates = DocumentUpdaterHandler._getRenameUpdates('file', changes.oldFiles, changes.newFiles) timer = new metrics.Timer("set-document") url = "#{settings.apis.documentupdater.url}/project/#{project_id}" @@ -231,6 +231,8 @@ module.exports = DocumentUpdaterHandler = callback new Error("doc updater returned a non-success status code: #{res.statusCode}") _getRenameUpdates: (entityType, oldEntities, newEntities) -> + oldEntities ||= [] + newEntities ||= [] updates = [] oldEntitiesHash = _.indexBy oldEntities, (entity) -> entity[entityType]._id.toString() diff --git a/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee b/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee index 9549417403..446786fa57 100644 --- a/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee +++ b/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee @@ -176,11 +176,12 @@ module.exports = ProjectEntityHandler = rev: 0 }, (err) -> return callback(err) if err? - newDoc = + newDocs = [ doc: doc path: result?.path?.fileSystem docLines: docLines.join('\n') - DocumentUpdaterHandler.updateProjectStructure project_id, userId, [], [newDoc], [], [], (error) -> + ] + DocumentUpdaterHandler.updateProjectStructure project_id, userId, {newDocs}, (error) -> return callback(error) if error? callback null, doc, folder_id @@ -214,11 +215,12 @@ module.exports = ProjectEntityHandler = return callback(err) tpdsUpdateSender.addFile {project_id:project._id, file_id:fileRef._id, path:result?.path?.fileSystem, project_name:project.name, rev:fileRef.rev}, (err) -> return callback(err) if err? - newFile = + newFiles = [ file: fileRef path: result?.path?.fileSystem url: fileStoreUrl - DocumentUpdaterHandler.updateProjectStructure project_id, userId, [], [], [], [newFile], (error) -> + ] + DocumentUpdaterHandler.updateProjectStructure project_id, userId, {newFiles}, (error) -> return callback(error) if error? callback null, fileRef, folder_id @@ -247,11 +249,12 @@ module.exports = ProjectEntityHandler = "$set": set Project.findOneAndUpdate conditions, update, { "new": true}, (err) -> return callback(err) if err? - newFile = + newFiles = [ file: fileRef path: path.fileSystem url: fileStoreUrl - DocumentUpdaterHandler.updateProjectStructure project_id, userId, [], [], [], [newFile], callback + ] + DocumentUpdaterHandler.updateProjectStructure project_id, userId, {newFiles}, callback copyFileFromExistingProjectWithProject: (project, folder_id, originalProject_id, origonalFileRef, userId, callback = (error, fileRef, folder_id) ->)-> project_id = project._id @@ -273,11 +276,12 @@ module.exports = ProjectEntityHandler = tpdsUpdateSender.addFile { project_id, file_id:fileRef._id, path:result?.path?.fileSystem, rev:fileRef.rev, project_name:project.name}, (err) -> if err? logger.err { err, project_id, folder_id, originalProject_id, origonalFileRef }, "error sending file to tpds worker" - newFile = + newFiles = [ file: fileRef path: result?.path?.fileSystem url: fileStoreUrl - DocumentUpdaterHandler.updateProjectStructure project_id, userId, [], [], [], [newFile], (error) -> + ] + DocumentUpdaterHandler.updateProjectStructure project_id, userId, {newFiles}, (error) -> return callback(error) if error? callback null, fileRef, folder_id @@ -393,7 +397,7 @@ module.exports = ProjectEntityHandler = tpdsUpdateSender.moveEntity opts self.getAllEntitiesFromProject newProject, (error, newDocs, newFiles) => return callback(error) if error? - DocumentUpdaterHandler.updateProjectStructure project_id, userId, oldDocs, newDocs, oldFiles, newFiles, callback + DocumentUpdaterHandler.updateProjectStructure project_id, userId, {oldDocs, newDocs, oldFiles, newFiles}, callback _checkValidMove: (project, entityType, entityPath, destFolderId, callback = (error) ->) -> return callback() if !entityType.match(/folder/) @@ -450,7 +454,7 @@ module.exports = ProjectEntityHandler = return callback(error) if error? ProjectEntityHandler.getAllEntitiesFromProject newProject, (error, newDocs, newFiles) => return callback(error) if error? - DocumentUpdaterHandler.updateProjectStructure project_id, userId, oldDocs, newDocs, oldFiles, newFiles, callback + DocumentUpdaterHandler.updateProjectStructure project_id, userId, {oldDocs, newDocs, oldFiles, newFiles}, callback _cleanUpEntity: (project, entity, entityType, callback = (error) ->) -> if(entityType.indexOf("file") != -1) diff --git a/services/web/test/unit/coffee/DocumentUpdater/DocumentUpdaterHandlerTests.coffee b/services/web/test/unit/coffee/DocumentUpdater/DocumentUpdaterHandlerTests.coffee index 99214749d6..14ccaa3a33 100644 --- a/services/web/test/unit/coffee/DocumentUpdater/DocumentUpdaterHandlerTests.coffee +++ b/services/web/test/unit/coffee/DocumentUpdater/DocumentUpdaterHandlerTests.coffee @@ -396,7 +396,7 @@ describe 'DocumentUpdaterHandler', -> @settings.apis.project_history.enabled = false @request.post = sinon.stub() - @handler.updateProjectStructure @project_id, @user_id, @oldDocs, @newDocs, @oldFiles, @newFiles, @callback + @handler.updateProjectStructure @project_id, @user_id, {}, @callback it 'does not make a web request', -> @request.post.called.should.equal false @@ -414,17 +414,17 @@ describe 'DocumentUpdaterHandler', -> it 'should send the structure update to the document updater', (done) -> @docIdA = new ObjectId() @docIdB = new ObjectId() - @oldDocs = [ - { path: '/old_a', doc: _id: @docIdA } - { path: '/old_b', doc: _id: @docIdB } - ] - # create new instances of the same ObjectIds so that == doens't pass - @newDocs = [ - { path: '/old_a', doc: _id: new ObjectId(@docIdA.toString()) } - { path: '/new_b', doc: _id: new ObjectId(@docIdB.toString()) } - ] - @oldFiles = [] - @newFiles = [] + @changes = { + oldDocs: [ + { path: '/old_a', doc: _id: @docIdA } + { path: '/old_b', doc: _id: @docIdB } + ] + # create new instances of the same ObjectIds so that == doesn't pass + newDocs: [ + { path: '/old_a', doc: _id: new ObjectId(@docIdA.toString()) } + { path: '/new_b', doc: _id: new ObjectId(@docIdB.toString()) } + ] + } docUpdates = [ id: @docIdB.toString(), @@ -432,7 +432,7 @@ describe 'DocumentUpdaterHandler', -> newPathname: "/new_b" ] - @handler.updateProjectStructure @project_id, @user_id, @oldDocs, @newDocs, @oldFiles, @newFiles, () => + @handler.updateProjectStructure @project_id, @user_id, @changes, () => @request.post .calledWith(url: @url, json: {docUpdates, fileUpdates: [], userId: @user_id}) .should.equal true @@ -441,12 +441,9 @@ describe 'DocumentUpdaterHandler', -> describe "when a doc has been added", -> it 'should send the structure update to the document updater', (done) -> @docId = new ObjectId() - @oldDocs = [] - @newDocs = [ + @changes = newDocs: [ { path: '/foo', docLines: 'a\nb', doc: _id: @docId } ] - @oldFiles = [] - @newFiles = [] docUpdates = [ id: @docId.toString(), @@ -455,7 +452,7 @@ describe 'DocumentUpdaterHandler', -> url: undefined ] - @handler.updateProjectStructure @project_id, @user_id, @oldDocs, @newDocs, @oldFiles, @newFiles, () => + @handler.updateProjectStructure @project_id, @user_id, @changes, () => @request.post .calledWith(url: @url, json: {docUpdates, fileUpdates: [], userId: @user_id}) .should.equal true @@ -464,10 +461,7 @@ describe 'DocumentUpdaterHandler', -> describe "when a file has been added", -> it 'should send the structure update to the document updater', (done) -> @fileId = new ObjectId() - @oldDocs = [] - @newDocs = [] - @oldFiles = [] - @newFiles = [ + @changes = newFiles: [ { path: '/bar', url: 'filestore.example.com/file', file: _id: @fileId } ] @@ -478,7 +472,7 @@ describe 'DocumentUpdaterHandler', -> docLines: undefined ] - @handler.updateProjectStructure @project_id, @user_id, @oldDocs, @newDocs, @oldFiles, @newFiles, () => + @handler.updateProjectStructure @project_id, @user_id, @changes, () => @request.post .calledWith(url: @url, json: {docUpdates: [], fileUpdates, userId: @user_id}) .should.equal true @@ -487,14 +481,11 @@ describe 'DocumentUpdaterHandler', -> describe "when a doc has been deleted", -> it 'should do nothing', (done) -> @docId = new ObjectId() - @oldDocs = [ + @changes = oldDocs: [ { path: '/foo', docLines: 'a\nb', doc: _id: @docId } ] - @newDocs = [] - @oldFiles = [] - @newFiles = [] - @handler.updateProjectStructure @project_id, @user_id, @oldDocs, @newDocs, @oldFiles, @newFiles, () => + @handler.updateProjectStructure @project_id, @user_id, @changes, () => @request.post.called.should.equal false done() diff --git a/services/web/test/unit/coffee/Project/ProjectEntityHandlerTests.coffee b/services/web/test/unit/coffee/Project/ProjectEntityHandlerTests.coffee index 18e44f46a6..f62690e226 100644 --- a/services/web/test/unit/coffee/Project/ProjectEntityHandlerTests.coffee +++ b/services/web/test/unit/coffee/Project/ProjectEntityHandlerTests.coffee @@ -69,7 +69,7 @@ describe 'ProjectEntityHandler', -> @settings = maxEntitiesPerProject:200 @documentUpdaterHandler = - updateProjectStructure: sinon.stub().callsArg(6) + updateProjectStructure: sinon.stub().yields() deleteDoc: sinon.stub().callsArg(2) @ProjectEntityHandler = SandboxedModule.require modulePath, requires: '../../models/Project': Project:@ProjectModel @@ -274,7 +274,7 @@ describe 'ProjectEntityHandler', -> it "should should send the update to the doc updater", -> @documentUpdaterHandler.updateProjectStructure - .calledWith(project_id, userId, @oldDocs, @newDocs, @oldFiles, @newFiles) + .calledWith(project_id, userId, {@oldDocs, @newDocs, @oldFiles, @newFiles}) .should.equal true it 'should remove the element from its current position', -> @@ -326,7 +326,7 @@ describe 'ProjectEntityHandler', -> it "should should send the update to the doc updater", -> @documentUpdaterHandler.updateProjectStructure - .calledWith(project_id, userId, @oldDocs, @newDocs, @oldFiles, @newFiles) + .calledWith(project_id, userId, {@oldDocs, @newDocs, @oldFiles, @newFiles}) .should.equal true it 'should remove the element from its current position', -> @@ -487,12 +487,13 @@ describe 'ProjectEntityHandler', -> .should.equal true it "should should send the change in project structure to the doc updater", () -> - newDoc = + newDocs = [ doc: @doc path: @path docLines: @lines.join('\n') + ] @documentUpdaterHandler.updateProjectStructure - .calledWith(project_id, userId, [], [newDoc], [], []) + .calledWith(project_id, userId, {newDocs}) .should.equal true describe "restoreDoc", -> @@ -570,9 +571,10 @@ describe 'ProjectEntityHandler', -> @ProjectEntityHandler.addFile project_id, folder_id, fileName, {}, userId, (err, fileRef, parentFolder)-> it "should should send the change in project structure to the doc updater", (done) -> - @documentUpdaterHandler.updateProjectStructure = (passed_project_id, passed_user_id, oldDocs, newDocs, oldFiles, newFiles) => + @documentUpdaterHandler.updateProjectStructure = (passed_project_id, passed_user_id, changes) => passed_project_id.should.equal project_id passed_user_id.should.equal userId + { newFiles } = changes newFiles.length.should.equal 1 newFile = newFiles[0] newFile.file.name.should.equal fileName @@ -635,9 +637,10 @@ describe 'ProjectEntityHandler', -> @ProjectEntityHandler.replaceFile project_id, @file_id, @fsPath, userId, => it "should should send the old and new project structure to the doc updater", (done) -> - @documentUpdaterHandler.updateProjectStructure = (passed_project_id, passed_user_id, oldDocs, newDocs, oldFiles, newFiles) => + @documentUpdaterHandler.updateProjectStructure = (passed_project_id, passed_user_id, changes) => passed_project_id.should.equal project_id passed_user_id.should.equal userId + { newFiles } = changes newFiles.length.should.equal 1 newFile = newFiles[0] newFile.file.name.should.equal @fileName @@ -1029,9 +1032,10 @@ describe 'ProjectEntityHandler', -> @ProjectEntityHandler.copyFileFromExistingProjectWithProject @project, folder_id, oldProject_id, oldFileRef, userId, (err, fileRef, parentFolder)-> it "should should send the change in project structure to the doc updater", (done) -> - @documentUpdaterHandler.updateProjectStructure = (passed_project_id, passed_user_id, oldDocs, newDocs, oldFiles, newFiles) => + @documentUpdaterHandler.updateProjectStructure = (passed_project_id, passed_user_id, changes) => passed_project_id.should.equal project_id passed_user_id.should.equal userId + { newFiles } = changes newFiles.length.should.equal 1 newFile = newFiles[0] newFile.file.name.should.equal fileName @@ -1060,12 +1064,12 @@ describe 'ProjectEntityHandler', -> @projectLocator.findElement = sinon.stub().callsArgWith(1, null, @entity = { _id: @entity_id, name:"old.tex", rev:4 }, @path) @tpdsUpdateSender.moveEntity = sinon.stub() @ProjectModel.findOneAndUpdate = sinon.stub().callsArgWith(3, null, @project) - @documentUpdaterHandler.updateProjectStructure = sinon.stub().callsArg(6) + @documentUpdaterHandler.updateProjectStructure = sinon.stub().yields() it "should should send the old and new project structure to the doc updater", (done) -> @ProjectEntityHandler.renameEntity project_id, @entity_id, @entityType, @newName, userId, => @documentUpdaterHandler.updateProjectStructure - .calledWith(project_id, userId, @oldDocs, @newDocs, @oldFiles, @newFiles) + .calledWith(project_id, userId, {@oldDocs, @newDocs, @oldFiles, @newFiles}) .should.equal true done() From aab43f59164e60408d839b59f0c4581ae9f4d2b3 Mon Sep 17 00:00:00 2001 From: Paulo Reis Date: Tue, 5 Dec 2017 10:07:45 +0000 Subject: [PATCH 94/98] Smaller line-height in the file tree. --- services/web/public/stylesheets/app/editor/file-tree.less | 2 +- services/web/public/stylesheets/core/_common-variables.less | 1 + services/web/public/stylesheets/core/ol-variables.less | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/services/web/public/stylesheets/app/editor/file-tree.less b/services/web/public/stylesheets/app/editor/file-tree.less index 1ccbc6859c..162b90c52f 100644 --- a/services/web/public/stylesheets/app/editor/file-tree.less +++ b/services/web/public/stylesheets/app/editor/file-tree.less @@ -52,7 +52,7 @@ } li { - line-height: 2.6; + line-height: @file-tree-line-height; position: relative; .entity { diff --git a/services/web/public/stylesheets/core/_common-variables.less b/services/web/public/stylesheets/core/_common-variables.less index 47030d08ff..cc020ee46e 100644 --- a/services/web/public/stylesheets/core/_common-variables.less +++ b/services/web/public/stylesheets/core/_common-variables.less @@ -906,6 +906,7 @@ // Editor file-tree @file-tree-bg : transparent; +@file-tree-line-height : 2.6; @file-tree-item-color : @gray-darker; @file-tree-item-toggle-color : @gray; @file-tree-item-icon-color : @gray-light; diff --git a/services/web/public/stylesheets/core/ol-variables.less b/services/web/public/stylesheets/core/ol-variables.less index 4911a9d2c4..f5360e467e 100644 --- a/services/web/public/stylesheets/core/ol-variables.less +++ b/services/web/public/stylesheets/core/ol-variables.less @@ -174,6 +174,7 @@ @toolbar-border-bottom : 1px solid @toolbar-border-color; // Editor file-tree @file-tree-bg : @ol-blue-gray-4; +@file-tree-line-height : 2.05; @file-tree-item-color : #FFF; @file-tree-item-input-color : @ol-blue-gray-5; @file-tree-item-toggle-color : @ol-blue-gray-2; From a88538c3fd452d03925f154ab50134119a7e97c8 Mon Sep 17 00:00:00 2001 From: Paulo Reis Date: Tue, 5 Dec 2017 10:20:25 +0000 Subject: [PATCH 95/98] Make the back to projects button closer to current SL. --- .../web/app/views/project/editor/header.pug | 2 +- .../stylesheets/app/editor/toolbar.less | 7 +++-- .../stylesheets/core/_common-variables.less | 29 ++++++++--------- .../public/stylesheets/core/ol-variables.less | 31 ++++++++++--------- 4 files changed, 37 insertions(+), 32 deletions(-) diff --git a/services/web/app/views/project/editor/header.pug b/services/web/app/views/project/editor/header.pug index 74a4639925..c80c4d7b3d 100644 --- a/services/web/app/views/project/editor/header.pug +++ b/services/web/app/views/project/editor/header.pug @@ -9,7 +9,7 @@ header.toolbar.toolbar-header.toolbar-with-labels( ) i.fa.fa-fw.fa-bars.editor-menu-icon p.toolbar-label #{translate("menu")} - a( + a.toolbar-header-back-projects( href="/project" ) i.fa.fa-fw.fa-level-up diff --git a/services/web/public/stylesheets/app/editor/toolbar.less b/services/web/public/stylesheets/app/editor/toolbar.less index ca9464b30a..5c00f00567 100644 --- a/services/web/public/stylesheets/app/editor/toolbar.less +++ b/services/web/public/stylesheets/app/editor/toolbar.less @@ -27,6 +27,10 @@ color: @toolbar-icon-btn-color; padding: 0 5px; border-radius: @border-radius-small; + &.toolbar-header-back-projects { + padding: 5px 10px 4px; + margin-bottom: 1px; + } &:hover { text-shadow: @toolbar-icon-btn-hover-shadow; color: @toolbar-icon-btn-hover-color; @@ -38,7 +42,7 @@ } color: white; background-color: @link-color; - .box-shadow(inset 0 3px 5px rgba(0, 0, 0, 0.225)); + box-shadow: @toolbar-icon-btn-hover-boxshadow; &:hover { color: white; } @@ -124,7 +128,6 @@ } } - .toolbar-small-mixin() { height: 32px; } diff --git a/services/web/public/stylesheets/core/_common-variables.less b/services/web/public/stylesheets/core/_common-variables.less index cc020ee46e..acf6bcc91f 100644 --- a/services/web/public/stylesheets/core/_common-variables.less +++ b/services/web/public/stylesheets/core/_common-variables.less @@ -889,20 +889,21 @@ @footer-padding : 2em; // Editor header -@toolbar-header-bg-color : transparent; -@toolbar-header-shadow : 0 0 2px #ccc; -@toolbar-btn-color : @link-color; -@toolbar-btn-hover-color : @link-hover-color; -@toolbar-btn-hover-bg-color : darken(white, 10%); -@toolbar-btn-hover-text-shadow : 0 1px 0 rgba(0, 0, 0, 0.15); -@toolbar-btn-active-color : white; -@toolbar-btn-active-bg-color : @link-color; -@toolbar-btn-active-shadow : inset 0 3px 5px rgba(0, 0, 0, 0.225); -@toolbar-alt-bg-color : #fafafa; -@toolbar-icon-btn-color : @gray-light; -@toolbar-icon-btn-hover-color : @gray-dark; -@toolbar-icon-btn-hover-shadow : 0 1px 0 rgba(0, 0, 0, 0.25); -@toolbar-border-bottom : 1px solid @toolbar-border-color; +@toolbar-header-bg-color : transparent; +@toolbar-header-shadow : 0 0 2px #ccc; +@toolbar-btn-color : @link-color; +@toolbar-btn-hover-color : @link-hover-color; +@toolbar-btn-hover-bg-color : darken(white, 10%); +@toolbar-btn-hover-text-shadow : 0 1px 0 rgba(0, 0, 0, 0.15); +@toolbar-btn-active-color : white; +@toolbar-btn-active-bg-color : @link-color; +@toolbar-btn-active-shadow : inset 0 3px 5px rgba(0, 0, 0, 0.225); +@toolbar-alt-bg-color : #fafafa; +@toolbar-icon-btn-color : @gray-light; +@toolbar-icon-btn-hover-color : @gray-dark; +@toolbar-icon-btn-hover-shadow : 0 1px 0 rgba(0, 0, 0, 0.25); +@toolbar-icon-btn-hover-boxshadow : inset 0 3px 5px rgba(0, 0, 0, 0.225); +@toolbar-border-bottom : 1px solid @toolbar-border-color; // Editor file-tree @file-tree-bg : transparent; diff --git a/services/web/public/stylesheets/core/ol-variables.less b/services/web/public/stylesheets/core/ol-variables.less index f5360e467e..5c15ad2d3d 100644 --- a/services/web/public/stylesheets/core/ol-variables.less +++ b/services/web/public/stylesheets/core/ol-variables.less @@ -157,21 +157,22 @@ @footer-padding : 2em 0; // Editor header -@toolbar-header-bg-color : @ol-blue-gray-6; -@toolbar-header-shadow : none; -@toolbar-btn-color : #FFF; -@toolbar-btn-hover-color : #FFF; -@toolbar-btn-hover-bg-color : @ol-blue-gray-5; -@toolbar-btn-hover-text-shadow : none; -@toolbar-btn-active-color : #FFF; -@toolbar-btn-active-bg-color : @ol-green; -@toolbar-btn-active-shadow : none; -@toolbar-border-color : @ol-blue-gray-5; -@toolbar-alt-bg-color : @ol-blue-gray-5; -@toolbar-icon-btn-color : #FFF; -@toolbar-icon-btn-hover-color : #FFF; -@toolbar-icon-btn-hover-shadow : none; -@toolbar-border-bottom : 1px solid @toolbar-border-color; +@toolbar-header-bg-color : @ol-blue-gray-6; +@toolbar-header-shadow : none; +@toolbar-btn-color : #FFF; +@toolbar-btn-hover-color : #FFF; +@toolbar-btn-hover-bg-color : @ol-blue-gray-5; +@toolbar-btn-hover-text-shadow : none; +@toolbar-btn-active-color : #FFF; +@toolbar-btn-active-bg-color : @ol-green; +@toolbar-btn-active-shadow : none; +@toolbar-border-color : @ol-blue-gray-5; +@toolbar-alt-bg-color : @ol-blue-gray-5; +@toolbar-icon-btn-color : #FFF; +@toolbar-icon-btn-hover-color : #FFF; +@toolbar-icon-btn-hover-shadow : none; +@toolbar-icon-btn-hover-boxshadow : none; +@toolbar-border-bottom : 1px solid @toolbar-border-color; // Editor file-tree @file-tree-bg : @ol-blue-gray-4; @file-tree-line-height : 2.05; From 5dffdf3efcfd0d3a4d22a16d632510e0cb1aca22 Mon Sep 17 00:00:00 2001 From: Paulo Reis Date: Tue, 5 Dec 2017 10:23:28 +0000 Subject: [PATCH 96/98] Add a small a11y fix. --- services/web/app/views/project/editor/editor.pug | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/services/web/app/views/project/editor/editor.pug b/services/web/app/views/project/editor/editor.pug index dc40d7b9ba..d1c6fa8156 100644 --- a/services/web/app/views/project/editor/editor.pug +++ b/services/web/app/views/project/editor/editor.pug @@ -118,7 +118,11 @@ div.full-size( h3.popover-title #{translate("link_sharing")} .popover-content p #{translate("try_out_link_sharing")} - img(src="/img/onboarding/linksharing/link-sharing.png" width="100%") + img( + src="/img/onboarding/linksharing/link-sharing.png" + alt="Link sharing demo" + width="100%" + ) p #{translate("try_link_sharing_description")} button.btn.btn-default.btn-block(ng-click="dismiss()") | #{translate("got_it")} From fa89b91ac6d25e7794932d743f7a4ed2d385c445 Mon Sep 17 00:00:00 2001 From: Alasdair Smith Date: Wed, 6 Dec 2017 09:58:38 +0000 Subject: [PATCH 97/98] Increase rollout to 40% --- .../web/app/coffee/Features/Project/ProjectController.coffee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/web/app/coffee/Features/Project/ProjectController.coffee b/services/web/app/coffee/Features/Project/ProjectController.coffee index 801459d4c5..c7fad83bf6 100644 --- a/services/web/app/coffee/Features/Project/ProjectController.coffee +++ b/services/web/app/coffee/Features/Project/ProjectController.coffee @@ -253,7 +253,7 @@ module.exports = ProjectController = # Extract data from user's ObjectId timestamp = parseInt(user_id.toString().substring(0, 8), 16) - rolloutPercentage = 20 # Percentage of users to roll out to + rolloutPercentage = 40 # Percentage of users to roll out to if !ProjectController._isInPercentageRollout('autocompile', user_id, rolloutPercentage) # Don't show if user is not part of roll out return cb(null, { enabled: false, showOnboarding: false }) From 2ccf0750f241a8660b77ca21c9b6c79a1c1911d8 Mon Sep 17 00:00:00 2001 From: Paulo Reis Date: Wed, 6 Dec 2017 11:21:02 +0000 Subject: [PATCH 98/98] Run SVGs through SVGOMG. --- .../public/img/ol-brand/overleaf-o-grey.svg | 19 +------------------ .../public/img/ol-brand/overleaf-o-white.svg | 19 +------------------ .../web/public/img/ol-brand/overleaf-o.svg | 11 +---------- 3 files changed, 3 insertions(+), 46 deletions(-) diff --git a/services/web/public/img/ol-brand/overleaf-o-grey.svg b/services/web/public/img/ol-brand/overleaf-o-grey.svg index 3b47c37cba..921148814f 100644 --- a/services/web/public/img/ol-brand/overleaf-o-grey.svg +++ b/services/web/public/img/ol-brand/overleaf-o-grey.svg @@ -1,18 +1 @@ - - - - - - - - - - - - + \ No newline at end of file diff --git a/services/web/public/img/ol-brand/overleaf-o-white.svg b/services/web/public/img/ol-brand/overleaf-o-white.svg index be896abcc3..85188b512c 100644 --- a/services/web/public/img/ol-brand/overleaf-o-white.svg +++ b/services/web/public/img/ol-brand/overleaf-o-white.svg @@ -1,18 +1 @@ - - - - - - - - - - - - + \ No newline at end of file diff --git a/services/web/public/img/ol-brand/overleaf-o.svg b/services/web/public/img/ol-brand/overleaf-o.svg index d95cee9ded..223203dc8f 100644 --- a/services/web/public/img/ol-brand/overleaf-o.svg +++ b/services/web/public/img/ol-brand/overleaf-o.svg @@ -1,10 +1 @@ - - - - - - - - - - \ No newline at end of file + \ No newline at end of file