├── .gitignore ├── .gitmodules ├── LICENSE ├── Makefile ├── README.md ├── extension └── src │ ├── background.html │ ├── background.js │ ├── flatui │ ├── bootstrap.css │ └── flat-ui.css │ ├── fonts │ ├── Flat-UI-Icons.eot │ ├── Flat-UI-Icons.svg │ ├── Flat-UI-Icons.ttf │ └── Flat-UI-Icons.woff │ ├── gravatar.js │ ├── icon.png │ ├── icon_16x16.png │ ├── inject.js │ ├── jquery-2.0.3.min.js │ ├── jquery.tagsinput.js │ ├── manifest.json │ ├── mousetrap.min.js │ ├── popup.html │ └── popup.js ├── requirements.txt └── webapp ├── api.py ├── api_test.py ├── app.yaml ├── fonts ├── Flat-UI-Icons.eot ├── Flat-UI-Icons.svg ├── Flat-UI-Icons.ttf └── Flat-UI-Icons.woff ├── frontend ├── about.jsx ├── backbonemixin.js ├── editor.jsx ├── faq.jsx ├── feed.jsx ├── follows.jsx ├── gravatar.js ├── header.jsx ├── models.js ├── review.jsx ├── site.jsx └── userheader.jsx ├── index.html ├── index.yaml ├── jsonify.py ├── main.py ├── models.py ├── package.json ├── search.py └── static ├── bootstrap.css ├── flat-ui.css ├── frontend.css ├── iloop.png ├── jquery.tagsinput.js └── reset.css /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | \#*\# 3 | webapp/static/frontend.js 4 | webapp/static/.module-cache 5 | webapp/static/.lock.pid 6 | node_modules 7 | memfinity_extension.zip 8 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "frankenserver"] 2 | path = frankenserver 3 | url = https://github.com/Khan/frankenserver.git 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 kohlmeier 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | help: 2 | @echo '"make deps" installs dev software. Do this first!' 3 | @echo '"make server" runs the website.' 4 | @echo '"make test" runs unit tests.' 5 | 6 | deps: 7 | cd webapp && npm install 8 | pip install -r requirements.txt 9 | git submodule sync && git submodule update --init --recursive 10 | 11 | serve: 12 | cd webapp ; \ 13 | node_modules/.bin/watchify -t reactify frontend/* -o static/frontend.js & \ 14 | ../frankenserver/python/dev_appserver.py --host=0.0.0.0 . 15 | 16 | test: 17 | cd webapp ; \ 18 | nosetests --with-gae --gae-lib=../frankenserver/python 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Memfinity 2 | ==== 3 | 4 | Memfinity is a modern webapp and API to provide a social, spaced repetition system. You can easily create flash cards and practice them utilizing a spaced repetition algorithm. You can make your flash cards public or private. You can follow other users or search for topics of interest to discover new cards of interest. If you see a card you like, you can "take" (make a copy of) that card for yourself. 5 | 6 | A current, partial list of features: 7 | * Create, edit, and delete cards. 8 | * Support for Markdown syntax, including support for image links. 9 | * Chrome extension for even faster card creation. 10 | * Review cards using a spaced repetition algortihm (Leitner algorithm) 11 | * Follow/unfollow users. Your follows then populate a pesonalized "feed" of cards. 12 | * Full text search, including support for @usernames and #tags. 13 | * Authentication performed via Google accounts. 14 | * Open source and API-based architecture, for easy extension to mobile apps, etc. 15 | 16 | The site is developed on Google App Engine with the Python SDK. The frontend is written is React. Some desired features are listed as open issues, and pull requests are welcome! 17 | 18 | 19 | 20 | ## Installation Instructions 21 | # First, install Google App Engine SDK for Python. Clone the repo, and run: 22 | make deps 23 | make serve 24 | 25 | 26 | Primary application routes (TODO) 27 | ========================== 28 | 29 | ## Signed out 30 | 31 | / => Signup/Splash page 32 | /[user] => Specific [user] card list/stream 33 | 34 | ## Signed in 35 | 36 | / => Signed-in-user's card list/stream 37 | /feed => Signed-in-user's friends' chronological card-added feed 38 | /[user] => Specific [user] card list/stream 39 | -------------------------------------------------------------------------------- /extension/src/background.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /extension/src/background.js: -------------------------------------------------------------------------------- 1 | console.log('Background page!') 2 | 3 | var Login = { 4 | loggedIn: null, 5 | username: null, 6 | checkLogin: function(callback, ecallback){ 7 | var req = new XMLHttpRequest(); 8 | var self = this; 9 | req.open('GET', 'http://www.memfinity.org/api/user'); 10 | req.onload = function(e){ 11 | var user = JSON.parse(this.responseText); 12 | console.log("Login result:", user) 13 | if (user === null){ 14 | self.loggedIn = false; 15 | if (callback){ callback(false); } 16 | return; 17 | } 18 | self.loggedIn = true; 19 | self.username = user; 20 | if (callback){ callback(user); } 21 | }; 22 | req.onerror = function(){ 23 | console.error(arguments); 24 | if (ecallback) {ecallback.apply(this, arguments);} 25 | }; 26 | req.send(); 27 | return req; 28 | } 29 | } 30 | 31 | Login.checkLogin(); 32 | 33 | function uploadCard(card, callback){ 34 | var req = new XMLHttpRequest(); 35 | req.open('POST', 'http://www.memfinity.org/api/card'); 36 | var postdata = JSON.stringify(card); 37 | req.setRequestHeader("Content-type", "application/json"); 38 | req.onload = callback; 39 | req.onerror = function(){console.error(arguments);}; 40 | req.send(postdata); 41 | return req; 42 | } 43 | 44 | var currentCard = null; 45 | function onPopupClosed() { 46 | console.log('Popup closed! Current card:', currentCard); 47 | if (currentCard !== null){ 48 | uploadCard(currentCard, function(e){console.log(e, this.responseText)}); 49 | } 50 | } 51 | 52 | var PopupCloseMonitor = { 53 | timeoutId: 0, 54 | popupPing: function() { 55 | if(this.timeoutId != 0) { 56 | clearTimeout(this.timeoutId); 57 | } 58 | 59 | var self = this; 60 | this.timeoutId = setTimeout(function() { 61 | onPopupClosed(); 62 | self.timeoutId = 0; 63 | }, 1000); 64 | } 65 | } 66 | 67 | function gotCardFromContent(card){ 68 | if (Login.loggedIn){ 69 | // need some way of invalidating if the request comes 70 | // back bad! 71 | console.log('sending card to popup') 72 | currentCard = card; 73 | chrome.runtime.sendMessage({ 74 | origin: 'background', 75 | content: { 76 | initialize: 'addCard', 77 | data: {card: card, user: Login.username} 78 | } 79 | }); 80 | return; 81 | } 82 | function handleLoginResult(){ 83 | if (Login.loggedIn){ 84 | gotCardFromContent(card); 85 | return; 86 | } 87 | currentCard = null; 88 | chrome.runtime.sendMessage({ 89 | origin: 'background', 90 | content: { 91 | initialize: 'authenticate', 92 | data: null 93 | } 94 | }); 95 | } 96 | Login.checkLogin(handleLoginResult, handleLoginResult); 97 | } 98 | 99 | chrome.runtime.onMessage.addListener(function(request, sender, sendResponse) { 100 | console.log('background got message', request); 101 | if (request.origin === 'content-script'){ 102 | sendResponse('ok'); 103 | gotCardFromContent(request.content); 104 | }else if (request.origin === 'popup'){ 105 | //console.log('Got updated card from popup', request.content); 106 | currentCard = request.content; 107 | } 108 | }); 109 | -------------------------------------------------------------------------------- /extension/src/fonts/Flat-UI-Icons.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kohlmeier/memfinity/3860985e29b203f0569f60eea68ffb22aaf34b1f/extension/src/fonts/Flat-UI-Icons.eot -------------------------------------------------------------------------------- /extension/src/fonts/Flat-UI-Icons.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | This is a custom SVG font generated by IcoMoon. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 23 | 28 | 30 | 34 | 37 | 41 | 46 | 51 | 56 | 58 | 62 | 68 | 72 | 76 | 79 | 83 | 90 | 93 | 95 | 112 | 114 | 117 | 118 | 123 | 127 | 130 | 133 | 138 | 139 | 140 | -------------------------------------------------------------------------------- /extension/src/fonts/Flat-UI-Icons.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kohlmeier/memfinity/3860985e29b203f0569f60eea68ffb22aaf34b1f/extension/src/fonts/Flat-UI-Icons.ttf -------------------------------------------------------------------------------- /extension/src/fonts/Flat-UI-Icons.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kohlmeier/memfinity/3860985e29b203f0569f60eea68ffb22aaf34b1f/extension/src/fonts/Flat-UI-Icons.woff -------------------------------------------------------------------------------- /extension/src/gravatar.js: -------------------------------------------------------------------------------- 1 | function getGravatar(email, size) { 2 | email = email || 'example@example.com'; 3 | 4 | // MD5 (Message-Digest Algorithm) by WebToolkit 5 | // 6 | 7 | var MD5=function(s){function L(k,d){return(k<>>(32-d))}function K(G,k){var I,d,F,H,x;F=(G&2147483648);H=(k&2147483648);I=(G&1073741824);d=(k&1073741824);x=(G&1073741823)+(k&1073741823);if(I&d){return(x^2147483648^F^H)}if(I|d){if(x&1073741824){return(x^3221225472^F^H)}else{return(x^1073741824^F^H)}}else{return(x^F^H)}}function r(d,F,k){return(d&F)|((~d)&k)}function q(d,F,k){return(d&k)|(F&(~k))}function p(d,F,k){return(d^F^k)}function n(d,F,k){return(F^(d|(~k)))}function u(G,F,aa,Z,k,H,I){G=K(G,K(K(r(F,aa,Z),k),I));return K(L(G,H),F)}function f(G,F,aa,Z,k,H,I){G=K(G,K(K(q(F,aa,Z),k),I));return K(L(G,H),F)}function D(G,F,aa,Z,k,H,I){G=K(G,K(K(p(F,aa,Z),k),I));return K(L(G,H),F)}function t(G,F,aa,Z,k,H,I){G=K(G,K(K(n(F,aa,Z),k),I));return K(L(G,H),F)}function e(G){var Z;var F=G.length;var x=F+8;var k=(x-(x%64))/64;var I=(k+1)*16;var aa=Array(I-1);var d=0;var H=0;while(H>>29;return aa}function B(x){var k="",F="",G,d;for(d=0;d<=3;d++){G=(x>>>(d*8))&255;F="0"+G.toString(16);k=k+F.substr(F.length-2,2)}return k}function J(k){k=k.replace(/rn/g,"n");var d="";for(var F=0;F127)&&(x<2048)){d+=String.fromCharCode((x>>6)|192);d+=String.fromCharCode((x&63)|128)}else{d+=String.fromCharCode((x>>12)|224);d+=String.fromCharCode(((x>>6)&63)|128);d+=String.fromCharCode((x&63)|128)}}}return d}var C=Array();var P,h,E,v,g,Y,X,W,V;var S=7,Q=12,N=17,M=22;var A=5,z=9,y=14,w=20;var o=4,m=11,l=16,j=23;var U=6,T=10,R=15,O=21;s=J(s);C=e(s);Y=1732584193;X=4023233417;W=2562383102;V=271733878;for(P=0;P/g, '>'); 32 | testSubject.html(escaped); 33 | // Calculate new width + whether to change 34 | var testerWidth = testSubject.width(), 35 | newWidth = (testerWidth + o.comfortZone) >= minWidth ? testerWidth + o.comfortZone : minWidth, 36 | currentWidth = input.width(), 37 | isValidWidthChange = (newWidth < currentWidth && newWidth >= minWidth) 38 | || (newWidth > minWidth && newWidth < maxWidth); 39 | 40 | // Animate width 41 | if (isValidWidthChange) { 42 | input.width(newWidth); 43 | } 44 | 45 | 46 | }; 47 | $.fn.resetAutosize = function(options){ 48 | // alert(JSON.stringify(options)); 49 | var minWidth = $(this).data('minwidth') || options.minInputWidth || $(this).width(), 50 | maxWidth = $(this).data('maxwidth') || options.maxInputWidth || ($(this).closest('.tagsinput').width() - options.inputPadding), 51 | val = '', 52 | input = $(this), 53 | testSubject = $('').css({ 54 | position: 'absolute', 55 | top: -9999, 56 | left: -9999, 57 | width: 'auto', 58 | fontSize: input.css('fontSize'), 59 | fontFamily: input.css('fontFamily'), 60 | fontWeight: input.css('fontWeight'), 61 | letterSpacing: input.css('letterSpacing'), 62 | whiteSpace: 'nowrap' 63 | }), 64 | testerId = $(this).attr('id')+'_autosize_tester'; 65 | if(! $('#'+testerId).length > 0){ 66 | testSubject.attr('id', testerId); 67 | testSubject.appendTo('body'); 68 | } 69 | 70 | input.data('minwidth', minWidth); 71 | input.data('maxwidth', maxWidth); 72 | input.data('tester_id', testerId); 73 | input.css('width', minWidth); 74 | }; 75 | 76 | $.fn.addTag = function(value,options) { 77 | options = jQuery.extend({focus:false,callback:true},options); 78 | this.each(function() { 79 | var id = $(this).attr('id'); 80 | 81 | var tagslist = $(this).val().split(delimiter[id]); 82 | if (tagslist[0] == '') { 83 | tagslist = new Array(); 84 | } 85 | 86 | value = jQuery.trim(value); 87 | 88 | if (options.unique) { 89 | var skipTag = $(this).tagExist(value); 90 | if(skipTag == true) { 91 | //Marks fake input as not_valid to let styling it 92 | $('#'+id+'_tag').addClass('not_valid'); 93 | } 94 | } else { 95 | var skipTag = false; 96 | } 97 | 98 | if (value !='' && skipTag != true) { 99 | $('').addClass('tag').append( 100 | $('').text(value).append('  '), 101 | $('', { 102 | href : '#', 103 | title : 'Remove tag', 104 | text : '' 105 | }).click(function () { 106 | return $('#' + id).removeTag(escape(value)); 107 | }) 108 | ).insertBefore('#' + id + '_addTag'); 109 | 110 | tagslist.push(value); 111 | 112 | $('#'+id+'_tag').val(''); 113 | if (options.focus) { 114 | $('#'+id+'_tag').focus(); 115 | } else { 116 | $('#'+id+'_tag').blur(); 117 | } 118 | 119 | $.fn.tagsInput.updateTagsField(this,tagslist); 120 | 121 | if (options.callback && tags_callbacks[id] && tags_callbacks[id]['onAddTag']) { 122 | var f = tags_callbacks[id]['onAddTag']; 123 | f.call(this, value); 124 | } 125 | if(tags_callbacks[id] && tags_callbacks[id]['onChange']) 126 | { 127 | var i = tagslist.length; 128 | var f = tags_callbacks[id]['onChange']; 129 | f.call(this, $(this), tagslist[i-1]); 130 | } 131 | } 132 | 133 | }); 134 | 135 | return false; 136 | }; 137 | 138 | $.fn.removeTag = function(value) { 139 | value = unescape(value); 140 | this.each(function() { 141 | var id = $(this).attr('id'); 142 | 143 | var old = $(this).val().split(delimiter[id]); 144 | 145 | $('#'+id+'_tagsinput .tag').remove(); 146 | str = ''; 147 | for (i=0; i< old.length; i++) { 148 | if (old[i]!=value) { 149 | str = str + delimiter[id] +old[i]; 150 | } 151 | } 152 | 153 | $.fn.tagsInput.importTags(this,str); 154 | 155 | if (tags_callbacks[id] && tags_callbacks[id]['onRemoveTag']) { 156 | var f = tags_callbacks[id]['onRemoveTag']; 157 | f.call(this, value); 158 | } 159 | }); 160 | 161 | return false; 162 | }; 163 | 164 | $.fn.tagExist = function(val) { 165 | var id = $(this).attr('id'); 166 | var tagslist = $(this).val().split(delimiter[id]); 167 | return (jQuery.inArray(val, tagslist) >= 0); //true when tag exists, false when not 168 | }; 169 | 170 | // clear all existing tags and import new ones from a string 171 | $.fn.importTags = function(str) { 172 | id = $(this).attr('id'); 173 | $('#'+id+'_tagsinput .tag').remove(); 174 | $.fn.tagsInput.importTags(this,str); 175 | } 176 | 177 | $.fn.tagsInput = function(options) { 178 | var settings = jQuery.extend({ 179 | interactive:true, 180 | defaultText:'', 181 | minChars:0, 182 | width:'', 183 | height:'', 184 | autocomplete: {selectFirst: false }, 185 | 'hide':true, 186 | 'delimiter':',', 187 | 'unique':true, 188 | removeWithBackspace:true, 189 | placeholderColor:'#666666', 190 | autosize: true, 191 | comfortZone: 20, 192 | inputPadding: 6*2 193 | },options); 194 | 195 | this.each(function() { 196 | if (settings.hide) { 197 | $(this).hide(); 198 | } 199 | var id = $(this).attr('id'); 200 | if (!id || delimiter[$(this).attr('id')]) { 201 | id = $(this).attr('id', 'tags' + new Date().getTime()).attr('id'); 202 | } 203 | 204 | var data = jQuery.extend({ 205 | pid:id, 206 | real_input: '#'+id, 207 | holder: '#'+id+'_tagsinput', 208 | input_wrapper: '#'+id+'_addTag', 209 | fake_input: '#'+id+'_tag' 210 | },settings); 211 | 212 | delimiter[id] = data.delimiter; 213 | 214 | if (settings.onAddTag || settings.onRemoveTag || settings.onChange) { 215 | tags_callbacks[id] = new Array(); 216 | tags_callbacks[id]['onAddTag'] = settings.onAddTag; 217 | tags_callbacks[id]['onRemoveTag'] = settings.onRemoveTag; 218 | tags_callbacks[id]['onChange'] = settings.onChange; 219 | } 220 | 221 | var containerClass = $('#'+id).attr('class').replace('tagsinput', ''); 222 | var markup = '
'; 223 | 224 | if (settings.interactive) { 225 | markup = markup + ''; 226 | } 227 | 228 | markup = markup + '
'; 229 | 230 | $(markup).insertAfter(this); 231 | 232 | $(data.holder).css('width',settings.width); 233 | $(data.holder).css('min-height',settings.height); 234 | $(data.holder).css('height','100%'); 235 | 236 | if ($(data.real_input).val()!='') { 237 | $.fn.tagsInput.importTags($(data.real_input),$(data.real_input).val()); 238 | } 239 | if (settings.interactive) { 240 | $(data.fake_input).val($(data.fake_input).attr('data-default')); 241 | $(data.fake_input).css('color',settings.placeholderColor); 242 | $(data.fake_input).resetAutosize(settings); 243 | 244 | $(data.holder).bind('click',data,function(event) { 245 | $(event.data.fake_input).focus(); 246 | }); 247 | 248 | $(data.fake_input).bind('focus',data,function(event) { 249 | if ($(event.data.fake_input).val()==$(event.data.fake_input).attr('data-default')) { 250 | $(event.data.fake_input).val(''); 251 | } 252 | $(event.data.fake_input).css('color','#000000'); 253 | }); 254 | 255 | if (settings.autocomplete_url != undefined) { 256 | autocomplete_options = {source: settings.autocomplete_url}; 257 | for (attrname in settings.autocomplete) { 258 | autocomplete_options[attrname] = settings.autocomplete[attrname]; 259 | } 260 | 261 | if (jQuery.Autocompleter !== undefined) { 262 | $(data.fake_input).autocomplete(settings.autocomplete_url, settings.autocomplete); 263 | $(data.fake_input).bind('result',data,function(event,data,formatted) { 264 | if (data) { 265 | $('#'+id).addTag(data[0] + "",{focus:true,unique:(settings.unique)}); 266 | } 267 | }); 268 | } else if (jQuery.ui.autocomplete !== undefined) { 269 | $(data.fake_input).autocomplete(autocomplete_options); 270 | $(data.fake_input).bind('autocompleteselect',data,function(event,ui) { 271 | $(event.data.real_input).addTag(ui.item.value,{focus:true,unique:(settings.unique)}); 272 | return false; 273 | }); 274 | } 275 | 276 | 277 | } else { 278 | // if a user tabs out of the field, create a new tag 279 | // this is only available if autocomplete is not used. 280 | $(data.fake_input).bind('blur',data,function(event) { 281 | var d = $(this).attr('data-default'); 282 | if ($(event.data.fake_input).val()!='' && $(event.data.fake_input).val()!=d) { 283 | if( (event.data.minChars <= $(event.data.fake_input).val().length) && (!event.data.maxChars || (event.data.maxChars >= $(event.data.fake_input).val().length)) ) 284 | $(event.data.real_input).addTag($(event.data.fake_input).val(),{focus:true,unique:(settings.unique)}); 285 | } else { 286 | $(event.data.fake_input).val($(event.data.fake_input).attr('data-default')); 287 | $(event.data.fake_input).css('color',settings.placeholderColor); 288 | } 289 | return false; 290 | }); 291 | 292 | } 293 | // if user types a comma, create a new tag 294 | $(data.fake_input).bind('keypress',data,function(event) { 295 | if (event.which==event.data.delimiter.charCodeAt(0) || event.which==13 ) { 296 | event.preventDefault(); 297 | if( (event.data.minChars <= $(event.data.fake_input).val().length) && (!event.data.maxChars || (event.data.maxChars >= $(event.data.fake_input).val().length)) ) 298 | $(event.data.real_input).addTag($(event.data.fake_input).val(),{focus:true,unique:(settings.unique)}); 299 | $(event.data.fake_input).resetAutosize(settings); 300 | return false; 301 | } else if (event.data.autosize) { 302 | $(event.data.fake_input).doAutosize(settings); 303 | 304 | } 305 | }); 306 | //Delete last tag on backspace 307 | data.removeWithBackspace && $(data.fake_input).bind('keydown', function(event) 308 | { 309 | if(event.keyCode == 8 && $(this).val() == '') 310 | { 311 | event.preventDefault(); 312 | var last_tag = $(this).closest('.tagsinput').find('.tag:last').text(); 313 | var id = $(this).attr('id').replace(/_tag$/, ''); 314 | last_tag = last_tag.replace(/[\s\u00a0]+x$/, ''); 315 | $('#' + id).removeTag(escape(last_tag)); 316 | $(this).trigger('focus'); 317 | } 318 | }); 319 | $(data.fake_input).blur(); 320 | 321 | //Removes the not_valid class when user changes the value of the fake input 322 | if(data.unique) { 323 | $(data.fake_input).keydown(function(event){ 324 | if(event.keyCode == 8 || String.fromCharCode(event.which).match(/\w+|[áéíóúÁÉÍÓÚñÑ,/]+/)) { 325 | $(this).removeClass('not_valid'); 326 | } 327 | }); 328 | } 329 | } // if settings.interactive 330 | }); 331 | 332 | return this; 333 | 334 | }; 335 | 336 | $.fn.tagsInput.updateTagsField = function(obj,tagslist) { 337 | var id = $(obj).attr('id'); 338 | $(obj).val(tagslist.join(delimiter[id])); 339 | }; 340 | 341 | $.fn.tagsInput.importTags = function(obj,val) { 342 | $(obj).val(''); 343 | var id = $(obj).attr('id'); 344 | var tags = val.split(delimiter[id]); 345 | for (i=0; if||k.hasOwnProperty(f)&&(q[k[f]]=f)}e=q[c]?"keydown":"keypress"}"keypress"==e&&g.length&&(e="keydown");return{key:d,modifiers:g,action:e}}function E(a,b,c,d,e){r[a+":"+c]=b;a=a.replace(/\s+/g," ");var g=a.split(" ");1":".","?":"/","|":"\\"},F={option:"alt",command:"meta","return":"enter",escape:"esc",mod:/Mac|iPod|iPhone|iPad/.test(navigator.platform)?"meta":"ctrl"},q,m={},r={},p={},C,y=!1,H=!1,u=!1,h=1;20>h;++h)k[111+h]="f"+h;for(h=0;9>=h;++h)k[h+96]=h;s(document,"keypress",x);s(document,"keydown",x);s(document,"keyup",x);var n={bind:function(a,b,c){a=a instanceof Array?a:[a];for(var d=0;d 2 | 3 | 4 | SSRS 5 | 6 | 7 | 72 | 73 | 74 | 97 | 103 |
104 |
105 |
106 |
107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | -------------------------------------------------------------------------------- /extension/src/popup.js: -------------------------------------------------------------------------------- 1 | Mousetrap._stopCallback = Mousetrap.stopCallback; 2 | Mousetrap.stopCallback = function(e, element, combo){ 3 | 4 | if (element.id === 'tags_tag'){ 5 | return false; 6 | } 7 | 8 | return Mousetrap._stopCallback(e, element, combo); 9 | }; 10 | 11 | var Templates = { 12 | addCard: $('#add-card').html(), 13 | authenticate: $('#authenticate').html() 14 | }; 15 | 16 | function addCard(data){ 17 | var card = data.card; 18 | $('#content').html(Templates.addCard); 19 | $('#front').val(card.front); 20 | $('#back').val(card.back); 21 | $('#who').attr({src: getGravatar(data.user + '@gmail.com')}); 22 | 23 | function update(){ 24 | chrome.runtime.sendMessage({ 25 | origin: 'popup', 26 | content: card 27 | }); 28 | } 29 | 30 | function nullOut(){ 31 | chrome.runtime.sendMessage({ 32 | origin: 'popup', 33 | content: null 34 | }); 35 | } 36 | 37 | $('#front,#back').keypress(function() { 38 | card.front = $('#front').val(); 39 | card.back = $('#back').val(); 40 | update(); 41 | }); 42 | $('#back').focus(function() { 43 | $(this).css({height: '150px'}); 44 | }); 45 | $('#back').blur(function() { 46 | if (this.value === ''){ 47 | $(this).css({height: '40px'}); 48 | } 49 | }); 50 | $('#cancel').click(function() { 51 | nullOut(); 52 | window.close(); 53 | }); 54 | $('#save').click(function(){ 55 | update(); 56 | window.close(); 57 | }); 58 | 59 | Mousetrap.bind(['ctrl+s', 'command+s'], function(e) { 60 | e.preventDefault(); 61 | update(); 62 | window.close(); 63 | }); 64 | 65 | Mousetrap.bind('esc', function(e) { 66 | e.preventDefault(); 67 | nullOut(); 68 | window.close(); 69 | }); 70 | 71 | Mousetrap.bind('up up down down', function(e) { 72 | var front = $('#front').val(); 73 | var back = $('#back').val(); 74 | $('#front').val(back); 75 | $('#back').val(front); 76 | 77 | if (front !== ''){ 78 | $('#back').css({height: '150px'}); 79 | } 80 | }); 81 | 82 | $(".taginput").tagsInput({ 83 | onChange: function(){ 84 | card.tags = $('#tags').val().split(','); 85 | update(); 86 | } 87 | }); 88 | 89 | $('#front').focus(); 90 | $('#front')[0].selectionStart = card.front.length; 91 | $('#front')[0].selectionEnd = card.front.length; 92 | } 93 | 94 | function authenticate(){ 95 | $('#content').html(Templates.authenticate); 96 | $('#login').click(function(){ 97 | chrome.tabs.create({'url': "http://www.memfinity.org/login"}); 98 | }); 99 | } 100 | 101 | function flash(){ 102 | $('#flash').fadeOut(250, function(){ 103 | $(this).css({display: 'none'}); 104 | }); 105 | } 106 | 107 | if (window.location.protocol == 'file:'){ 108 | addCard({ 109 | card: { 110 | front: 'Hello, world!', 111 | back: '', 112 | info: '', 113 | tags: [], 114 | source_url: '' 115 | }, 116 | user: 'sam.m.birch' 117 | }); 118 | flash(); 119 | } else { 120 | // http://stackoverflow.com/questions/3907804/how-to-detect-when-action-popup-gets-closed 121 | // unload events don't work for popups. 122 | function ping() { 123 | chrome.extension.getBackgroundPage().PopupCloseMonitor.popupPing(); 124 | setTimeout(ping, 500); 125 | } 126 | ping(); 127 | 128 | chrome.runtime.onMessage.addListener(function(request, sender, sendResponse) { 129 | console.log('popup got message', request, sender); 130 | 131 | if (request.origin === 'background'){ 132 | window[request.content.initialize](request.content.data); 133 | flash(); 134 | } 135 | }); 136 | 137 | // Set everything in motion via the content script 138 | chrome.tabs.getSelected(null, function(tab) { 139 | chrome.tabs.executeScript(null, {file: 'inject.js'}); 140 | }); 141 | } 142 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # For running unit tests. 2 | NoseGAE==0.2.1 3 | nose==1.3.3 4 | -------------------------------------------------------------------------------- /webapp/api.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import json 3 | 4 | from google.appengine.api import oauth 5 | from google.appengine.api import users 6 | from google.appengine.ext import ndb 7 | 8 | import jsonify 9 | import models 10 | import search 11 | 12 | 13 | def get_oauth_user(): 14 | """Return the OAuth authenticated user, else raise an exception.""" 15 | try: 16 | # Get the db.User that represents the user on whose behalf the 17 | # consumer is making this request. 18 | user = oauth.get_current_user() 19 | 20 | except oauth.OAuthRequestError: 21 | # The request was not a valid OAuth request. 22 | raise # TODO(jace) something better to do here? 23 | 24 | return user 25 | 26 | 27 | def get_current_user(handler): 28 | """Return the UserData for the currently logged in user (or None).""" 29 | user = users.get_current_user() 30 | 31 | if not user: 32 | handler.error(401) 33 | return None 34 | 35 | return models.UserData.get_for_user_id(user.user_id()) 36 | 37 | 38 | def entity_view(handler, route_root): 39 | """Query for a single entity by Key.""" 40 | path = handler.request.path 41 | response = '{}' 42 | 43 | if not path.startswith(route_root) and len(path) > len(route_root): 44 | return response 45 | 46 | entity_key = path[len(route_root):] 47 | entity = ndb.Key(urlsafe=entity_key).get() 48 | if entity: 49 | response = jsonify.jsonify(entity, pretty_print=True) 50 | 51 | return response 52 | 53 | 54 | def user_view_current(handler): 55 | """Return information about the currently logged in user.""" 56 | return jsonify.jsonify(users.get_current_user()) 57 | 58 | 59 | def user_view(handler): 60 | """Query for a single user by Key.""" 61 | return entity_view(handler, '/api/user/') 62 | 63 | 64 | def card_view(handler): 65 | """Query for a single card by Key.""" 66 | return entity_view(handler, '/api/card/') 67 | 68 | 69 | def card_query(handler): 70 | """Query for multiple cards. 71 | 72 | See main.py for usage examples. 73 | 74 | TODO(jace): return a query cursor, too? 75 | """ 76 | 77 | tag = handler.request.get("tag", None) 78 | tag_list = tag.split(',') if tag else None 79 | review = handler.request.get('review', None) 80 | reviewAll = handler.request.get('reviewAll', False) 81 | include_followers = handler.request.get('include_followers', None) 82 | user_key = handler.request.get('user', None) 83 | 84 | if review and user_key: 85 | handler.error(400) 86 | return "'review' and 'user_key' cannot be used together." 87 | 88 | if review and include_followers: 89 | handler.error(400) 90 | return "'review' and 'include_followers' cannot be used together." 91 | 92 | if include_followers and not user_key: 93 | handler.error(400) 94 | return "'review' and 'include_followers' cannot be used together." 95 | 96 | 97 | if review: 98 | # if asked for review cards, get them for the current user only 99 | current_user = get_current_user(handler) 100 | if not current_user: 101 | handler.error(400) 102 | return "must be logged in to quer for review cards." 103 | user_key = current_user.key.urlsafe() 104 | 105 | 106 | query = models.Card.query() 107 | 108 | if include_followers: 109 | user_data = ndb.Key(urlsafe=user_key).get() 110 | if not user_data: 111 | handler.error(500) 112 | return "UserData not found." 113 | user_list = user_data.following + [ndb.Key(urlsafe=user_key)] 114 | query = query.filter(models.Card.user_key.IN(user_list)) 115 | elif user_key: 116 | query = query.filter(models.Card.user_key == ndb.Key(urlsafe=user_key)) 117 | 118 | if tag_list: 119 | query = query.filter(models.Card.tags.IN(tag_list)) 120 | 121 | if review: 122 | # For review mode, we sort by next scheduled review 123 | query = query.order(models.Card.next_review) 124 | else: 125 | query = query.order(-models.Card.added) 126 | 127 | results = query.fetch(100) 128 | 129 | response = '[]' 130 | if results: 131 | if review and not reviewAll: 132 | # TODO(jace) if the current user is asking for review cards but 133 | # hasn't explicitly asked to review ALL cards, then we truncate 134 | # the results to include just cards scheduled on or before 135 | # today... the future can wait. 136 | now = datetime.datetime.now() 137 | results = [card for card in results if card.next_review <= now] 138 | 139 | response = jsonify.jsonify(results, pretty_print=True) 140 | 141 | return response 142 | 143 | 144 | def card_search(handler): 145 | """Search cards with query parameter "q". 146 | 147 | Returns a (possibly empty) list of JSONified models.Card 148 | entities. See search.py for query processing details. 149 | """ 150 | user = users.get_current_user() 151 | if user: 152 | user_data = models.UserData.get_for_user_id(user.user_id()) 153 | user_key = user_data.key.urlsafe() 154 | else: 155 | user_key = None 156 | 157 | query = handler.request.get('q', '') 158 | search_results = search.query_cards(query, limit=20, ids_only=True, 159 | user_key=user_key) 160 | results = ndb.get_multi([ndb.Key(urlsafe=result.doc_id) 161 | for result in search_results]) 162 | return jsonify.jsonify(results) 163 | 164 | 165 | def card_add(handler): 166 | """Add a new Card.""" 167 | user_data = get_current_user(handler) 168 | if not user_data: 169 | return 170 | 171 | data = json.loads(handler.request.body) 172 | 173 | card = models.Card(user_key=user_data.key) 174 | card.update_from_dict(data) 175 | card.update_email_and_nickname() 176 | 177 | card.put() 178 | search.insert_cards([card]) 179 | 180 | # Update the list of all known tags for this user 181 | # Update the list of all known tags for this user 182 | user_data.update_card_tags([], data.get('tags', [])) 183 | user_data.put() # TODO(jace): only put if necessary 184 | 185 | # TODO(jace) Notify followers 186 | 187 | return card.key.urlsafe() 188 | 189 | 190 | def card_update(handler, delete=False, review=False): 191 | """Update or Delete an exisiting Card.""" 192 | user_data = get_current_user(handler) 193 | if not user_data: 194 | return 195 | 196 | path = handler.request.path 197 | route_root = '/api/card/' 198 | err_response = '{}' 199 | 200 | if not path.startswith(route_root) and len(path) > len(route_root): 201 | return err_response 202 | 203 | card_key = path[len(route_root):] 204 | if card_key.endswith('/review'): 205 | card_key = card_key[:-len('/review')] 206 | 207 | card = ndb.Key(urlsafe=card_key).get() 208 | if not card: 209 | return err_response 210 | 211 | if user_data.key != card.user_key: 212 | # Disallow modification of other people's cards 213 | return err_response 214 | 215 | # Finally ready to do the update 216 | card_tags_original = set(card.tags) 217 | if delete: 218 | card_tags_updated = set() 219 | card.key.delete() 220 | search.delete_cards([card]) 221 | elif review: 222 | data = json.loads(handler.request.body) 223 | card.record_review(data.get('grade')) 224 | card_tags_updated = set(card.tags) # unchanged in this case 225 | card.put() 226 | else: 227 | data = json.loads(handler.request.body) 228 | card.update_from_dict(data) 229 | card_tags_updated = set(card.tags) 230 | card.put() 231 | search.insert_cards([card]) 232 | 233 | # Update the list of all known tags for this user 234 | user_data.update_card_tags(card_tags_original, card_tags_updated) 235 | user_data.put() # TODO(jace): only put if necessary 236 | 237 | # TODO(jace) Notify followers 238 | 239 | return card.key.urlsafe() 240 | 241 | 242 | def card_import(handler): 243 | """Import another user's existing Card to the current user's account. 244 | 245 | Called with the form: /api/card//import 246 | """ 247 | user_data = get_current_user(handler) 248 | if not user_data: 249 | return 250 | 251 | path = handler.request.path 252 | 253 | card_key = path[len('/api/card/'):-len('/import')] 254 | card = ndb.Key(urlsafe=card_key).get() 255 | if not card: 256 | return "card not found" 257 | 258 | if user_data.key == card.user_key: 259 | # Disallow importing a card this user already owns 260 | return "can't import your own card" 261 | 262 | # Finally ready to do the update 263 | new_card = models.Card() 264 | new_card.populate(**card.to_dict()) 265 | new_card.user_key = user_data.key 266 | new_card.update_email_and_nickname() 267 | new_card.put() 268 | search.insert_cards([new_card]) 269 | 270 | # Update the list of all known tags for this user 271 | user_data.update_card_tags([], new_card.tags) 272 | user_data.put() # TODO(jace): only put if necessary 273 | 274 | # TODO(jace) Notify followers 275 | 276 | return new_card.key.urlsafe() 277 | 278 | 279 | def user_follows(handler): 280 | """Get the users followed by and following a certain users. 281 | 282 | Called via a route like: 283 | /api/user//follows 284 | """ 285 | path = handler.request.path 286 | 287 | user_key = path[len('/api/user/'):-len('/follows')] 288 | user_data = ndb.Key(urlsafe=user_key).get() 289 | if not user_data: 290 | return "User not found" 291 | 292 | # TODO make async 293 | following_data = ndb.get_multi(user_data.following) 294 | followers_data = ndb.get_multi(user_data.followers) 295 | 296 | # Finally ready to do the update 297 | data = { 298 | 'user_data': user_data, 299 | 'following': following_data, 300 | 'followers': followers_data 301 | } 302 | 303 | return jsonify.jsonify(data) 304 | 305 | 306 | def user_update(handler): 307 | """Update an exisiting User.""" 308 | user_data = get_current_user(handler) 309 | if not user_data: 310 | return 311 | 312 | path = handler.request.path 313 | route_root = '/api/user/' 314 | err_response = '{}' 315 | 316 | if not path.startswith(route_root) and len(path) > len(route_root): 317 | return err_response 318 | 319 | user_key = path[len(route_root):] 320 | if user_data.key != ndb.Key(urlsafe=user_key): 321 | # Disallow modification of other people's data! 322 | return err_response 323 | 324 | # Finally ready to do the update 325 | data = json.loads(handler.request.body) 326 | user_data.update_from_dict(data) 327 | user_data.put() # TODO(jace): only put if necessary 328 | 329 | return user_data.key.urlsafe() 330 | 331 | 332 | def user_follow_unfollow(handler, follow_or_unfollow): 333 | """Follow a new user.""" 334 | user_data = get_current_user(handler) 335 | if not user_data: 336 | return 337 | 338 | path = handler.request.path 339 | 340 | suffix = '/' + follow_or_unfollow 341 | # path form is '/api/user//[un]follow' 342 | follow_user_key = ndb.Key(urlsafe=path[len('/api/user/'):-len(suffix)]) 343 | follow_user = follow_user_key.get() 344 | 345 | if not follow_user: 346 | handler.error(500) 347 | return "User to follow not found." 348 | 349 | if follow_user_key == user_data.key: 350 | handler.error(500) 351 | return "Users may not follow themselves." 352 | 353 | if follow_or_unfollow == 'follow': 354 | if follow_user_key not in user_data.following: 355 | user_data.following.append(follow_user_key) 356 | user_data.put() 357 | 358 | if user_data.key not in follow_user.followers: 359 | follow_user.followers.append(user_data.key) 360 | follow_user.put() 361 | 362 | elif follow_or_unfollow == 'unfollow': 363 | if follow_user_key in user_data.following: 364 | user_data.following.remove(follow_user_key) 365 | user_data.put() 366 | 367 | if user_data.key in follow_user.followers: 368 | follow_user.followers.remove(user_data.key) 369 | follow_user.put() 370 | 371 | return user_data.key.urlsafe() 372 | 373 | 374 | class _JSONCardArchive(object): 375 | """Simple format for storing cards used by bulk import & export.""" 376 | # TODO(chris): unit tests for import / export. 377 | 378 | VERSION = "v1" 379 | """Increment VERSION when the JSON archive format changes.""" 380 | 381 | def __init__(self, cards=None): 382 | self.cards = cards or [] 383 | 384 | def _card_to_archive_card(self, card): 385 | """Python object representation of a model.Card for export.""" 386 | obj = {"front": card.front or "", 387 | "back": card.back or "", 388 | "input_format": card.input_format or "text", 389 | } 390 | if card.tags: 391 | obj["tags"] = card.tags 392 | return obj 393 | 394 | def get_cards(self): 395 | return self.cards 396 | 397 | def to_json(self): 398 | archive = {"format": "JSONCardArchive", 399 | "version": self.VERSION, 400 | "cards": map(self._card_to_archive_card, self.cards), 401 | } 402 | return jsonify.jsonify(archive) 403 | 404 | @classmethod 405 | def from_json(cls, json_str): 406 | obj = json.loads(json_str) 407 | assert obj.get("format") == "JSONCardArchive", obj.get("format") 408 | assert obj.get("version") == "v1", obj.get("version") 409 | assert "cards" in obj 410 | cards = [] 411 | for card_obj in obj["cards"]: 412 | card = models.Card() 413 | card.update_from_dict(card_obj) 414 | cards.append(card) 415 | archive = _JSONCardArchive(cards) 416 | return archive 417 | 418 | 419 | def card_bulk_export(handler): 420 | """Return all cards for the current user in JSON archive format.""" 421 | user_data = get_current_user(handler) 422 | if not user_data: 423 | return 424 | 425 | query = models.Card.query() 426 | query = query.filter(models.Card.user_key == user_data.key) 427 | # TODO(chris): support export of >1k cards. 428 | # TODO(chris): support streaming JSON w/a fixed memory buffer to 429 | # avoid OOMs due to large card content. 430 | archive = _JSONCardArchive(query.fetch(limit=1000)) 431 | return archive.to_json() 432 | 433 | 434 | def card_bulk_import(handler): 435 | """Create POSTed cards for the current user.""" 436 | user_data = get_current_user(handler) 437 | user = users.get_current_user() 438 | if not user_data or not user: 439 | return 440 | 441 | archive_json_str = handler.request.body 442 | archive = _JSONCardArchive.from_json(archive_json_str) 443 | 444 | tags = set() 445 | cards = archive.get_cards() 446 | # TODO(chris): support streaming JSON w/a fixed memory buffer to 447 | # avoid OOMs due to large card content. 448 | for card in cards: 449 | card.user_key = user_data.key 450 | card.update_email_and_nickname(user) 451 | tags.update(card.tags) 452 | 453 | # Update the list of all known tags for this user. 454 | user_data.update_card_tags([], tags) 455 | ndb.put_multi(cards + [user_data]) # TODO(chris): only put if necessary 456 | search.insert_cards(cards) 457 | -------------------------------------------------------------------------------- /webapp/api_test.py: -------------------------------------------------------------------------------- 1 | import json 2 | import unittest 3 | 4 | import api 5 | import models 6 | 7 | 8 | class CardBulkExportTest(unittest.TestCase): 9 | def _to_json(self, card): 10 | return api._JSONCardArchive(cards=[card]).to_json() 11 | 12 | def assert_json(self, first, second): 13 | """Assert JSON matches, ignoring key order.""" 14 | first_json = json.dumps(json.loads(first), sort_keys=True, indent=2) 15 | second_json = json.dumps(json.loads(second), sort_keys=True, indent=2) 16 | self.assertMultiLineEqual(first_json, second_json) 17 | 18 | def assert_card_json(self, card, front='', back='', 19 | input_format='text', tags=None): 20 | """Assert that card serializes to JSON with the given properties.""" 21 | card_obj = {'front': front, 22 | 'back': back, 23 | 'input_format': input_format, 24 | } 25 | if tags is not None: 26 | card_obj['tags'] = tags 27 | 28 | expected = json.dumps({'format': 'JSONCardArchive', 29 | 'version': 'v1', 30 | 'cards': [card_obj], 31 | }) 32 | self.assert_json(expected, self._to_json(card)) 33 | 34 | def test_empty_properties(self): 35 | # Default values. 36 | self.assert_card_json(models.Card(), front='', back='', 37 | input_format='text', tags=None) 38 | 39 | # Null handling. Setting "tags" to None is an error in NDB, though. 40 | card = models.Card(front=None, back=None, input_format=None, tags=[]) 41 | self.assert_card_json(card, front='', back='', 42 | input_format='text', tags=None) 43 | 44 | def test_no_cards(self): 45 | self.assert_json( 46 | api._JSONCardArchive().to_json(), 47 | '{"cards": [], "version": "v1", "format": "JSONCardArchive"}') 48 | 49 | def test_simple_card(self): 50 | kwargs = {'front': 'Hello', 'back': 'World', 'tags': ['in-text']} 51 | self.assert_card_json(models.Card(**kwargs), **kwargs) 52 | 53 | # Now again but with markdown. 54 | kwargs = {'front': 'Hello\n====', 'back': '* World', 55 | 'tags': ['in-text'], 'input_format': 'markdown'} 56 | self.assert_card_json(models.Card(**kwargs), **kwargs) 57 | 58 | 59 | class CardBulkImportTest(unittest.TestCase): 60 | def test_invalid_metadata(self): 61 | # Empty JSON is missing a necessary attribute. 62 | with self.assertRaises(AssertionError): 63 | api._JSONCardArchive.from_json('{}') 64 | # Missing "version" attribute. 65 | with self.assertRaises(AssertionError): 66 | api._JSONCardArchive.from_json('{"format":"JSONCardArchive"}') 67 | 68 | def test_valid_empty_archive(self): 69 | archive = api._JSONCardArchive.from_json(""" 70 | {"format": "JSONCardArchive", 71 | "version": "v1", 72 | "cards": []} 73 | """) 74 | self.assertIsNotNone(archive) 75 | self.assertTrue(len(archive.get_cards()) == 0) 76 | 77 | def test_valid_archive(self): 78 | archive = api._JSONCardArchive.from_json(""" 79 | {"format": "JSONCardArchive", 80 | "version": "v1", 81 | "cards": [{"front": "First side.", 82 | "back": "Second side.", 83 | "input_format": "text", 84 | "tags": ["keep-it-simple"] 85 | }] 86 | } 87 | """) 88 | self.assertIsNotNone(archive) 89 | self.assertTrue(len(archive.get_cards()) == 1) 90 | card = archive.get_cards()[0] 91 | self.assertEqual(card.front, "First side.") 92 | self.assertEqual(card.back, "Second side.") 93 | self.assertEqual(card.input_format, "text") 94 | self.assertEqual(card.tags, ["keep-it-simple"]) 95 | -------------------------------------------------------------------------------- /webapp/app.yaml: -------------------------------------------------------------------------------- 1 | application: khan-ssrs 2 | version: 1 3 | runtime: python27 4 | api_version: 1 5 | threadsafe: true 6 | libraries: 7 | - name: webapp2 8 | version: latest 9 | - name: jinja2 10 | version: latest 11 | 12 | handlers: 13 | - url: /static 14 | static_dir: static 15 | - url: /fonts 16 | static_dir: fonts 17 | - url: /.* 18 | script: main.application 19 | 20 | builtins: 21 | - remote_api: on 22 | 23 | skip_files: 24 | # These are App Engine's default values. 25 | - ^(.*/)?#.*#$ 26 | - ^(.*/)?.*~$ 27 | - ^(.*/)?.*\.py[co]$ 28 | - ^(.*/)?.*/RCS/.*$ 29 | - ^(.*/)?\..*$ 30 | # These are needed in development but aren't used in production. 31 | - ^(.*/)?node_modules$ 32 | - ^(.*/)?package\.json$ 33 | -------------------------------------------------------------------------------- /webapp/fonts/Flat-UI-Icons.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kohlmeier/memfinity/3860985e29b203f0569f60eea68ffb22aaf34b1f/webapp/fonts/Flat-UI-Icons.eot -------------------------------------------------------------------------------- /webapp/fonts/Flat-UI-Icons.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | This is a custom SVG font generated by IcoMoon. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 23 | 28 | 30 | 34 | 37 | 41 | 46 | 51 | 56 | 58 | 62 | 68 | 72 | 76 | 79 | 83 | 90 | 93 | 95 | 112 | 114 | 117 | 118 | 123 | 127 | 130 | 133 | 138 | 139 | 140 | -------------------------------------------------------------------------------- /webapp/fonts/Flat-UI-Icons.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kohlmeier/memfinity/3860985e29b203f0569f60eea68ffb22aaf34b1f/webapp/fonts/Flat-UI-Icons.ttf -------------------------------------------------------------------------------- /webapp/fonts/Flat-UI-Icons.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kohlmeier/memfinity/3860985e29b203f0569f60eea68ffb22aaf34b1f/webapp/fonts/Flat-UI-Icons.woff -------------------------------------------------------------------------------- /webapp/frontend/about.jsx: -------------------------------------------------------------------------------- 1 | /** @jsx React.DOM */ 2 | /* 3 | * Interface for the About page 4 | */ 5 | var React = require('react'); 6 | var BackboneMixin = require('./backbonemixin.js'); 7 | var models = require('./models.js'); 8 | 9 | var About = React.createClass({ 10 | render: function() { 11 | return
12 |
13 |
14 |
15 |
16 |
17 |

Memfinity

18 |

A social spaced-repetition system.

19 |

Learn with your friends. Remember forever.

20 |
21 | 27 |
28 |
29 |
30 |
31 |
32 |

Remember all the things! We all encounter facts and information that we'd love to remember permanently, and now it's possible. Enter Memfinity, a powerful tool for personalized and social learning.

33 |
34 |

Create flashcards on-the-fly.

35 |

Memfinity lets you create your own flashcards right in your browser. In addition to the website, Memfinity offers a Chrome browser extension for even faster card creation. See a new vocab word while reading online? Wanna remember that incredible statistic? Stoked about an awesome keyboard shortcut, math concept, or piece of ridculous trivia? With card creation this simple, it takes just seconds to file information away for permanent recall.

36 |
37 |
38 |

Turbocharge your brain.

39 |

Spaced repetition algorithms are a game changer for personal learning. Each time you practice a card, you can rate it as "easy" or "hard". Memfinity will automatically adapt and schedule your next review of that card, maximizing your efficiency and making it possible to ensure all concepts will eventually become part of your permanent knowledge.

40 |
41 |
42 |

Follow friends, and see what the world is learning.

43 |

Memfinity is social, so you can also follow the public learning activity of people that share your learning interests. See a feed of what others care about learning, and efforlessly create a copy of cards that you want to learn, too.

44 |
45 |
46 |

Built for openess.

47 |

48 |

Memfinity is built from the ground up as a web-service. That means the open source community can create new apps for phones, browsers, or any device. Also, with Memfinity your data is never held hostage. We're open source, and you're always free to host your own personal version of Memfinity. And by learning with Memfinity, you're not only helping yourself learn; you're opening up doors for world-class research on memory and how people learn.

49 |
50 |
51 |

Still wanna know more? Check out our FAQ page.

52 |
53 |
54 | 55 |
56 |
; 57 | } 58 | }); 59 | 60 | 61 | module.exports = About; 62 | -------------------------------------------------------------------------------- /webapp/frontend/backbonemixin.js: -------------------------------------------------------------------------------- 1 | var _validateModelArray = function(backboneModels) { 2 | if (!_.isArray(backboneModels)) { 3 | throw new Error('getBackboneModels must return an array, ' + 4 | 'get this ' + backboneModels + ' out of here.'); 5 | } 6 | } 7 | 8 | /** 9 | * BackboneMixin - automatic binding and unbinding for react classes mirroring 10 | * backbone models and views. Example: 11 | * 12 | * var Model = Backbone.Model.extend({ ... }); 13 | * var Collection = Backbone.Collection.extend({ ... }); 14 | * 15 | * var Example = React.createClass({ 16 | * mixins: [BackboneMixin], 17 | * getBackboneModels: function() { 18 | * return [this.model, this.collection]; 19 | * } 20 | * }); 21 | * 22 | * List the models and collections that your class uses and it'll be 23 | * automatically `forceUpdate`-ed when they change. 24 | * 25 | * This binds *and* unbinds the events. 26 | */ 27 | var BackboneMixin = { 28 | // Passing this.forceUpdate directly to backbone.on will cause it to call 29 | // forceUpdate with the changed model, which we don't want 30 | _backboneForceUpdate: function() { 31 | this.forceUpdate(); 32 | }, 33 | componentDidMount: function() { 34 | // Whenever there may be a change in the Backbone data, trigger a 35 | // reconcile. 36 | var backboneModels = this.getBackboneModels(); 37 | _validateModelArray(backboneModels); 38 | backboneModels.map(function(backbone) { 39 | // The add, remove, and reset events are never fired for 40 | // models, as far as I know. 41 | backbone.on('add change remove reset', this._backboneForceUpdate, 42 | this); 43 | }.bind(this)); 44 | }, 45 | componentWillUnmount: function() { 46 | var backboneModels = this.getBackboneModels(); 47 | _validateModelArray(backboneModels); 48 | // Ensure that we clean up any dangling references when the 49 | // component is destroyed. 50 | backboneModels.map(function(backbone) { 51 | // Remove all callbacks for all events with `this` as a context 52 | backbone.off('add change remove reset', this._backboneForceUpdate, 53 | this); 54 | }.bind(this)); 55 | } 56 | }; 57 | 58 | module.exports = BackboneMixin; 59 | -------------------------------------------------------------------------------- /webapp/frontend/editor.jsx: -------------------------------------------------------------------------------- 1 | /** @jsx React.DOM */ 2 | /* 3 | * Interface for card editing 4 | */ 5 | var React = require('react'); 6 | var Router = require('react-nested-router'); 7 | var BackboneMixin = require('./backbonemixin.js'); 8 | var models = require('./models.js'); 9 | 10 | // props: submitCardData 11 | // stats: [a dict representing fields which have been changed] 12 | var EditorForm = React.createClass({ 13 | getInitialState: function() { 14 | return {isEnabled: true}; 15 | }, 16 | handleChange: function(field, event) { 17 | var state = {}; 18 | if (field === 'reversible' || field === 'private') { 19 | state[field] = event.target.checked; 20 | } else { 21 | state[field] = event.target.value; 22 | } 23 | this.setState(state); 24 | }, 25 | handleTagsInputChange: function(elt) { 26 | // The tagsInput arguments aren't helpful or consistent, so we 27 | // go straight to the source. 28 | // TODO(chris): this is called once per tag on initialization, 29 | // which is silly. 30 | var tags = this.refs.tagsinput.getDOMNode().value.split(','); 31 | this.setState({tags: tags}); 32 | }, 33 | handleSubmit: function() { 34 | this.setState({isEnabled: false}); 35 | this.props.submitCardData(this.state); 36 | }, 37 | render: function() { 38 | var tagsArray = this.props.cardModel.tags; 39 | var tagsCSV = tagsArray ? tagsArray.join(", ") : ""; 40 | var inputFormat = (this.props.cardModel.input_format 41 | ? this.props.cardModel.input_format.toLowerCase() 42 | : "text"); 43 | return
44 |
45 | 46 |