Rietveld Code Review Tool
Help | Bug tracker | Discussion group | Source code

Side by Side Diff: options.js

Issue 6088024630755328: issue 1526 - Implement new options page design for Chrome/Opera/Safari (Closed)
Patch Set: Created Jan. 28, 2015, 3:59 p.m.
Left:
Right:
Use n/p to move between diff chunks; N/P to move between comments.
Jump to:
View unified diff | Download patch
OLDNEW
(Empty)
1 /*
2 * This file is part of Adblock Plus <https://adblockplus.org/>,
3 * Copyright (C) 2006-2015 Eyeo GmbH
4 *
5 * Adblock Plus is free software: you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License version 3 as
7 * published by the Free Software Foundation.
8 *
9 * Adblock Plus is distributed in the hope that it will be useful,
10 * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 * GNU General Public License for more details.
13 *
14 * You should have received a copy of the GNU General Public License
15 * along with Adblock Plus. If not, see <http://www.gnu.org/licenses/>.
16 */
17
18 "use strict";
19
20 (function()
21 {
22 var optionSubscriptions = {};
23 var acceptableAdsUrl = null;
24
25 function onDOMLoaded()
26 {
27 initTabs();
28 updateVersionNumber();
29 updateShareLink();
30 populateLists();
31
32 E("find-language").setAttribute("placeholder", ext.i18n.getMessage("options_ modal_language_find"));
33 setLinks("block-element-explanation", "#");
34
35 E("add-blocking-list").addEventListener("click", Modal.open, false);
36 E("add-website-language").addEventListener("click", Modal.open, false);
37 E("modal-close").addEventListener("click", Modal.close, false);
38 E("whitelisting-add-icon").addEventListener("click", whitelistDomainBtnClick , false);
39 E("whitelisting-add-btn").addEventListener("click", whitelistDomainBtnClick, false);
40 E("whitelisting-enter-icon").addEventListener("click", whitelistDomainBtnCli ck, false);
41 E("whitelisting-textbox").addEventListener("keypress", function(e) {
42 if (e.keyCode == 13)
43 whitelistDomainBtnClick();
44 }, false);
45 E("whitelisting-cancel-btn").addEventListener("click", function(){
46 E("whitelisting-textbox").value = "";
47 }, false);
48 E("allow-whitelist-cb").addEventListener("click", toggleAcceptableAds, false );
49 E("import-blockingList-btn").addEventListener("click", importListBtnCLick, f alse);
50 E("edit-ownBlockingList-btn").addEventListener("click", editOwnRulsBtnClick, false);
51 E("find-language").addEventListener("keyup", searchLanguage, false);
52 }
53
54 function initTabs()
55 {
56 var showContent = function(tab)
57 {
58 var tab = tab.querySelector(".tabs li.active");
59 if (tab.dataset.show)
60 E(tab.dataset.show).classList.add("active");
Thomas Greiner 2015/01/30 13:55:52 Using CSS classes instead of modifying the style a
saroyanm 2015/02/13 10:57:11 Yes, good point.
61 };
62 var optionList = document.querySelectorAll('.tabs li[data-show]');
63 for (var i = 0; i < optionList.length; ++i)
64 {
65 optionList[i].addEventListener("click", function(ev)
66 {
67 var tab = this.parentNode.querySelector(".active");
68 tab.classList.remove("active");
69 this.classList.add("active");
70 E(tab.dataset.show).classList.remove("active");
71 showContent(this.parentNode);
72 }, false);
73 }
74 showContent(E("main-navigation-tabs"));
75 showContent(E("blocking-list-tabs"));
76 }
77
78 var Modal =
79 {
80 open: function (content)
81 {
82 var modal = E("modal");
83 var content = E(this && this.dataset ? this.dataset.show : content);
84 content.classList.add("active");
85 document.body.classList.add("modal-active");
Thomas Greiner 2015/01/30 13:55:52 You could get rid of this entire function by setti
saroyanm 2015/02/13 10:57:11 Done.
86 if (content.dataset.title)
87 E("modal-title").innerHTML = ext.i18n.getMessage(content.dataset.title);
88 modal.style.marginTop = -(modal.clientHeight/2)+"px";
89 },
90 close: function ()
91 {
92 var contents = E("modal-content").childNodes;
93 for (var i = 0; i < contents.length; ++i)
94 {
95 if (contents[i].style)
96 contents[i].classList.remove("active");
97 }
98 document.body.classList.remove("modal-active");
99 }
100 }
101
102 function populateLists()
103 {
104 ext.backgroundPage.sendMessage({
105 type: "subscriptions.get",
106 special: true
107 }, function(subscriptions)
108 {
109 for (var i = 0; i < subscriptions.length; i++)
110 {
111 ext.backgroundPage.sendMessage({
112 type: "filters.get",
113 subscriptionUrl: subscriptions[i].url
114 }, function(filters)
115 {
116 var whitelistArray = [];
117 for (var i = 0; i < filters.length; i++)
118 {
119 var match = filters[i].text.match(/^@@\|\|([^\/:]+)\^\$document$/);
120 if (match[1])
121 {
122 whitelistArray.push(match[1]);
123 }
124 else
125 {
126 // TODO: add `filters[i].text` to list of custom filters
127 }
128 }
129
130 if (whitelistArray.length > 0)
131 {
132 whitelistArray.sort();
133 for (var i = 0; i < whitelistArray.length; i++)
134 {
135 var domain = whitelistArray[i];
136 E("whitelisting-table").appendChild(createWhitelistElem(domain));
137 }
138 }
139 });
140 }
141 });
142
143 loadRecommendations(function(recommends)
144 {
145 ext.backgroundPage.sendMessage({
146 type: "subscriptions.get",
147 downloadable: true
148 }, function(subscriptions)
149 {
150 getAcceptableAdsURL(function(url)
151 {
152 acceptableAdsUrl = url;
153 for (var i = 0; i < subscriptions.length; i++)
154 {
155 if (subscriptions[i].url == acceptableAdsUrl)
156 {
157 E("allow-whitelist-cb").checked = !subscriptions[i].disabled;
158 continue;
159 }
160
161 var subscription = recommends[subscriptions[i].url];
162 if (!subscription)
163 recommends[subscriptions[i].url] = subscriptions[i];
164 else
165 {
166 subscription.disabled = subscriptions[i].disabled;
167 if (subscription.type == "ads")
168 subscription.isAdded = true;
169 }
170 }
171 for (var key in recommends)
172 addOptionItem(recommends[key]);
173 });
174 });
175 });
176 }
177
178 function loadRecommendations(callback)
179 {
180 var recommendations = {};
181 var request = new XMLHttpRequest();
182 request.open("GET", "subscriptions.xml");
Thomas Greiner 2015/01/30 13:55:52 This function call is missing the third parameter
saroyanm 2015/02/13 10:57:11 Done.
183 request.onload = function()
184 {
185 var list = document.getElementById("subscriptionSelector");
186 var elements = request.responseXML.documentElement.getElementsByTagName("s ubscription");
187 for (var i = 0; i < elements.length; i++)
188 {
189 var element = elements[i];
190 var subscription = {};
Thomas Greiner 2015/01/30 13:55:52 Use `var subscription = Object.create(null);` inst
saroyanm 2015/02/13 10:57:11 Done.
191 subscription.title = element.getAttribute("title");
192 subscription.url = element.getAttribute("url");
193 subscription.disabled = true;
194 var prefix = element.getAttribute("prefixes");
195 if (prefix)
196 {
197 subscription.prefixes = element.getAttribute("prefixes");
198 subscription.type = "ads";
199 subscription.display = ext.i18n.getMessage("options_language_"+subscri ption.prefixes.replace(/,/g, '_'));
Thomas Greiner 2015/01/30 13:55:52 This line doesn't need to be that long. I'd sugges
saroyanm 2015/02/13 10:57:11 Done.
200 }
201 else
202 subscription.display = element.getAttribute("specialization");
203
204 var popular = element.getAttribute("popular");
Thomas Greiner 2015/01/30 13:55:52 It's safer to convert this string into a boolean.
saroyanm 2015/02/13 10:57:11 Done.
205 if (popular)
206 subscription.popular = element.getAttribute("popular");
207
208 recommendations[subscription.url] = subscription;
209 }
210 optionSubscriptions = recommendations;
211 callback(recommendations);
212 }
213 request.send();
214 }
215
216 function searchLanguage()
217 {
218 var searchVal = this.value;
219 var items = E("all-lang-table").childNodes;
220 for (var i = 0; i < items.length; ++i)
221 {
222 var item = items[i];
223 var language = item.getElementsByTagName("span")[1].innerHTML;
Thomas Greiner 2015/01/30 13:55:52 Don't rely on the content of an element because it
224 if (language.toLowerCase().indexOf(searchVal.toLowerCase()) > -1)
225 item.style.display = "block";
226 else
227 item.style.display = "none";
228 }
229 }
230
231 function addOptionItem(subscription)
232 {
233 var display = subscription.display ? subscription.display : subscription.tit le;
Thomas Greiner 2015/01/30 13:55:52 You can reduce this down to `var display = subscri
234 var getPossition = function(elements, subscription)
Thomas Greiner 2015/01/30 13:55:52 This variable name is spelled incorrectly.
Thomas Greiner 2015/01/30 13:55:52 You were able to use a much nicer approach for pop
saroyanm 2015/02/13 10:57:11 Changed to use arrays for sorting.
235 {
236 var localArray = [];
237 for (var i = 0; i < elements.length; i++)
238 {
239 var elem = elements[i];
240 localArray.push(elem);
241 }
242
243 localArray.push(subscription);
244 return localArray.sort(function(a, b) {
245 var aPopular = a.getElementsByClassName("popular").length > 0;
246 var bPopular = b.getElementsByClassName("popular").length > 0;
247 if(aPopular == bPopular)
248 {
249 var aValue = a.getElementsByClassName("display")[0].innerHTML.toLowerC ase();
250 var bValue = b.getElementsByClassName("display")[0].innerHTML.toLowerC ase();
251 if (aValue < bValue)
252 return -1;
253 if (aValue > bValue)
254 return 1;
255 return 0;
256 }
257 if (aPopular == "true")
258 return 1;
259 else
260 return -1;
261 }).indexOf(subscription);
262 };
263
264 var checkBoxClick = function(e)
265 {
266 e.preventDefault();
267 toggleSubscription(subscription);
268 };
269
270 var appendToTable = function(table, elem)
271 {
272 var elements = table.getElementsByTagName("li");
273 if (elements.length == 0)
274 table.appendChild(elem);
275 else
276 {
277 var possition = getPossition(elements, elem);
Thomas Greiner 2015/01/30 13:55:52 This variable name is spelled incorrectly.
278 table.insertBefore(elem, table.childNodes[possition]);
279 }
280 };
281
282 if (subscription.type && subscription.type == "ads")
283 {
284 if (!subscription.isAdded)
285 {
286 var listElem = generateListElement(subscription, subscription.display, " add");
287 listElem.dataset.url = subscription.url;
288 listElem._subscription = subscription;
289 listElem.getElementsByTagName("button")[0].addEventListener("click", fun ction(e)
290 {
291 addSubscription(this.dataset.url);
292 }.bind(listElem), false);
293 appendToTable(E("all-lang-table"), listElem);
294 }
295 else
296 {
297 var listElem = generateListElement(subscription, display, "checkbox");
298 listElem.dataset.url = subscription.url;
299 listElem._subscription = subscription;
300 listElem.getElementsByTagName("input")[0].addEventListener("click", chec kBoxClick, false);
301 appendToTable(E("blocking-languages-table"), listElem);
302 var listElem = generateListElement(subscription, display);
303 listElem.dataset.url = subscription.url;
304 listElem._subscription = subscription;
305 appendToTable(E("blocking-languages-modal-table"), listElem);
306 }
307 }
308 else
309 {
310 var listElem = generateListElement(subscription, display, "checkbox");
311 listElem.dataset.url = subscription.url;
312 listElem._subscription = subscription;
313 listElem.getElementsByTagName("input")[0].addEventListener("click", checkB oxClick, false);
314 appendToTable(E("further-list-table"), listElem);
315 }
316 }
317
318 function addLanguageSubscription(subscription)
319 {
320 var optionSubscription = getOptionSubscription(subscription.url);
321 var elems = getElementsByUrl(subscription.url);
322 for (var i = 0; i < elems.length; i++)
Thomas Greiner 2015/01/30 13:55:52 Why are you removing elements when adding a subscr
saroyanm 2015/02/13 10:57:11 The item will stay there when it's added while we
323 elems[i].parentNode.removeChild(elems[i]);
324 optionSubscription.isAdded = true;
325 optionSubscription.disabled = false;
326 addOptionItem(optionSubscription);
327 }
328
329 function createWhitelistElem(domain)
330 {
331 var listElem = generateListElement(null, domain, "delete");
332 listElem.dataset.domain = domain;
333 listElem.getElementsByTagName("button")[0].addEventListener("click", removeW hitelistBtnClick.bind(listElem), false);
334 return listElem;
335 }
336
337 function addFurtherList(subscription)
Thomas Greiner 2015/01/30 13:55:52 We tend to call them "custom subscriptions" in our
saroyanm 2015/02/13 10:57:11 Done.
338 {
339 var optionSubscription = getOptionSubscription(subscription.url);
340 if (optionSubscription)
341 {
342 optionSubscription.disabled = false;
343 addOptionItem(optionSubscription);
344 }
345 else
346 {
347 optionSubscriptions[subscription.url] = subscription;
348 addOptionItem(subscription);
349 }
350 }
351
352 function updateSubscriptionState(subscription, state)
353 {
354 var elem = getElementsByUrl(subscription.url);
355 if (elem.length > 0)
356 {
357 for (var i = 0; i < elem.length; i++)
Thomas Greiner 2015/01/30 13:55:52 A subscription should only be shown once on the pa
saroyanm 2015/02/13 10:57:11 if you mean tab, then Language subscriptions shown
358 {
359 var checkbox = elem[i].getElementsByTagName("input")[0];
360 if (checkbox)
361 checkbox.checked = state;
Thomas Greiner 2015/01/30 13:55:52 Might make sense to implement some simple data-bin
362 }
363 }
364 else
365 {
366 if (subscription.url == acceptableAdsUrl)
367 E("allow-whitelist-cb").checked = state;
368 else
369 addFurtherList(subscription);
370 }
371 }
372
373 function getElementsByUrl(url)
374 {
375 return document.querySelectorAll("[data-url='"+url+"']");
376 }
377
378 function generateListElement(subscription, text, type)
379 {
380 var list = document.createElement("li");
381 if (type == "checkbox")
382 {
383 var input = document.createElement("input");
384 input.setAttribute("type", "checkbox");
385 if (subscription.disabled == false)
386 input.checked = true;
387 list.appendChild(input);
388 }
389 else if (type == "delete")
390 {
391 var button = document.createElement("button");
392 button.setAttribute("class", "delete");
393 list.appendChild(button);
394 }
395 else if (type == "add")
396 {
397 var button = document.createElement("button");
398 button.setAttribute("class", "button-add");
399 var span = document.createElement("span");
400 span.innerHTML = "+" + ext.i18n.getMessage("options_btn_add");
401 button.appendChild(span);
402 list.appendChild(button);
403 }
404 var span = document.createElement("span");
405 span.setAttribute("class", "display");
406 span.innerHTML = text;
407 list.appendChild(span);
408
409 if (subscription && subscription.popular == "true")
410 {
411 var popular = document.createElement("span");
412 popular.setAttribute("class", "popular");
413 popular.innerHTML = "popular";
414 list.appendChild(popular);
415 }
416
417 return list;
418 }
419
420 function getOptionSubscription(url)
421 {
422 return optionSubscriptions[url];
423 }
424
425 function importListBtnCLick()
426 {
427 var url = E("blockingList-textbox").value;
428 addSubscription(url);
429 Modal.close();
430 }
431
432 function whitelistDomainBtnClick()
433 {
434 var domain = E("whitelisting-textbox").value;
435 if (domain)
436 addWhitelistedDomain(domain);
437 }
438
439 function removeWhitelistBtnClick()
440 {
441 removeWhitelistedDomain(this.dataset.domain);
442 }
443
444 function editOwnRulsBtnClick()
445 {
446
447 }
448
449 function showAddSubscriptionDialog(action, subscription)
Thomas Greiner 2015/01/30 13:55:52 The `action` parameter is unused.
saroyanm 2015/02/13 10:57:11 Done.
450 {
451 E("blockingList-textbox").value = subscription.url;
452 Modal.open("further-blocking-modal");
453 }
454
455 function getAcceptableAdsURL(callback)
456 {
457 ext.backgroundPage.sendMessage({
458 type: "prefs.get",
459 key: "subscriptions_exceptionsurl"
460 }, function(value)
461 {
462 getAcceptableAdsURL = function(callback)
463 {
464 callback(value);
465 }
466 getAcceptableAdsURL(callback);
467 });
468 }
469
470 function toggleSubscription(subscription)
471 {
472 ext.backgroundPage.sendMessage({
473 type: "subscriptions.toggle",
474 url: subscription.url,
475 title: subscription.title,
476 homepage: subscription.homepage
477 });
478 }
479
480 function toggleAcceptableAds(e)
481 {
482 e.preventDefault();
483 var acceptableCheckbox = this;
Thomas Greiner 2015/01/30 13:55:52 This variable is not necessary. You can access the
484 getAcceptableAdsURL(function(url)
485 {
486 var isChecked = !acceptableCheckbox.checked;
487 var title = "Allow non-intrusive advertising";
Thomas Greiner 2015/01/30 13:55:52 Those two values are only used once so no need to
488 if (isChecked)
489 removeSubscription(url);
490 else
491 addSubscription(url, title);
492 });
493 }
494
495 function addSubscription(url, title, homepage)
496 {
497 var message = {
498 type: "subscriptions.add",
499 url: url
500 };
501 if (title)
502 message.title = title;
503 if (homepage)
504 message.homepage = homepage;
505
506 ext.backgroundPage.sendMessage(message);
507 }
508
509 function removeSubscription(url)
510 {
511 ext.backgroundPage.sendMessage({
512 type: "subscriptions.remove",
513 url: url
514 });
515 }
516
517 function addWhitelistedDomain(domain)
518 {
519 ext.backgroundPage.sendMessage({
520 type: "filters.add",
521 text: "@@||" + domain.toLowerCase() + "^$document"
522 });
523 }
524
525 function removeWhitelistedDomain(domain)
526 {
527 ext.backgroundPage.sendMessage({
528 type: "filters.remove",
529 text: "@@||" + domain.toLowerCase() + "^$document"
530 });
531 }
532
533 function onFilterMessage(action, filter)
534 {
535 switch (action)
536 {
537 case "added":
538 var match = filter.text.match(/^@@\|\|([^\/:]+)\^\$document$/);
539 if (match[1])
Thomas Greiner 2015/01/30 13:55:52 `match` could be null which can cause an exception
saroyanm 2015/02/13 10:57:11 Done.
540 {
541 var whitelistTbl = E("whitelisting-table");
542 var items = whitelistTbl.getElementsByClassName("display");
543 var domains = [];
544 for (var i = 0; i < items.length; i++)
545 {
546 domains.push(items[i].innerHTML);
Thomas Greiner 2015/01/30 13:55:52 The content of an HTML element might change so bet
saroyanm 2015/02/13 10:57:11 Done.
547 }
548 var domain = match[1];
549 domains.push(domain);
550 domains.sort();
551
552 whitelistTbl.insertBefore(createWhitelistElem(domain), whitelistTbl.ch ildNodes[domains.indexOf(domain)]);
553 E("whitelisting-textbox").value = "";
554 }
555 else
556 {
557 // TODO: add `filters[i].text` to list of custom filters
558 }
559 break;
560 case "loaded":
561 populateLists();
562 break;
563 case "removed":
564 var match = filter.text.match(/^@@\|\|([^\/:]+)\^\$document$/);
565 if (match[1])
Thomas Greiner 2015/01/30 13:55:52 As mentioned above, `match` could be null so bette
saroyanm 2015/02/13 10:57:11 Done.
566 {
567 var elem = document.querySelector("[data-domain='"+match[1]+"']");
Thomas Greiner 2015/01/30 13:55:52 We're not restricted by bandwidth so you don't nee
Thomas Greiner 2015/01/30 13:55:52 Nit: Missing spaces around `+`s
568 elem.parentNode.removeChild(elem);
569 }
570 break;
571 }
572 }
573
574 function onSubscriptionMessage(action, subscription)
575 {
576 switch (action)
577 {
578 case "added":
579 var optionSubscription = getOptionSubscription(subscription.url);
580 if (optionSubscription)
581 {
582 var isAdsType = optionSubscription.type && optionSubscription.type == "ads";
583 if (isAdsType && !optionSubscription.isAdded)
584 addLanguageSubscription(subscription);
585 else
586 updateSubscriptionState(subscription, true);
587 }
588 else if (subscription.url == acceptableAdsUrl)
589 updateSubscriptionState(subscription, true);
590 else
591 addFurtherList(subscription);
592 break;
593 case "disabled":
594 updateSubscriptionState(subscription, false);
595 break;
596 case "homepage":
597 // TODO: NYI
598 break;
599 case "removed":
600 updateSubscriptionState(subscription, false);
601 break;
602 case "title":
603 // TODO: NYI
604 break;
605 }
606 }
607
608 function updateShareLink()
609 {
610 ext.backgroundPage.sendMessage({
611 type: "filters.blocked",
612 url: "https://platform.twitter.com/widgets/",
613 requestType: "SCRIPT",
614 docDomain: "adblockplus.org",
615 thirdParty: true
616 }, function(blocked)
617 {
618 // TODO: modify "share" link accordingly
619 });
620 }
621
622 function updateVersionNumber()
623 {
624 ext.backgroundPage.sendMessage({
625 method: "app.get",
Thomas Greiner 2015/01/30 13:55:52 Nit: Adjust indentation level
saroyanm 2015/02/13 10:57:11 Done.
626 what: "addonVersion"
627 }, function(addonVersion)
628 {
629 E("abp-version").innerHTML = addonVersion;
Thomas Greiner 2015/01/30 13:55:52 You're only changing the text content so use `text
saroyanm 2015/02/13 10:57:11 Done.
630 });
631 }
632
633 function getDocLink(link, callback)
634 {
635 ext.backgroundPage.sendMessage({
636 type: "app.get",
637 what: "doclink",
638 link: link
639 }, callback);
640 }
641
642 function setLinks(id)
Thomas Greiner 2015/01/30 13:55:52 Seems like you should be able to get rid of this f
643 {
644 var element = E(id);
645 if (!element)
646 {
647 return;
648 }
649
650 var links = element.getElementsByTagName("a");
651
652 for (var i = 0; i < links.length; i++)
653 {
654 if (typeof arguments[i + 1] == "string")
655 {
656 links[i].href = arguments[i + 1];
657 links[i].setAttribute("target", "_blank");
658 }
659 else if (typeof arguments[i + 1] == "function")
660 {
661 links[i].href = "javascript:void(0);";
662 links[i].addEventListener("click", arguments[i + 1], false);
663 }
664 }
665 }
666
667 function E(id)
668 {
669 return document.getElementById(id);
670 }
671
672 ext.onMessage.addListener(function(message)
673 {
674 switch (message.type)
675 {
676 case "app.listen":
677 if (message.action == "addSubscription")
678 {
679 message.args.unshift(message.action);
Thomas Greiner 2015/01/30 13:55:52 You don't even use `message.action` in `showAddSub
saroyanm 2015/02/13 10:57:11 Done.
680 showAddSubscriptionDialog.apply(null, message.args);
Thomas Greiner 2015/01/30 13:55:52 `showAddSubscription` only requires the first argu
saroyanm 2015/02/13 10:57:11 Done.
681 }
682 break;
683 case "filters.listen":
684 message.args.unshift(message.action);
685 onFilterMessage.apply(null, message.args);
Thomas Greiner 2015/01/30 13:55:52 `onFilterMessage` only expects two parameters so l
saroyanm 2015/02/13 10:57:11 Done.
686 break;
687 case "subscriptions.listen":
688 message.args.unshift(message.action);
689 onSubscriptionMessage.apply(null, message.args);
690 break;
691 }
692 });
693
694 ext.backgroundPage.sendMessage({
695 type: "app.listen",
696 filter: ["addSubscription"]
697 });
698 ext.backgroundPage.sendMessage({
699 type: "filters.listen",
700 filter: ["added", "loaded", "removed"]
701 });
702 ext.backgroundPage.sendMessage({
703 type: "subscriptions.listen",
704 filter: ["added", "disabled", "homepage", "removed", "title"]
705 });
706
707 window.addEventListener("DOMContentLoaded", onDOMLoaded, false);
708 })();
OLDNEW

Powered by Google App Engine
This is Rietveld