1 # -*- coding: iso-8859-1 -*- 2 """ 3 MoinMoin - EventAggregatorNewEvent Action 4 5 @copyright: 2008, 2009, 2010, 2011 by Paul Boddie <paul@boddie.org.uk> 6 @copyright: 2000-2004 Juergen Hermann <jh@web.de>, 7 2003-2008 MoinMoin:ThomasWaldmann, 8 2004-2006 MoinMoin:AlexanderSchremmer, 9 2007 MoinMoin:ReimarBauer. 10 @license: GNU GPL (v2 or later), see COPYING.txt for details. 11 """ 12 13 from MoinMoin.action import ActionBase 14 from MoinMoin.Page import Page 15 from MoinMoin.PageEditor import PageEditor 16 from MoinMoin import config 17 from EventAggregatorSupport import * 18 19 try: 20 import pytz 21 except ImportError: 22 pytz = None 23 24 Dependencies = ['pages'] 25 26 # Action class and supporting functions. 27 28 class EventAggregatorNewEvent(ActionBase, ActionSupport): 29 30 "An event creation dialogue requesting various parameters." 31 32 def get_form_html(self, buttons_html): 33 _ = self._ 34 request = self.request 35 form = self.get_form() 36 37 # Handle advanced and basic forms, and enable/disable certain fields. 38 39 show_advanced = form.get("advanced") and not form.get("basic") 40 show_end_date = form.get("end-day") and not form.get("hide-end-date") or form.get("show-end-date") 41 show_times = (form.get("start-hour") or form.get("end-hour")) and not form.get("hide-times") or form.get("show-times") 42 show_location = form.get("show-location") or form.get("new-location") and not form.get("hide-location") 43 44 # Prepare the category list. 45 46 category_list = [] 47 category_pagenames = form.get("category", []) 48 49 for category_name, category_pagename in getCategoryMapping(getCategories(request), request): 50 51 selected = self._get_selected_for_list(category_pagename, category_pagenames) 52 53 # In the advanced view, populate a menu. 54 55 if show_advanced: 56 category_list.append('<option value="%s" %s>%s</option>' % ( 57 escattr(category_pagename), selected, escape(category_name))) 58 59 # In the basic view, use hidden fields. 60 61 elif selected: 62 category_list.append('<input value="%s" name="category" type="hidden" />' % escattr(category_pagename)) 63 64 # Prepare the topics list. 65 66 topics = form.get("topics", []) 67 68 if form.get("add-topic"): 69 topics.append("") 70 else: 71 for i in range(0, len(topics)): 72 if form.get("remove-topic-%d" % i): 73 del topics[i] 74 break 75 76 # Initialise month lists. 77 78 start_month_list, end_month_list = self.get_month_lists(default_as_current=1) 79 start_year_default, end_year_default = self.get_year_defaults(default_as_current=1) 80 81 # Initialise the locations list. 82 83 locations_page = getLocationsPage(request) 84 locations = getWikiDict(locations_page, request) 85 86 locations_list = [] 87 locations_list.append('<option value=""></option>') 88 89 location = (form.get("location") or form.get("new-location") or [""])[0] 90 91 # Prepare the locations list, selecting the specified location. 92 93 if locations: 94 locations_list += self.get_option_list(location, locations) or [] 95 96 locations_list.sort() 97 98 # Initialise the time regime from the location. 99 100 start_regime = form.get("start-regime", [None])[0] 101 end_regime = form.get("end-regime", [None])[0] 102 103 if not start_regime: 104 start_regime = Location(location, locations).getTimeRegime() 105 end_regime = end_regime or start_regime 106 107 # Initialise regime lists. 108 109 start_regime_list = [] 110 start_regime_list.append('<option value="">%s</option>' % escape(_("<The event location (if known)>"))) 111 end_regime_list = [] 112 end_regime_list.append('<option value="">%s</option>' % escape(_("<Same as start time>"))) 113 114 # Prepare regime lists, selecting specified regimes. 115 116 if pytz is not None: 117 start_regime_list += self.get_option_list(start_regime, pytz.common_timezones) 118 end_regime_list += self.get_option_list(end_regime, pytz.common_timezones) 119 120 # Show time zone-related information depending on various fields. 121 122 show_zone_regime = ( 123 form.get("start-regime") # have a regime 124 and not form.get("show-offsets") # are not switching to offsets 125 and not form.get("hide-zone") # are not removing zone information 126 or form.get("show-regime") # are switching to a regime 127 or form.get("show-times") and start_regime # are showing times with a regime 128 ) 129 show_zone_offsets = ( 130 form.get("start-offset") # have an offset 131 and not form.get("show-regime") # are not switching to a regime 132 and not form.get("hide-zone") # are not removing zone information 133 or form.get("show-offsets") # are switching to offsets 134 ) 135 136 show_zones = show_zone_regime or show_zone_offsets 137 138 # Permitting configuration of the template name. 139 140 template_default = getattr(request.cfg, "event_aggregator_new_event_template", "EventTemplate") 141 142 d = { 143 "buttons_html" : buttons_html, 144 "form_trigger" : escattr(self.form_trigger), 145 "form_cancel" : escattr(self.form_cancel), 146 147 "category_label" : escape(_("Categories")), 148 "category_list" : "\n".join(category_list), 149 150 "start_month_list" : "\n".join(start_month_list), 151 "end_month_list" : "\n".join(end_month_list), 152 153 "start_regime_list" : "\n".join(start_regime_list), 154 "end_regime_list" : "\n".join(end_regime_list), 155 "use_regime_label" : escape(_("Using local time in...")), 156 157 "show_end_date_label" : escape(_("Specify end date")), 158 "hide_end_date_label" : escape(_("End event on same day")), 159 160 "show_times_label" : escape(_("Specify times")), 161 "hide_times_label" : escape(_("No start and end times")), 162 163 "show_offsets_label" : escape(_("Specify UTC offsets")), 164 "show_regime_label" : escape(_("Use local time")), 165 "hide_zone_label" : escape(_("Make times apply everywhere")), 166 167 "start_label" : escape(_("Start date (day, month, year)")), 168 "start_day_default" : escattr(form.get("start-day", [""])[0]), 169 "start_year_default" : escattr(start_year_default), 170 "start_time_label" : escape(_("Start time (hour, minute, second)")), 171 "start_hour_default" : escattr(form.get("start-hour", [""])[0]), 172 "start_minute_default" : escattr(form.get("start-minute", [""])[0]), 173 "start_second_default" : escattr(form.get("start-second", [""])[0]), 174 "start_offset_default" : escattr(form.get("start-offset", [""])[0]), 175 176 "end_label" : escape(_("End date (day, month, year) - if different")), 177 "end_day_default" : escattr(form.get("end-day", [""])[0].strip() or form.get("start-day", [""])[0]), 178 "end_year_default" : escattr(end_year_default), 179 "end_time_label" : escape(_("End time (hour, minute, second)")), 180 "end_hour_default" : escattr(form.get("end-hour", [""])[0]), 181 "end_minute_default" : escattr(form.get("end-minute", [""])[0]), 182 "end_second_default" : escattr(form.get("end-second", [""])[0]), 183 "end_offset_default" : escattr(form.get("end-offset", [""])[0].strip() or form.get("start-offset", [""])[0]), 184 185 "title_label" : escape(_("Event title/summary")), 186 "title_default" : escattr(form.get("title", [""])[0]), 187 "description_label" : escape(_("Event description")), 188 "description_default" : escattr(form.get("description", [""])[0]), 189 190 "location_label" : escape(_("Event location")), 191 "locations_list" : "\n".join(locations_list), 192 "show_location_label" : escattr(_("Specify a different location")), 193 "hide_location_label" : escattr(_("Choose a known location")), 194 "new_location" : escattr(form.get("new-location", [location])[0]), 195 196 "latitude_label" : escape(_("Latitude")), 197 "latitude_default" : escattr(form.get("latitude", [""])[0]), 198 "longitude_label" : escape(_("Longitude")), 199 "longitude_default" : escattr(form.get("longitude", [""])[0]), 200 "link_label" : escape(_("External URL")), 201 "link_default" : escattr(form.get("link", [""])[0]), 202 203 "topics_label" : escape(_("Topics")), 204 "add_topic_label" : escape(_("Add topic")), 205 "remove_topic_label" : escape(_("Remove topic")), 206 207 "template_label" : escape(_("Event template")), 208 "template_default" : escattr(form.get("template", [""])[0].strip() or template_default), 209 "parent_label" : escape(_("Parent page")), 210 "parent_default" : escattr(form.get("parent", [""])[0]), 211 212 "advanced_label" : escape(_("Show advanced options")), 213 "basic_label" : escape(_("Hide advanced options")), 214 } 215 216 # Prepare the output HTML. 217 218 html = ''' 219 <input name="update-form-only" value="false" type="hidden" /> 220 <table> 221 <tr> 222 <td class="label"><label>%(title_label)s</label></td> 223 <td colspan="2"> 224 <input name="title" type="text" size="40" value="%(title_default)s" /> 225 </td> 226 </tr>''' % d 227 228 # Location options. 229 230 html += ''' 231 <tr> 232 <td class="label"><label>%(location_label)s</label></td> 233 <td colspan="2">''' % d 234 235 if not show_location: 236 html += ''' 237 <select name="location"> 238 %(locations_list)s 239 </select> 240 <input name="show-location" type="submit" value="%(show_location_label)s" />''' % d 241 242 else: 243 html += ''' 244 <input name="new-location" type="text" size="30" value="%(new_location)s" /> 245 <input name="hide-location" type="submit" value="%(hide_location_label)s" />''' % d 246 247 html += ''' 248 </td> 249 </tr>''' % d 250 251 # Latitude and longitude. 252 253 if show_location: 254 html += ''' 255 <tr> 256 <td class="label"><label>%(latitude_label)s</label></td> 257 <td colspan="2"> 258 <input name="latitude" type="text" size="40" value="%(latitude_default)s" /> 259 </td> 260 </tr> 261 <tr> 262 <td class="label"><label>%(longitude_label)s</label></td> 263 <td colspan="2"> 264 <input name="longitude" type="text" size="40" value="%(longitude_default)s" /> 265 </td> 266 </tr>''' % d 267 268 # Start date controls. 269 270 html += ''' 271 <tr> 272 <td class="label"><label>%(start_label)s</label></td> 273 <td colspan="2" style="white-space: nowrap"> 274 <input name="start-day" type="text" value="%(start_day_default)s" size="2" /> 275 <select name="start-month"> 276 %(start_month_list)s 277 </select> 278 <input name="start-year" type="text" value="%(start_year_default)s" size="4" /> 279 </td> 280 </tr>''' % d 281 282 # End date controls. 283 284 if show_end_date: 285 html += ''' 286 <tr> 287 <td class="label"><label>%(end_label)s</label></td> 288 <td colspan="2" style="white-space: nowrap"> 289 <input name="end-day" type="text" value="%(end_day_default)s" size="2" /> 290 <select name="end-month"> 291 %(end_month_list)s 292 </select> 293 <input name="end-year" type="text" value="%(end_year_default)s" size="4" /> 294 </td> 295 </tr> 296 <tr> 297 <td class="label"> 298 <input name="hide-end-date" type="submit" value="%(hide_end_date_label)s" /> 299 </td> 300 </tr>''' % d 301 else: 302 html += ''' 303 <tr> 304 <td class="label"> 305 <input name="show-end-date" type="submit" value="%(show_end_date_label)s" /> 306 </td> 307 </tr>''' % d 308 309 # Generic time information. 310 311 if show_times: 312 313 # Start time controls. 314 315 html += ''' 316 <tr> 317 <td class="label event-time-selection"><label>%(start_time_label)s</label></td> 318 <td style="white-space: nowrap" class="event-time-selection"> 319 <input name="start-hour" type="text" value="%(start_hour_default)s" size="2" maxlength="2" /> 320 <input name="start-minute" type="text" value="%(start_minute_default)s" size="2" maxlength="2" /> 321 <input name="start-second" type="text" value="%(start_second_default)s" size="2" maxlength="2" /> 322 </td>''' % d 323 324 # Offset information displayed. 325 326 if show_zone_offsets: 327 html += ''' 328 <td class="event-zone-selection"> 329 UTC <input name="start-offset" type="text" value="%(start_offset_default)s" size="6" maxlength="6" /> 330 </td>''' % d 331 332 # Regime information displayed. 333 334 elif show_zone_regime: 335 html += ''' 336 <td class="event-regime-selection"> 337 <label>%(use_regime_label)s</label><br/> 338 <select name="start-regime"> 339 %(start_regime_list)s 340 </select> 341 </td>''' % d 342 343 html += ''' 344 </tr>''' 345 346 # End time controls. 347 348 html += ''' 349 <tr> 350 <td class="label event-time-selection"><label>%(end_time_label)s</label></td> 351 <td style="white-space: nowrap" class="event-time-selection"> 352 <input name="end-hour" type="text" value="%(end_hour_default)s" size="2" maxlength="2" /> 353 <input name="end-minute" type="text" value="%(end_minute_default)s" size="2" maxlength="2" /> 354 <input name="end-second" type="text" value="%(end_second_default)s" size="2" maxlength="2" /> 355 </td>''' % d 356 357 # Offset information displayed. 358 359 if show_zone_offsets: 360 html += ''' 361 <td class="event-zone-selection"> 362 UTC <input name="end-offset" type="text" value="%(end_offset_default)s" size="6" maxlength="6" /> 363 </td>''' % d 364 365 # Regime information displayed. 366 367 elif show_zone_regime: 368 html += ''' 369 <td class="event-regime-selection event-end-time"> 370 <select name="end-regime"> 371 %(end_regime_list)s 372 </select> 373 </td>''' % d 374 375 # Controls for removing times. 376 377 html += ''' 378 </tr> 379 <tr> 380 <td class="label"> 381 <input name="hide-times" type="submit" value="%(hide_times_label)s" /> 382 </td> 383 <td></td> 384 <td>''' % d 385 386 # Time zone controls. 387 388 if show_zones: 389 390 # To remove zone information. 391 392 html += ''' 393 <input name="hide-zone" type="submit" value="%(hide_zone_label)s" />''' % d 394 395 # No time zone information shown. 396 397 else: 398 html += ''' 399 <input name="show-regime" type="submit" value="%(show_regime_label)s" /> 400 <input name="show-offsets" type="submit" value="%(show_offsets_label)s" />''' % d 401 402 html += ''' 403 </td> 404 </tr>''' 405 406 # Controls for adding times. 407 408 else: 409 html += ''' 410 <tr> 411 <td class="label"> 412 <input name="show-times" type="submit" value="%(show_times_label)s" /> 413 </td> 414 </tr>''' % d 415 416 417 # Various basic controls. 418 419 html += ''' 420 <tr> 421 <td class="label"><label>%(description_label)s</label></td> 422 <td colspan="2"> 423 <input name="description" type="text" size="40" value="%(description_default)s" /> 424 </td> 425 </tr> 426 <tr> 427 <td class="label"><label>%(link_label)s</label></td> 428 <td colspan="2"> 429 <input name="link" type="text" size="40" value="%(link_default)s" /> 430 </td> 431 </tr>''' % d 432 433 # Topics. 434 435 for i, topic in enumerate(topics): 436 d["topic"] = escattr(topic) 437 d["topic_number"] = i 438 html += ''' 439 <tr> 440 <td class="label"><label>%(topics_label)s</label></td> 441 <td colspan="2"> 442 <input name="topics" type="text" size="20" value="%(topic)s" /> 443 <input name="remove-topic-%(topic_number)s" type="submit" value="%(remove_topic_label)s" /> 444 </td> 445 </tr>''' % d 446 447 html += ''' 448 <tr> 449 <td></td> 450 <td colspan="2"> 451 <input name="add-topic" type="submit" value="%(add_topic_label)s" /> 452 </td> 453 </tr>''' % d 454 455 # Advanced options. 456 457 if show_advanced: 458 html += ''' 459 <tr> 460 <td></td> 461 <td colspan="2"> 462 <input name="basic" type="submit" value="%(basic_label)s" /> 463 <input name="advanced" type="hidden" value="true" /> 464 </td> 465 </tr> 466 <tr> 467 <td class="label"><label>%(category_label)s</label></td> 468 <td colspan="2" class="content"> 469 <select multiple="multiple" name="category"> 470 %(category_list)s 471 </select> 472 </td> 473 </tr> 474 <tr> 475 <td class="label"><label>%(template_label)s</label></td> 476 <td colspan="2"> 477 <input name="template" type="text" size="40" value="%(template_default)s" /> 478 </td> 479 </tr> 480 <tr> 481 <td class="label"><label>%(parent_label)s</label></td> 482 <td colspan="2"> 483 <input name="parent" type="text" size="40" value="%(parent_default)s" /> 484 </td> 485 </tr> 486 <tr> 487 <td></td> 488 <td colspan="2" class="buttons"> 489 %(buttons_html)s 490 </td> 491 </tr> 492 </table>''' % d 493 else: 494 html += ''' 495 <tr> 496 <td></td> 497 <td colspan="2"> 498 <input name="advanced" type="submit" value="%(advanced_label)s" /> 499 %(category_list)s 500 <input name="parent" type="hidden" value="%(parent_default)s" /> 501 <input name="template" type="hidden" value="%(template_default)s" /> 502 </td> 503 </tr> 504 <tr> 505 <td></td> 506 <td colspan="2" class="buttons"> 507 %(buttons_html)s 508 </td> 509 </tr> 510 </table> 511 <script type="text/javascript"> 512 function replaceDialog(url, button) { 513 var form = findForm(); 514 var dialog = findDialog(document); 515 if (form != null && dialog != null) { 516 var xmlhttp = new XMLHttpRequest(); 517 xmlhttp.open("POST", url, false); 518 xmlhttp.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); 519 520 var requestBody = encodeURIComponent(button.name) + "=" + encodeURIComponent(button.value); 521 for (var i = 0; i < form.elements.length; i++) { 522 var element = form.elements[i]; 523 if (element.type != "submit") { 524 requestBody += "&" + encodeURIComponent(element.name) + "=" + encodeURIComponent(element.value); 525 } 526 } 527 xmlhttp.send(requestBody); 528 529 var newDialog = xmlhttp.responseText; 530 531 if (newDialog != null) { 532 dialog.parentNode.innerHTML = newDialog; 533 initForm(); 534 return false; 535 } 536 } 537 } 538 539 function findDialog(d) { 540 var elements = d.getElementsByTagName("div"); 541 for (var i = 0; i < elements.length; i++) { 542 var element = elements[i]; 543 var cls = element.getAttribute("class"); 544 if (cls == "dialog") { 545 return element; 546 } 547 } 548 return null; 549 } 550 551 function findForm() { 552 for (var i = 0; i < document.forms.length; i++) { 553 var form = document.forms[i]; 554 if (form["update-form-only"] != null) { 555 return form; 556 } 557 } 558 return null; 559 } 560 561 function initForm() { 562 var form = findForm(); 563 var url = form.getAttribute("action"); 564 form["update-form-only"].value = "true"; 565 for (var i = 0; i < form.length; i++) { 566 var element = form[i]; 567 if (element.type == "submit" && element.name != "%(form_trigger)s" && element.name != "%(form_cancel)s") { 568 element.setAttribute("onclick", "return replaceDialog('" + url + "', this);"); 569 } 570 } 571 } 572 573 initForm(); 574 </script>''' % d 575 576 return html 577 578 def do_action(self): 579 580 "Create the new event." 581 582 _ = self._ 583 form = self.get_form() 584 585 # If no title exists in the request, an error message is returned. 586 587 title = form.get("title", [None])[0] 588 template = form.get("template", [None])[0] 589 590 if not title: 591 return 0, _("No event title specified.") 592 593 if not template: 594 return 0, _("No page template specified.") 595 596 return self.create_event(self.request) 597 598 def render_msg(self, msg, msgtype): 599 600 """ 601 Render 'msg' and 'msgtype'. If 'msgtype' is "dialog" then the form is 602 rendered, and if only part of the form is being requested, the output 603 will be only the form HTML fragment and not the entire page. 604 """ 605 606 # Either render the form as a fragment of a page. 607 608 form = self.get_form() 609 update_form_only = form.get("update-form-only", ["false"])[0] == "true" 610 action_attempted = form.has_key(self.form_trigger) 611 612 if msgtype == "dialog" and update_form_only and not action_attempted: 613 send_headers = get_send_headers(self.request) 614 send_headers(["Content-Type: text/html; charset=%s" % config.charset]) 615 self.request.write(msg.render()) 616 617 # Or render the message/form within an entire page. 618 619 else: 620 ActionBase.render_msg(self, msg, msgtype) 621 622 def render_success(self, msg, msgtype): 623 624 """ 625 Render neither 'msg' nor 'msgtype' since redirection should occur 626 instead. 627 """ 628 629 pass 630 631 def create_event(self, request): 632 633 "Create an event page using the 'request'." 634 635 _ = request.getText 636 form = self.get_form() 637 638 category_pagenames = form.get("category", []) 639 description = form.get("description", [None])[0] 640 location = form.get("location", [None])[0] 641 latitude = form.get("latitude", [None])[0] 642 longitude = form.get("longitude", [None])[0] 643 link = form.get("link", [None])[0] 644 topics = form.get("topics", []) 645 646 start_regime = form.get("start-regime", [None])[0] 647 end_regime = form.get("end-regime", form.get("start-regime", [None]))[0] 648 start_offset = form.get("start-offset", [None])[0] 649 end_offset = form.get("end-offset", [None])[0] 650 651 start_zone = start_regime or start_offset 652 end_zone = end_regime or end_offset 653 654 # Validate certain fields. 655 656 title = form.get("title", [""])[0].strip() 657 template = form.get("template", [""])[0].strip() 658 parent = form.get("parent", [""])[0].strip() 659 660 if not title: 661 return 0, _("No event title specified.") 662 if not template: 663 return 0, _("No event template specified.") 664 665 try: 666 start_day = self._get_input(form, "start-day") 667 start_month = self._get_input(form, "start-month") 668 start_year = self._get_input(form, "start-year") 669 670 if not start_day or not start_month or not start_year: 671 return 0, _("A start date must be specified.") 672 673 end_day = self._get_input(form, "end-day", start_day) 674 end_month = self._get_input(form, "end-month", start_month) 675 end_year = self._get_input(form, "end-year", start_year) 676 677 except (TypeError, ValueError): 678 return 0, _("Days and years must be numbers yielding a valid date!") 679 680 try: 681 start_hour = self._get_input(form, "start-hour") 682 start_minute = self._get_input(form, "start-minute") 683 start_second = self._get_input(form, "start-second") 684 685 end_hour = self._get_input(form, "end-hour") 686 end_minute = self._get_input(form, "end-minute") 687 end_second = self._get_input(form, "end-second") 688 689 except (TypeError, ValueError): 690 return 0, _("Hours, minutes and seconds must be numbers yielding a valid time!") 691 692 start_date = DateTime( 693 (start_year, start_month, start_day, start_hour, start_minute, start_second, start_zone) 694 ) 695 start_date.constrain() 696 697 end_date = DateTime( 698 (end_year, end_month, end_day, end_hour, end_minute, end_second, end_zone) 699 ) 700 end_date.constrain() 701 702 # An elementary date ordering check. 703 704 if (start_date.as_date() != end_date.as_date() or start_date.has_time() and end_date.has_time()) and start_date > end_date: 705 start_date, end_date = end_date, start_date 706 707 event_details = { 708 "start" : str(start_date), "end" : str(end_date), 709 "title" : title, "summary" : title, 710 "description" : description, "location" : location, "link" : link, 711 "topics" : [topic for topic in topics if topic] 712 } 713 714 if latitude and longitude: 715 event_details["geo"] = latitude, longitude 716 717 # Copy the template. 718 719 template_page = PageEditor(request, template) 720 721 if not template_page.exists(): 722 return 0, _("Event template not available.") 723 724 # Use any parent page information. 725 726 full_title = getFullPageName(parent, title) 727 728 # Load the new page and replace the event details in the body. 729 730 new_page = PageEditor(request, full_title) 731 732 if new_page.exists(): 733 return 0, _("The specified page already exists. Please choose another name.") 734 735 # Complete the new page and return its body. 736 737 body = fillEventPageFromTemplate(template_page, new_page, event_details, category_pagenames) 738 739 # Open the page editor on the new page. 740 # NOTE: Replacing the revision in the request to prevent Moin from 741 # NOTE: attempting to use the queued changes page's revision. 742 # NOTE: Replacing the action and page in the request to avoid issues 743 # NOTE: with editing tickets. 744 745 request.rev = 0 746 request.action = "edit" 747 request.page = new_page 748 new_page.sendEditor(preview=body, staytop=True) 749 750 # Return success. 751 752 return 1, None 753 754 # Action function. 755 756 def execute(pagename, request): 757 EventAggregatorNewEvent(pagename, request).render() 758 759 # vim: tabstop=4 expandtab shiftwidth=4