diff --git a/data/Makefile.am b/data/Makefile.am
index 40562ca..a875c7b 100644
--- a/data/Makefile.am
+++ b/data/Makefile.am
@@ -48,6 +48,13 @@ check:
 	xmllint --valid --noout $(top_srcdir)/data/Locations.xml.in
 	$(srcdir)/check-timezones.sh $(srcdir)/Locations.xml.in
 
+### Locations.xml.in rebuild
+rebuild-locations: locationdb.sqlite update-locations.py city-fixups.pl station-fixups.pl
+	($(srcdir)/update-locations.py > Locations.xml.in.new && mv Locations.xml.in.new Locations.xml.in) || rm -f Locations.xml.in
+
+locationdb.sqlite: build-locationdb.pl major-cities.txt sources/nsd_cccc.txt sources/POP_PLACES.txt sources/US_CONCISE.txt sources/geonames_dd_dms_date_*.txt
+	$(srcdir)/build-locationdb.pl
+
 EXTRA_DIST =	\
 	$(libgweatherlocations_in_files)	\
 	$(libgweatherdtd_DATA)			\
diff --git a/data/build-locationdb.pl b/data/build-locationdb.pl
new file mode 100755
index 0000000..660c404
--- /dev/null
+++ b/data/build-locationdb.pl
@@ -0,0 +1,456 @@
+#!/usr/bin/perl -w
+
+use File::Temp "tempfile";
+use Text::Unidecode;
+
+sub myunidecode {
+  my ($text) = @_;
+  $text = unidecode($text);
+  $text =~ s/\@/a/g;
+  return $text;
+}
+
+@intlglob = glob("sources/geonames_dd_dms_date_*.txt");
+$intlsrc = $intlglob[$#intlglob];
+
+$uscitysrc = "sources/POP_PLACES.txt";
+$usbigsrc = "sources/US_CONCISE.txt";
+$majorsrc = "major-cities.txt";
+$weathersrc = "sources/nsd_cccc.txt";
+
+$locationdb = "locationdb.sqlite";
+unlink $locationdb;
+
+($states, $states_filename) = tempfile();
+binmode $states, ":utf8";
+($counties, $counties_filename) = tempfile();
+binmode $counties, ":utf8";
+($cities, $cities_filename) = tempfile();
+binmode $cities, ":utf8";
+
+# The columns in $intlsrc: (interesting ones in caps)
+# RC UFI uni LAT LONG dms_lat dms_long mgrs jog fc DSG PC CC1 ADM1 adm2 POP ELEV CC2 NT LC SHORT_FORM generic SORT_NAME FULL_NAME FULL_NAME_ND modify_date
+
+print "Reading $intlsrc\n";
+open INTLSRC, "-|:utf8", "./city-fixups.pl < $intlsrc";
+while (<INTLSRC>) {
+  # skip header, and historical records
+  next if $. == 1 || /\(\(/ || /\(historical\)/i;
+
+  @fields = split /\t/;
+  next if $#fields != 25;
+
+  ($rc, $id, $junk, $latitude, $longitude, $junk, $junk, $junk,
+   $junk, $junk, $designation, $pc, $country_code, $state_code, $junk,
+   $population, $elevation, $cc2, $name_type, $language, $short_name,
+   $junk, $flat_name, $long_name, $long_name_nd, $junk) = @fields;
+
+  # Don't use wacky complex transliteration rules for non-European
+  # names; just use the non-diacritic version of the name instead
+  if ($name_type eq 'N' && (!$rc || $rc > 2)) {
+    $long_name = myunidecode($long_name);
+    $short_name = myunidecode($short_name);
+  } elsif ($name_type eq 'NS' && (!$rc || $rc > 2)) {
+    $long_name = $long_name_nd;
+  }
+
+  # Also fix smart quotes and dumb spacing
+  $short_name =~ s/\x{2019}/'/g;
+  $short_name =~ s/  +/ /;
+  $short_name =~ s/ $//;
+  $long_name =~ s/\x{2019}/'/g;
+  $long_name =~ s/  +/ /;
+  $long_name =~ s/ $//;
+
+  if ($designation =~ /^PCL/) {
+    # /^PCL/ matches countries and other ISO-3166-like things:
+    # PCLD - dependent political entity
+    # PCLF - freely associated state
+    # PCLI - independent political entity (ie, "country")
+    # PCLIX - section of independent political entity
+
+    if ($short_name) { $countries{$short_name} = $country_code; }
+    if ($long_name) { $countries{$long_name} = $country_code; }
+
+  } elsif ($designation eq "ADM1") {
+    # ID COUNTRY_CODE STATE_CODE NAME_TYPE LANGUAGE SHORT_NAME
+    # LONG_NAME FLAT_NAME
+    print $states "intl-$id\t$country_code\t$country_code$state_code\t$name_type\t$language\t$short_name\t$long_name\t$flat_name\n";
+
+  } elsif ($designation =~ /^PPL/) {
+    # "PPL*" is "Populated Place"
+
+    # Assign an "importance" to the city. Having a positive
+    # $importance makes a city "bigger" in terms of figuring out how
+    # close it is to a given coordinate, and having negative
+    # $importance makes it "smaller". Furthermore, a negative
+    # importance city can never match solely on coordinates; it needs
+    # to match based on name as well.
+    #
+    # $importance = 2 means furthermore that if we haven't found a
+    # weather station for the city after going through the whole
+    # stations table, then we'll add an entry using whatever weather
+    # station is closest. $importance = 3 means it's a national
+    # capital.
+    #
+    # Our current definition of "important" is that it has designation
+    # of "PPLC" (capital of a country [that is, a /PCL.*/]) or "PPLA"
+    # (capital of an ADM1). We also look at the "Populated Place
+    # Classification" ($pc) and population, but as of 2008-05 those
+    # aren't filled in yet for most countries.
+    #
+    # If $pc is 4 or 5 ("unimportant"), or $designation is "PPLX"
+    # (neighborhood/subdivision), we give the city $importance = -1,
+    # meaning it will only be used when a weather station actually
+    # matches its name; it won't get pulled in as a "nearest city"
+    # match.
+    if ($designation eq "PPLC") {
+      $importance = 3;
+    } elsif ($designation eq "PPLA" ||
+	     ($population && $population > 100000)) {
+      $importance = 2;
+    } elsif ($pc eq "1" || $pc eq "2") {
+      $importance = 1;
+    } elsif ($designation eq "PPLX" || $pc eq "4" || $pc eq "5") {
+      $importance = -1;
+    } else {
+      $importance = 0;
+    }
+
+    # either sqlite or pysqlite treats ".9" as a string rather than a
+    # number, so make sure we say "0.9" instead
+    $latitude = 0 + $latitude;
+    $longitude = 0 + $longitude;
+
+    # ID LATITUDE LONGITUDE ELEVATION IMPORTANCE COUNTRY_CODE
+    # STATE_CODE COUNTY_CODE NAME_TYPE LANGUAGE SHORT_NAME LONG_NAME
+    # FLAT_NAME
+    print $cities "intl-$id\t$latitude\t$longitude\t$elevation\t$importance\t$country_code\t$country_code$state_code\t\t$name_type\t$language\t$short_name\t$long_name\t$flat_name\n";
+
+    # The dataset gives two countries for some features, usually
+    # meaning either that they cross a national border, or are
+    # disputed.
+    if ($cc2) {
+      print $cities "intl-$id\t$latitude\t$longitude\t$elevation\t$importance\t$cc2\t\t\t$name_type\t$language\t$short_name\t$long_name\t$flat_name\n";
+    }
+  }
+}
+close INTLSRC;
+
+# $usbigsrc is the "Concise Features Gazetteer", containing "Large
+# features that should be labeled on maps with a scale of 1:250,000".
+# We use this to find medium-to-large cities. After reading through
+# that, we read $uscitysrc, the "Populated Places Gazetteer", and add
+# any remaining cities from there with $importance=-1. Both of these
+# files have DOS newlines. (After importing the data into sqlite,
+# we'll use $majorsrc to set $importance=2 on the largest US cities.)
+%uscities = ();
+$/ = "\r\n";
+
+# The columns in $usbigsrc:
+# feature_id feature_name class st_alpha st_num county county_num primary_lat_dms primary_lon_dms primary_lat_dec primary_lon_dec source_lat_dms source_lon_dms source_lat_dec source_lon_dec elev map_name
+
+print "Reading $usbigsrc\n";
+open USBIGSRC, "<:utf8", $usbigsrc;
+while (<USBIGSRC>) {
+  # skip header, and historical records
+  next if $. == 1 || /\(historical\)/i;
+
+  # remove CRLF and split
+  chomp;
+  @fields = split /\t/;
+  next if $#fields != 16;
+  ($feature_id, $name, $class, $state, $state_num, $junk, $county_num, $junk,
+   $junk, $latitude, $longitude, $junk, $junk, $junk, $junk,
+   $elevation, $junk) = @fields;
+  $flat = uc(myunidecode($name));
+
+  if ($class eq "Populated Place") {
+    $importance = 0;
+  } else {
+    next;
+  }
+
+  # ID LATITUDE LONGITUDE ELEVATION IMPORTANCE COUNTRY_CODE STATE_CODE
+  # COUNTY_CODE NAME_TYPE LANGUAGE SHORT_NAME LONG_NAME FLAT_NAME
+  print $cities "us-$feature_id\t$latitude\t$longitude\t$elevation\t$importance\tUS\tUS$state_num\t$county_num\tN\teng\t$name\t\t$flat\n";
+  $uscities{$feature_id} = 1;
+}
+close USBIGSRC;
+
+# The columns in $uscitysrc:
+# feature_id feature_name class	st_alpha st_num county county_num primary_lat_dms primary_lon_dms primary_lat_dec primary_lon_dec elev map_name
+print "Reading $uscitysrc\n";
+open USCITYSRC, "<:utf8", $uscitysrc;
+while (<USCITYSRC>) {
+  # skip header, and historical records
+  next if $. == 1 || /\(historical\)/i;
+
+  # remove CRLF and split
+  chomp;
+  @fields = split /\t/;
+  next if $#fields != 12;
+  ($feature_id, $name, $class, $state, $state_num, $county, $county_num,
+   $junk, $junk, $latitude, $longitude, $elevation, $junk) = @fields;
+  $flat = uc(myunidecode($name));
+
+  if (!$uscities{$feature_id}) {
+    # ID LATITUDE LONGITUDE ELEVATION IMPORTANCE COUNTRY_CODE STATE_CODE
+    # COUNTY_CODE NAME_TYPE LANGUAGE SHORT_NAME LONG_NAME FLAT_NAME
+    print $cities "us-$feature_id\t$latitude\t$longitude\t$elevation\t-1\tUS\tUS$state_num\t$county_num\tN\teng\t$name\t\t$flat\n";
+  }
+
+  # There are no separate entries for counties, so we just extract
+  # that data out of the cities, keeping track of which counties we've
+  # already added
+  $county_id = "us-$state_num-$county_num";
+  if (!$counties{$county_id}) {
+    $counties{$county_id} = 1;
+
+    # ID COUNTRY_CODE STATE_CODE COUNTY_CODE NAME_TYPE LANGUAGE
+    # SHORT_NAME LONG_NAME FLAT_NAME
+    print $counties "$county_id\tUS\tUS$state_num\t$county_num\tN\teng\t$county\t\t\n";
+  }
+}
+close USCITYSRC;
+
+# Now add some additional data that's not in any source file...
+
+# $intlsrc has no entry for the US or its dependencies
+$countries{"United States"} = "US";
+$countries{"United States of America"} = "US";
+$countries{"United States Minor Outlying Islands"} = "US";
+$countries{"Micronesia, Federated States of"} = "US";
+# These are in $intlsrc, but not listed as countries
+$countries{"Åland Islands"} = "FI01";
+$countries{"Antarctica"} = "AY";
+$countries{"Faroe Islands"} = "FO";
+$countries{"French Guiana"} = "FG";
+$countries{"French Polynesia"} = "FP";
+$countries{"Greenland"} = "GL";
+$countries{"Guadeloupe"} = "GP";
+$countries{"Martinique"} = "MB";
+$countries{"New Caledonia"} = "NC";
+$countries{"Reunion"} = "RE";
+$countries{"Saint Pierre and Miquelon"} = "SB";
+$countries{"Svalbard"} = "SV";
+$countries{"Wallis and Futuna"} = "WF";
+# These are variations that appear in $weathersrc
+$countries{"Congo, Republic of the"} = "CF";
+$countries{"Congo, Democratic Republic of the"} = "CG";
+$countries{"Cote d'Ivoire"} = "IV";
+$countries{"Falkland Islands, Islas Malvinas"} = "FK";
+$countries{"Gambia, The"} = "GA";
+$countries{"Korea, North"} = "KN";
+$countries{"Korea, South"} = "KS";
+$countries{"Macedonia, The Republic of"} = "MK";
+$countries{"People's Republic of China"} = "CH";
+$countries{"Viet Nam"} = "VM";
+
+$usstatedata = << "EOF";
+01	AL	Alabama
+02	AK	Alaska
+04	AZ	Arizona
+05	AR	Arkansas
+06	CA	California
+08	CO	Colorado
+09	CT	Connecticut
+10	DE	Delaware
+11	DC	District of Columbia
+12	FL	Florida
+13	GA	Georgia
+15	HI	Hawaii
+16	ID	Idaho
+17	IL	Illinois
+18	IN	Indiana
+19	IA	Iowa
+20	KS	Kansas
+21	KY	Kentucky
+22	LA	Louisiana
+23	ME	Maine
+24	MD	Maryland
+25	MA	Massachusetts
+26	MI	Michigan
+27	MN	Minnesota
+28	MS	Mississippi
+29	MO	Missouri
+30	MT	Montana
+31	NE	Nebraska
+32	NV	Nevada
+33	NH	New Hampshire
+34	NJ	New Jersey
+35	NM	New Mexico
+36	NY	New York
+37	NC	North Carolina
+38	ND	North Dakota
+39	OH	Ohio
+40	OK	Oklahoma
+41	OR	Oregon
+42	PA	Pennsylvania
+44	RI	Rhode Island
+45	SC	South Carolina
+46	SD	South Dakota
+47	TN	Tennessee
+48	TX	Texas
+49	UT	Utah
+50	VT	Vermont
+51	VA	Virginia
+53	WA	Washington
+54	WV	West Virginia
+55	WI	Wisconsin
+56	WY	Wyoming
+60	AS	American Samoa
+64	FM	Federated States of Micronesia
+66	GU	Guam
+69	MP	Northern Mariana Islands
+70	PW	Palau
+72	PR	Puerto Rico
+74	UM	United States Minor Outlying Islands
+78	VI	United States Virgin Islands
+EOF
+# ID COUNTRY_CODE STATE_CODE NAME_TYPE LANGUAGE SHORT_NAME
+# LONG_NAME FLAT_NAME
+for $usstate (split(/\n/, $usstatedata)) {
+  ($fips, $abbrev, $name) = split(/\t/, $usstate);
+  ($flat = uc $name) =~ s/ //g;
+  $states{$name} = "US$fips";
+  $states{$abbrev} = "US$fips";
+  print $states "us-$fips\tUS\tUS$fips\tN\teng\t$abbrev\t$name\t$flat\n";
+}
+
+close $states;
+close $counties;
+close $cities;
+
+print "\nCreating adm1 ('states') table\n";
+open SQLITE, "|-:utf8", "sqlite3 $locationdb";
+print SQLITE "CREATE TABLE adm1 (id TEXT, country_code TEXT, state_code TEXT, name_type TEXT, language TEXT, short_name TEXT, long_name TEXT, flat_name TEXT);\n";
+print SQLITE ".separator '\t'\n";
+print SQLITE ".import $states_filename adm1\n";
+close SQLITE;
+#unlink $states_filename;
+
+print "Creating adm2 ('counties') table\n";
+open SQLITE, "|-:utf8", "sqlite3 $locationdb";
+print SQLITE "CREATE TABLE adm2 (id TEXT, country_code TEXT, state_code TEXT, county_code TEXT, name_type TEXT, language TEXT, short_name TEXT, long_name TEXT, flat_name TEXT);\n";
+print SQLITE ".separator '\t'\n";
+print SQLITE ".import $counties_filename adm2\n";
+close SQLITE;
+#unlink $counties_filename;
+
+print "Creating cities table\n";
+open SQLITE, "|-:utf8", "sqlite3 $locationdb";
+print SQLITE "CREATE TABLE cities (id TEXT, latitude REAL, longitude REAL, elevation REAL, importance INTEGER, country_code TEXT, state_code TEXT, county_code TEXT, name_type TEXT, language TEXT, short_name TEXT, long_name TEXT, flat_name TEXT);\n";
+print SQLITE ".separator '\t'\n";
+print SQLITE ".import $cities_filename cities\n";
+close SQLITE;
+#unlink $cities_filename;
+
+print "Indexing cities\n";
+open SQLITE, "|-:utf8", "sqlite3 $locationdb";
+print SQLITE "CREATE INDEX short_name ON cities (country_code, short_name);\n";
+print SQLITE "CREATE INDEX long_name ON cities (country_code, long_name);\n";
+print SQLITE "CREATE INDEX flat_name ON cities (country_code, flat_name);\n";
+print SQLITE "CREATE INDEX long_lat ON cities (country_code, longitude, latitude);\n";
+print SQLITE "CREATE INDEX id ON cities (id);\n";
+close SQLITE;
+
+# Update cities by marking cities "important" if they have the same
+# name as their ADM1. (This seems to do a good job of picking up major
+# cities in some countries that don't have PC or POP data.)
+open SQLITE, "|-:utf8", "sqlite3 $locationdb";
+print SQLITE "UPDATE cities SET importance=1 WHERE importance < 1 AND id IN (SELECT cities.id FROM cities INNER JOIN adm1 ON cities.country_code = adm1.country_code AND cities.state_code = adm1.state_code AND cities.flat_name = adm1.flat_name);\n";
+print SQLITE "UPDATE cities SET importance=1 WHERE importance < 1 AND id IN (SELECT cities.id FROM cities INNER JOIN adm1 ON cities.country_code = adm1.country_code AND cities.state_code = adm1.state_code AND cities.long_name = adm1.short_name);\n";
+close SQLITE;
+
+# Update cities table with additional information about major cities
+$/ = "\n";
+open MAJORSRC, "<:utf8", $majorsrc;
+open SQLITE, "|-:utf8", "sqlite3 $locationdb";
+
+while (<MAJORSRC>) {
+  chomp;
+  s/\s*#.*//;
+
+  @fields = split /\t/;
+  next if $#fields != 4;
+  ($country, $latitude, $longitude, $importance, $city) = @fields;
+
+  $city =~ s/'/''/g; # for SQL
+
+  if ($latitude && $longitude) {
+    $latlo = $latitude - 0.1;
+    $lathi = $latitude + 0.1;
+    $lonlo = $longitude - 0.1;
+    $lonhi = $longitude + 0.1;
+    print SQLITE "UPDATE cities SET importance=$importance WHERE ( short_name='$city' OR long_name='$city' ) AND country_code='$country' AND importance!=-1 AND latitude BETWEEN $latlo AND $lathi AND longitude BETWEEN $lonlo AND $lonhi;\n";
+  } else {
+    print SQLITE "UPDATE cities SET importance=$importance WHERE ( short_name='$city' OR long_name='$city' ) AND country_code='$country' AND importance!=-1;\n";
+  }
+}
+
+close MAJORSRC;
+close SQLITE;
+
+# The columns in $weathersrc are
+# CODE wmo_zone wmo_id NAME STATE COUNTRY region LATITUDE_DMS LONGITUDE_DMS ue_lat ue_long ELEVATION upper_elevation primary
+# (however, some lines are mising one or both of the last two fields!)
+#
+# The columns in the stations table will be
+# CODE NAME STATE COUNTRY LATITUDE LONGITUDE ELEVATION
+
+sub dms_to_dec {
+  my ($coord) = @_;
+  return 0 if $coord !~ /(\d+)-(\d+)(-(\d+))?([NSEW])/;
+  my $dec = $1 + $2 / 60;
+  if ($4) { $dec += $4 / 3600; }
+  if ($5 =~ /[SW]/) { $dec = -$dec; }
+  return $dec;
+}
+
+print "\nCreating weather stations\n";
+($stations, $stations_filename) = tempfile();
+open WEATHERSRC, "-|:utf8", "./station-fixups.pl < $weathersrc";
+while (<WEATHERSRC>) {
+  # remove CRLF and split
+  chomp;
+  @fields = split /;/;
+  next if $#fields < 11;
+  
+  ($code, $junk, $id, $location, $state, $country, $junk, $latitude, $longitude, $junk, $junk, $elevation) = @fields;
+
+  $latitude = dms_to_dec($latitude);
+  $longitude = dms_to_dec($longitude);
+
+  $country_code = $countries{$country};
+  if (!$country_code) {
+    $alt_country = s/^(.*), (.*)$/$2 $1/;
+    $country_code = $countries{$alt_country};
+  }
+  if (!$country_code) {
+    if ($country) { print "No country for '$country'\n"; }
+    $country_code = '';
+  }
+  if ($state) {
+    $state_code = $states{$state};
+    if (!$state_code) {
+      print "No state for '$state'\n";
+      next;
+    }
+  } else {
+    $state_code = "";
+  }
+
+  print $stations "$code;$location;$state_code;$country_code;$latitude;$longitude;$elevation\n";
+}
+close WEATHERSRC;
+close $stations;
+
+open SQLITE, "|-:utf8", "sqlite3 $locationdb";
+print SQLITE "CREATE TABLE stations (code TEXT, name TEXT, state TEXT, country TEXT, latitude REAL, longitude REAL, elevation REAL);\n";
+print SQLITE ".separator ';'\n";
+print SQLITE ".import $stations_filename stations\n";
+print SQLITE "CREATE INDEX station_long_lat ON stations (longitude, latitude);\n";
+close SQLITE;
+
+# unlink $stations_filename;
diff --git a/data/city-fixups.pl b/data/city-fixups.pl
new file mode 100755
index 0000000..8fd78b9
--- /dev/null
+++ b/data/city-fixups.pl
@@ -0,0 +1,35 @@
+#!/usr/bin/perl
+
+binmode STDIN, ":utf8";
+binmode STDOUT, ":utf8";
+
+while (<>) {
+  # Drop apparently-duplicate cities
+  next if /-364867.*Tacna/; # Tacna, Peru
+  next if /6034170.*Bujumbura/; # Bujumbura, Burundi
+  next if /-225337.*Akita/; # Akita, Japan
+  next if /\t10074674\t/; # Tehran, Iran
+
+  # "Zürich" should be listed with an English name of "Zurich"
+  if (/-2554935.*Zürich/) {
+    # print the local name
+    print;
+    # change the data, then fall through to print that as well
+    s/\tN\t\t/\tC\teng\t/;
+    s/Zürich/Zurich/;
+  }
+
+  # The capital of Mexico should be "Mexico City" in English, not just
+  # "Mexico". #171718
+  if (/-1658079.*\tC\teng/) {
+    s/MEXICO\tMexico\tMexico\t/MEXICOCITY\tMexico City\tMexico City\t/;
+  }
+
+  # geonames only identifies the largest city on the island of Kosrae,
+  # FM by its old "V" (variant) name
+  if (/-374125.*\tV\t/) {
+    s/V\t\t\t\tTOYNIM\tToynim\tToynim\t/N\t\t\t\tTOFOL\tTofol\tTofol\t/;
+  }
+
+  print;
+}
diff --git a/data/major-cities.txt b/data/major-cities.txt
new file mode 100644
index 0000000..a51ff0c
--- /dev/null
+++ b/data/major-cities.txt
@@ -0,0 +1,260 @@
+# This is used as input to build-locations.pl, to supplement the
+# city data with information about what cities are "important".
+
+# Format:
+# FIPS-code  [Latitude]  [Longitude]  Level  Name [ # comment ]
+# (fields are separated by tabs)
+
+# FIPS-code is a two-letter FIPS 10-4 country code (*NOT* an ISO 3601
+# country code) Latitude and longitude can be specified to any
+# precision, and are optional if the combination of FIPS code and city
+# name uniquely identify a city.
+#
+
+# Level is the importance level: "1" just means it's a big city (which
+# makes it more likely that nearby weather stations will be assigned
+# to it). "2" means that update-locations.py will try to create an
+# entry for the city using the nearest weather station, even if that
+# weather station is already also assigned to another city. "3" means
+# additionally that the city is the capital of its country (and will
+# be marked as such in the translator comments).
+
+###
+
+# United States. Source: 2000 Census data, "Incorporated Places of
+# 100,000 or More, Ranked by Population"
+US	40.714	-074.005	2	New York # NY
+US	34.052	-118.243	2	Los Angeles # CA
+US	41.850	-087.650	2	Chicago # IL
+US	29.763	-095.363	2	Houston # TX
+US	39.952	-075.163	2	Philadelphia # PA
+US	33.448	-112.074	2	Phoenix # AZ
+US	32.715	-117.157	2	San Diego # CA
+US	32.783	-096.806	2	Dallas # TX
+US	29.424	-098.493	2	San Antonio # TX
+US	42.331	-083.045	2	Detroit # MI
+US	37.339	-121.894	2	San Jose # CA
+US	39.768	-086.158	2	Indianapolis # IN
+US	37.774	-122.419	2	San Francisco # CA
+US	30.332	-081.655	2	Jacksonville # FL
+US	39.961	-082.998	2	Columbus # OH
+US	30.267	-097.743	2	Austin # TX
+US	39.290	-076.612	2	Baltimore # MD
+US	35.149	-090.048	2	Memphis # TN
+US	43.038	-087.906	2	Milwaukee # WI
+US	42.358	-071.059	2	Boston # MA
+US	38.895	-077.036	3	Washington # DC
+US	36.165	-086.784	2	Nashville # TN
+US	31.758	-106.486	2	El Paso # TX
+US	47.606	-122.332	2	Seattle # WA
+US	39.739	-104.984	2	Denver # CO
+US	35.227	-080.843	2	Charlotte # NC
+US	32.725	-097.320	2	Fort Worth # TX
+US	45.523	-122.676	2	Portland # OR
+US	35.467	-097.516	2	Oklahoma City # OK
+US	32.221	-110.926	2	Tucson # AZ
+US	29.954	-090.075	2	New Orleans # LA
+US	36.174	-115.137	2	Las Vegas # NV
+US	41.499	-081.695	2	Cleveland # OH
+US	33.766	-118.189	2	Long Beach # CA
+US	35.084	-106.651	2	Albuquerque # NM
+US	39.099	-094.578	2	Kansas City # MO
+US	36.747	-119.772	2	Fresno # CA
+US	36.852	-075.977	2	Virginia Beach # VA
+US	18.466	-066.105	2	San Juan # PR
+US	33.748	-084.387	2	Atlanta # GA
+US	38.581	-121.494	2	Sacramento # CA
+US	37.804	-122.270	2	Oakland # CA
+US	33.422	-111.822	2	Mesa # AZ
+US	36.153	-095.992	2	Tulsa # OK
+US	41.258	-095.937	2	Omaha # NE
+US	44.979	-093.263	2	Minneapolis # MN
+US	21.306	-157.858	2	Honolulu # HI
+US	25.774	-080.193	2	Miami # FL
+US	38.833	-104.821	2	Colorado Springs # CO
+US	37.692	-097.337	2	Wichita # KS
+US	33.745	-117.867	2	Santa Ana # CA
+US	40.440	-079.995	2	Pittsburgh # PA
+US	32.735	-097.108	2	Arlington # TX
+US	39.162	-084.456	2	Cincinnati # OH
+US	33.835	-117.914	2	Anaheim # CA
+US	41.663	-083.555	2	Toledo # OH
+US	27.947	-082.458	2	Tampa # FL
+US	42.886	-078.878	2	Buffalo # NY
+US	27.800	-097.396	2	Corpus Christi # TX
+US	39.729	-104.831	2	Aurora # CO
+US	35.772	-078.638	2	Raleigh # NC
+US	40.735	-074.172	2	Newark # NJ
+US	37.988	-084.477	2	Lexington # KY
+US	61.218	-149.900	2	Anchorage # AK
+US	38.254	-085.759	2	Louisville # KY
+US	33.953	-117.396	2	Riverside # CA
+US	35.373	-119.018	2	Bakersfield # CA
+US	37.957	-121.290	2	Stockton # CA
+US	33.520	-086.802	2	Birmingham # AL
+US	40.728	-074.077	2	Jersey City # NJ
+US	36.846	-076.285	2	Norfolk # VA
+US	30.450	-091.154	2	Baton Rouge # LA
+US	25.857	-080.278	2	Hialeah # FL
+US	40.800	-096.666	2	Lincoln # NE
+US	36.072	-079.791	2	Greensboro # NC
+US	33.019	-096.698	2	Plano # TX
+US	43.154	-077.615	2	Rochester # NY
+US	33.538	-112.185	2	Glendale # AZ
+US	41.081	-081.519	2	Akron # OH
+US	32.912	-096.638	2	Garland # TX
+US	43.073	-089.401	2	Madison # WI
+US	41.130	-085.128	2	Fort Wayne # IN
+US	37.548	-121.988	2	Fremont # CA
+US	33.509	-111.899	2	Scottsdale # AZ
+US	32.366	-086.299	2	Montgomery # AL
+US	32.525	-093.750	2	Shreveport # LA
+US	33.470	-081.974	2	Augusta # GA
+US	33.577	-101.855	2	Lubbock # TX
+US	36.819	-076.274	2	Chesapeake # VA
+US	30.694	-088.043	2	Mobile # AL
+US	41.600	-093.609	2	Des Moines # IA
+US	42.963	-085.668	2	Grand Rapids # MI
+US	37.553	-077.460	2	Richmond # VA
+US	40.931	-073.898	2	Yonkers # NY
+US	47.658	-117.426	2	Spokane # WA
+US	34.142	-118.255	2	Glendale # CA
+US	47.252	-122.444	2	Tacoma # WA
+US	32.814	-096.948	2	Irving # TX
+US	33.660	-117.999	2	Huntington Beach # CA
+US	37.639	-120.996	2	Modesto # CA
+US	35.994	-078.898	2	Durham # NC
+US	32.460	-084.987	2	Columbus # GA
+US	28.538	-081.379	2	Orlando # FL
+US	36.099	-080.244	2	Winston-Salem # NC
+US	34.108	-117.289	2	San Bernardino # CA
+US	32.298	-090.184	2	Jackson # MS
+US	34.746	-092.289	2	Little Rock # AR
+US	40.760	-111.891	2	Salt Lake City # UT
+US	39.529	-119.813	2	Reno # NV
+US	36.978	-076.428	2	Newport News # VA
+US	33.306	-111.841	2	Chandler # AZ
+US	27.506	-099.507	2	Laredo # TX
+US	36.039	-114.981	2	Henderson # NV
+US	35.960	-083.920	2	Knoxville # TN
+US	35.221	-101.831	2	Amarillo # TX
+US	41.823	-071.412	2	Providence # RI
+US	32.640	-117.084	2	Chula Vista # CA
+US	42.262	-071.802	2	Worcester # MA
+US	34.197	-119.177	2	Oxnard # CA
+US	18.380	-065.957	2	Carolina # PR
+US	39.758	-084.191	2	Dayton # OH
+US	33.773	-117.941	2	Garden Grove # CA
+US	33.195	-117.379	2	Oceanside # CA
+US	33.414	-111.909	2	Tempe # AZ
+US	34.730	-086.586	2	Huntsville # AL
+US	34.063	-117.650	2	Ontario # CA
+US	35.045	-085.309	2	Chattanooga # TN
+US	18.011	-066.614	2	Ponce # PR
+US	26.122	-080.143	2	Fort Lauderdale # FL
+US	42.101	-072.589	2	Springfield # MA
+US	37.215	-093.298	2	Springfield # MO
+US	36.677	-121.655	2	Salinas # CA
+US	30.438	-084.280	2	Tallahassee # FL
+US	42.271	-089.093	2	Rockford # IL
+US	34.055	-117.752	2	Pomona # CA
+US	40.916	-074.171	2	Paterson # NJ
+US	38.982	-094.670	2	Overland Park # KS
+US	38.440	-122.714	2	Santa Rosa # CA
+US	43.048	-076.147	2	Syracuse # NY
+US	39.114	-094.627	2	Kansas City # KS
+US	37.029	-076.345	2	Hampton # VA
+US	39.704	-105.081	2	Lakewood # CO
+US	45.638	-122.661	2	Vancouver # WA
+US	33.669	-117.823	2	Irvine # CA
+US	41.760	-088.320	2	Aurora # IL
+US	29.691	-095.209	2	Pasadena # TX
+US	37.668	-122.080	2	Hayward # CA
+US	25.901	-097.497	2	Brownsville # TX
+US	41.167	-073.204	2	Bridgeport # CT
+US	26.011	-080.149	2	Hollywood # FL
+US	42.477	-083.027	2	Warren # MI
+US	33.835	-118.340	2	Torrance # CA
+US	44.052	-123.086	2	Eugene # OR
+US	26.003	-080.223	2	Pembroke Pines # FL
+US	44.942	-123.035	2	Salem # OR
+US	34.147	-118.144	2	Pasadena # CA
+US	33.119	-117.086	2	Escondido # CA
+US	37.368	-122.036	2	Sunnyvale # CA
+US	32.083	-081.099	2	Savannah # GA
+US	34.092	-117.435	2	Fontana # CA
+US	33.787	-117.853	2	Orange # CA
+US	41.785	-088.147	2	Naperville # IL
+US	38.804	-077.046	2	Alexandria # VA
+US	34.106	-117.593	2	Rancho Cucamonga # CA
+US	32.745	-096.997	2	Grand Prairie # TX
+US	33.870	-117.925	2	Fullerton # CA
+US	33.875	-117.566	2	Corona # CA
+US	43.012	-083.687	2	Flint # MI
+US	32.766	-096.599	2	Mesquite # TX
+US	42.580	-083.030	2	Sterling Heights # MI
+US	43.549	-096.700	2	Sioux Falls # SD
+US	41.308	-072.928	2	New Haven # CT
+US	39.048	-095.678	2	Topeka # KS
+US	37.977	-122.031	2	Concord # CA
+US	37.974	-087.555	2	Evansville # IN
+US	41.763	-072.685	2	Hartford # CT
+US	35.052	-078.878	2	Fayetteville # NC
+US	42.008	-091.644	2	Cedar Rapids # IA
+US	40.663	-074.210	2	Elizabeth # NJ
+US	42.732	-084.555	2	Lansing # MI
+US	34.698	-118.136	2	Lancaster # CA
+US	40.585	-105.084	2	Fort Collins # CO
+US	41.053	-073.538	2	Stamford # CT
+US	34.170	-118.837	2	Thousand Oaks # CA
+US	38.104	-122.256	2	Vallejo # CA
+US	34.579	-118.116	2	Palmdale # CA
+US	34.000	-081.034	2	Columbia # SC
+US	34.068	-118.027	2	El Monte # CA
+US	32.448	-099.733	2	Abilene # TX
+US	36.198	-115.117	2	North Las Vegas # NV
+US	42.270	-083.726	2	Ann Arbor # MI
+US	30.086	-094.101	2	Beaumont # TX
+US	31.549	-097.146	2	Waco # TX
+US	39.091	-094.415	2	Independence # MO
+US	40.693	-089.588	2	Peoria # IL
+US	33.961	-118.353	2	Inglewood # CA
+US	39.801	-089.643	2	Springfield # IL
+US	34.269	-118.781	2	Simi Valley # CA
+US	30.224	-092.019	2	Lafayette # LA
+US	33.352	-111.789	2	Gilbert # AZ
+US	32.953	-096.890	2	Carrollton # TX
+US	47.610	-122.200	2	Bellevue # WA
+US	40.691	-112.001	2	West Valley City # UT
+US	27.965	-082.800	2	Clearwater # FL
+US	33.641	-117.918	2	Costa Mesa # CA
+US	33.580	-112.237	2	Peoria # AZ
+US	41.683	-086.250	2	South Bend # IN
+US	41.558	-073.051	2	Waterbury # CT
+US	42.995	-071.454	2	Manchester # NH
+US	40.608	-075.490	2	Allentown # PA
+US	26.203	-098.230	2	McAllen # TX
+US	41.525	-088.081	2	Joliet # IL
+US	42.633	-071.316	2	Lowell # MA
+US	40.233	-111.658	2	Provo # UT
+US	34.068	-117.938	2	West Covina # CA
+US	33.913	-098.493	2	Wichita Falls # TX
+US	42.129	-080.085	2	Erie # PA
+US	37.705	-122.461	2	Daly City # CA
+US	36.529	-087.359	2	Clarksville # TN
+US	33.902	-118.081	2	Norwalk # CA
+US	41.593	-087.346	2	Gary # IN
+US	37.871	-122.272	2	Berkeley # CA
+US	37.354	-121.955	2	Santa Clara # CA
+US	44.519	-088.019	2	Green Bay # WI
+US	26.562	-081.949	2	Cape Coral # FL
+US	39.802	-105.087	2	Arvada # CO
+US	38.254	-104.609	2	Pueblo # CO
+US	33.960	-083.377	2	Athens # GA
+US	42.375	-071.105	2	Cambridge # MA
+US	39.836	-105.037	2	Westminster # CO
+US	34.278	-119.293	2	Ventura # CA
+US	36.835	-076.298	2	Portsmouth # VA
+US	42.368	-083.352	2	Livonia # MI
+US	34.180	-118.308	2	Burbank # CA
+
diff --git a/data/sources/README.sources b/data/sources/README.sources
new file mode 100644
index 0000000..57cf4c6
--- /dev/null
+++ b/data/sources/README.sources
@@ -0,0 +1,157 @@
+Locations.xml.in is generated from 6 primary sources:
+
+    data/Locations.xml.in
+    data/major_cities.txt
+
+    data/sources/nsd_cccc.txt
+    data/sources/POP_PLACES.txt
+    data/sources/US_CONCISE.txt
+    data/sources/geonames_dd_dms_date_*.txt
+
+The first two are maintained by us and are checked in to subversion.
+The other four are produced by the US government and can be downloaded
+off the web. Since they are very large, and only needed when
+regenerating Locations.xml.in, they are not checked in to subversion
+and must be downloaded to data/sources by hand if you want to rebuild
+Locations.xml.in.
+
+The files are used as follows:
+
+
+Locations.xml.in:
+
+   The process of building a new Locations.xml.in uses certain
+   information from the old Locations.xml.in:
+
+     * The overall division of the world into <region>, <country>, and
+       <state>. When a new country appears in the input data (either
+       because it's an actual new country, or because the country
+       previously did not have any active weather stations but now
+       does), a <country> node needs to be added to the appropriate
+       <region> in Locations.xml.in so that the importer knows where
+       the country belongs in the output.
+
+       Likewise, if you want to split an existing country into
+       <state>s, you must first create the <state> nodes in
+       Locations.xml.in, assigning one or more <fips-code>s to each
+       state. The importer will then use those FIPS codes to assign
+       cities to the correct states automatically.
+
+     * The <iso-code>, <fips-code>, <tz-hint>, <radar>, and <zone>
+       tags for <country>, <state>, and <location> nodes are copied
+       from the input to the output. Likewise, some comments in
+       Locations.xml.in will be copied over.
+
+
+major_cities.txt:
+
+   This is used to indicate major cities that should be included in
+   the generated Locations.xml.in file, even when those cities don't
+   have their own weather stations. (Eg, since Cambridge,
+   Massachusetts is listed in major_cities.txt, Locations.xml.in
+   includes an entry for Cambridge, using the closest weather station,
+   which is in Boston.)
+
+   For some countries, the importer can determine major-city
+   information itself using the data in the geonames file. However, if
+   the generated Locations.xml.in is missing major cities for a
+   country, that can be fixed by adding those cities here.
+
+   (The list of "major cities" in the US was generated from census
+   data, and contains all cities with population greater than
+   100,000.)
+
+
+nsd_cccc.txt:
+
+   This is the US National Weather Service's "Meteorological Station
+   Location Information", keyed by ICAO location indicator, available
+   from:
+
+     http://weather.noaa.gov/data/nsd_cccc.txt
+
+   described at: 
+
+     http://weather.noaa.gov/tg/site.shtml
+
+   This file was the original data source, and is the primary source
+   of the <code> elements in Locations.xml.in. Traditionally the
+   user-visible locations in Locations.xml were generated from this
+   file, but this was problematic in several ways:
+
+     * The locations are only divided into states within the US, so
+       Canadian/British/Chinese/etc locations needed to be split up
+       into provinces/regions/etc by hand.
+
+     * Many locations are named after airports or regions rather than
+       cities.
+
+     * The internationalization in this file is very inconsistent;
+       some cities have anglicized names ("Antwerp"), others have
+       ASCIIfied local names ("Muenchen"). Other names (particularly
+       in Africa and the Middle East) use odd variant forms of names
+       that are neither the preferred English form nor the preferred
+       local form.
+
+   Now we mostly use this file to get the station codes and
+   coordinates, and use the other two files to map those coordinates
+   to good city names.
+
+   The data in this file is not very good, and needs numerous
+   corrections; these are handled by data/station-fixups.pl, which
+   gets run on the data as it's being read.
+
+   Also, some of the stations in this list are inactive, or only
+   report sporadically, so we need to prune the list to only those
+   stations that report regularly. We do that by running
+   data/check-observations.py once a day on master.gnome.org, and then
+   using its output (which update-locations.py will download
+   automatically) to decide which stations to use.
+
+POP_PLACES.txt, US_CONCISE.txt:
+
+   This is our source of US city name information, and it comes from
+   the US Board on Geographic Names's State and Topical Gazetteer
+   downloads, available from:
+
+     http://geonames.usgs.gov/domestic/download_data.htm
+
+   POP_PLACES.txt is the "Populated Places" gazetteer, and
+   US_CONCISE.txt is the "Concise Features" gazetteer. The data format
+   is described here:
+
+     http://geonames.usgs.gov/domestic/gaz_fileformat.htm
+
+   This data also allows us to determine which US cities are in which
+   counties, which is needed to get the time zones correct in states
+   that span two time zones.
+
+
+geonames_dd_dms_date_*.txt:
+
+   (The actual filename includes a date stamp.) This is the extract
+   from the US National Geospatial- Intelligence Agency's GEOnet Names
+   Server country files data. We use the "single compressed zip file
+   that contains the entire country files dataset", linked from here:
+
+     http://earth-info.nga.mil/gns/html/namefiles.htm
+
+   The data is described here:
+
+     http://earth-info.nga.mil/gns/html/help.htm#C3
+
+   I can't find a convenient description of the DSG (Feature
+   Designation Code) values on the NGA's site, but they are explained
+   here:
+
+     http://speech.tec.army.mil/GeonamesDSGHelp.htm
+
+   This provides state/province/etc-level divisions for (some) non-US
+   cities, and also lets us replace the ASCIIfied names from the
+   weather station database with properly accented UTF-8 names.
+
+   Although this data is much much cleaner than the weather station
+   data, it does have a few problems. (In particular, some cities seem
+   to appear twice with different IDs.) data/city-fixups.pl is run on
+   this file while reading it in, and has examples of how to fix
+   various problems there.
diff --git a/data/station-fixups.pl b/data/station-fixups.pl
new file mode 100755
index 0000000..aebade0
--- /dev/null
+++ b/data/station-fixups.pl
@@ -0,0 +1,230 @@
+#!/usr/bin/perl
+
+binmode STDIN, ":utf8";
+binmode STDOUT, ":utf8";
+
+while (<>) {
+  ### Drop certain stations
+  next if / Platform *;/; # offshort oil platforms
+  next if /^LYPZ;/; # buggy duplicate
+
+  ### Whitespace/punctuation cleanup
+  s/   */ /g;
+  s/\\/\//g;
+  s/([^ ])\//$1 \//g;
+  s/\/([^ ])/\/ $1/g;
+  s/,([^ ])/, $1/g;
+  s/ ,/,/g;
+  s/[ ,\/]*;/;/g;
+  s/; /;/g;
+
+  ### Capitalization, etc
+  s/Mc /Mc/g;
+  s/ Of / of /g;
+  s/([a-z]) D(a |e |el |es |i |o |u |\')/$1 d$2/g;
+  s/([a-z]) L(a|es?) /$1 l$2 /g;
+  # lowercasify a capital letter after an apostrophe, unless the
+  # preceding letter was "d" (eg, "Cote d'Ivoire")
+  s/([a-ce-z]\'[A-Z])/\L$1/g; 
+
+
+  ### Fix incorrect or outdated codes
+  if (/;Angelholm;;Sweden;/)   { s/ESDB/ESTA/; }
+  if (/;M\. Calamita;;Italy;/) { s/LIRJ/LIRX/; }
+  if (/;Yerevan;;Armenia;/)    { s/UGEE/UDYZ/; }
+  if (/;Novosibirsk;;Russia;/) { s/UNNN/UNNT/; }
+  if (/;Jinan;;China;/)        { s/ZSTN/ZSJN/; }
+
+  ### Fix invalid or incorrect coordinates
+  if (/^K3MW;/) { s/;40-26-94N;106-44-95W;/;40-27N;106-45W;/; }
+  if (/^KBKB;/) { s/;092-97W;/;093-00W;/; }
+  if (/^KBJN;/) { s/;37-37-02;/;37-37-02N;/; }
+  if (/^KFHU;/) { s/;46-98N;/;31-35N;/; }
+  if (/^KWTR;/) { s/;104-87W;/;105-00W;/; }
+  if (/^MMML;/) { s/;117-00W;/;115-14W;/; }
+  if (/^MNBL;/) { s/;086-46W;/;083-46W;/; }
+  if (/^PGNT;/) { s/;14-96N;/;15-00N;/; }
+
+
+  ### Fix some country divisions to match FIPS codes
+  if (/^EGJA;/) { s/;United Kingdom;/;Guernsey;/; }
+  if (/^EGJB;/) { s/;United Kingdom;/;Guernsey;/; }
+  if (/^EGJJ;/) { s/;United Kingdom;/;Jersey;/; }
+  if (/^EGNS;/) { s/;United Kingdom;/;Isle of Man;/; }
+  if (/^EKVG;/) { s/;Denmark;/;Faroe Islands;/; }
+  if (/^ENSB;/) { s/;Norway;/;Svalbard;/; }
+  if (/^FMCZ;/) { s/;Comoros;/;Mayotte;/; }
+  if (/^NLWW;/) { s/;France;/;Wallis and Futuna;/; }
+  if (/^PLCH;/) { s/;New Zealand;/;Kiribati;/; }
+  if (/^TI..;/) { s/;;Virgin Islands;/;VI;United States;/; }
+  if (/^YSNF;/) { s/;Australia;/;Norfolk Island;/; }
+
+
+  ### Fix some country/state divisions to correct bugs
+  if (/^EGYP;/) { s/;South Georgia and the Islands;/;Falkland Islands (Islas Malvinas);/; }
+  if (/^HHAS;/) { s/;Ethiopia;/;Eritrea;/; } # 502576
+  if (/^LY..;/) { s/;Serbia and Montenegro;/;;/; } # This will force update-locations to figure them out itself
+  if (/^NIUE;/) { s/;Cook Islands;/;Niue;/; }
+  if (/^NSTU;/) { s/;;United States Minor Outlying Islands;/;AS;United States;/; }
+  if (/^NZWD;/) { s/, Antarctic;;New Zealand;/;;Antarctica;/; }
+  if (/^PMDY;/) { s/;HI;/;UM;/; }
+  if (/^PWAK;/) { s/;GU;/;UM;/; }
+  if (/^TKPN;/) { s/;Antigua and Barbuda;/;Saint Kitts and Nevis;/; }
+  if (/^YPCC;/) { s/;Christmas Island;/;Cocos (Keeling) Islands;/; }
+
+
+  ### Fix some spelling mistakes/wackiness/nonstandardnesses. Mostly
+  ### alphabetical by station code
+  if (/^EDDF;/) { s/ \/ M-Flughafen//; }
+  if (/^ETWM;/) { s/-Mil;/ Military Base;/; }
+  if (/^FAJS;/) { s/;Johannesburg International Airport;/;O. R. Tambo International Airport;/; } # 533622
+  if (/^HESH;/) { s/Sheikhintl/Sheikh Intl/; }
+  if (/^HKJK;/) { s/ TWR \/ APP \/ NOF \/ Civil Airlines//; }
+  if (/^LBGO;/) { s/Orechovista/Oryakhovitsa/; } # 313655
+  if (/^LEJR;/) { s/Fronteraaeropuerto/Frontera Aeropuerto/; }
+  if (/^LFPG;/) { s/Paris-Aeroport Charles de Gaulle/Paris, Charles de Gaulle International Airport/; }
+  if (/^LIPO;/) { s/Montichia;/Montichiara;/; } # 350945
+  if (/^LOAV;/) { s/Lugplatz/Flugplatz/; }
+  if (/^MMMD;/) { s/ lic / /; }
+  if (/^MNMG;/) { s/Managua A. C. Sandino/Managua, A. C. Sandino Airport/; }
+  if (/^MTPP;/) { s/ \/ Aeroport International/ International Airport/; }
+  if (/^MWCR;/) { s/Airportgrand/Airport, Grand/; }
+  # 319538 - the entry for OIAG actually has the data for OIAJ
+  if (/^OIAG;/) { s/Omidieh/Aghajari/; s/30-46N;049-40E/30-44-44N;049-40-35E/; }
+  if (/^OIKB;/) { s/Bandarabbass/Bandar Abbas/; }
+  if (/^OINN;/) { s/Noshahr/Now Shahr/; }
+  if (/^OITR;/) { s/Orumieh/Orumiyeh/; }
+  if (/^PGUM;/) { s/Agana/Hagåtña/; } # to match POP_PLACES
+  if (/^SABE;/) { s/Aeroparque Bs\. As\./Buenos Aires, Jorge Newbery/; }
+  if (/^SBFZ;/) { s/pinto/Pinto/; }
+  if (/^SPHY;/) { s/Andahuayla/Andahuaylas/; }
+  if (/^SPIM;/) { s/Aerop\. Internacional Jorgechavez/Jorge Chavez International Airport/; }
+  if (/^SVMI;/) { s/Maiquetia Aerop\. Intl\. Simon Bolivar/Simon Bolivar International/; }
+  if (/^TAPA;/) { s/Vc /V. C. /; }
+  if (/^TKPN;/) { s/Newcast;/Newcastle;/; }
+  if (/^UBBG;/) { s/Gyanca/Gyandzha/; }
+  if (/^UKDR;/) { s/Krivyy/Kryvyy/; }
+
+  ### Untranslate/unabbreviate the word "Airport". (The names in
+  ### nsd_cccc.txt don't seem to be especially close to
+  ### correct/official, so this is a net win.
+  s/Aerop\. Internacional ([^,;]*)/$1 International Airport/;
+  s/Aeropuerto[^ ]* ([^,;]*)/$1 Airport/;
+  s/Aeroporto* d[ea] ([^,;]*)/$1 Airport/;
+  s/[ -]Aero(|\.|drome|porto?|-Porto|puerto)( |;)/ Airport$2/;
+  s/Air(-Port|p\.)/Airport/;
+  s/Civ \/ (Mil|Afb)/Airport/;
+  s/( \/)? Civ(|il|ilian);/$1 Airport;/;
+  s/Lufthavn/Airport/;
+  s/Int\'?l\.?/International/;
+  s/Int\./International/;
+  s/Inter-National/International/;
+  s/Internationalairport;/International Airport;/;
+  s/International;/International Airport;/;
+  s/Airport ([A-Z])/Airport, $1/;
+  # Change "Foo / Airport" to "Foo Airport"
+  s/;([^;]*)(,| \/) (International Airport|Airport);/;$1 $3;/;
+  # And "Foo / Bar Airport" to "Foo, Bar Airport"
+  s/;([^;\/,]*) \/ ([^;\/,]* Airport)/;$1, $2/;
+
+  s/,? ([a-z][a-z]*-)?afb/ Air Force Base/i;
+  s/ ([A-Z][a-z]*-)?Ab;/ Air Base;/;
+  s/Usa . Af/US Air Force Base/;
+  s/Usaf/US Air Force Base/;
+  s/Air Force Operated Base In Foreign Country/Air Force Base/;
+  s/ (Can-)?Mil(\.|itary);/ Military Base;/;
+
+  s/Obs(\.|erv\.|ervatory|ervatorio)/Observatory/;
+
+  # US National Weather Service, but appears not just in /;United States;/
+  s/, NWS Office//;
+  # Likewise Australian Weather Service (or Automated Weather Station?)
+  s/,? Aws;/;/;
+
+  ### Country-specific fixups, sorted alphabetically by country
+
+  if (/;Argentina;/) {
+    # Remove province name from location description
+    s/, (BA|B\. A\.|CHT|SF);/;/;
+  }
+
+  if (/;Australia;/) {
+    # ??
+    s/ (Amo|Mo);/ Airport;/;
+    s/,? M\. O\.?;/;/;
+
+    s/ Ran / Royal Australian Navy /;
+  }
+
+  if (/;Austria;/) {
+    s/-Flughafen/ Airport/;
+    s/Flugplatz/Airport/;
+    s/ Am / am /;
+    s/ Im / im /;
+  }
+
+  if (/;Canada;/) {
+    # Remove province/territory name from location description
+    s/,? (Alta|B\. C|Man|N\. B|Nfld|N\. S|N\. W\. T|Ont|P\. E\. I|Prince Edward Island|Que|Sask|Y\. T)\.?;/;/;
+
+    # Canadian Department of Agriculture, [Remote] Climate Station
+    s/ Cda//i;
+    s/ R?CS//;
+
+    s/Airport, [^;]*Station;/Airport;/;
+  }
+
+  if (/;Cuba;/) {
+    s/, Oriente//;
+  }
+
+  if (/;Mexico;/) {
+    # Remove state name from location description
+    s/,? (Ags|B\. C\. S|Camp|Chis|Coah|Mor|N\. L|Nay|Pue|Q\. Roo|Qro|S\. L\. P|Sin|Son)\.?;/;/;
+  }
+
+  if (/;Netherlands;/) {
+    s/;([^;]*) Airport, ([^;]*);/;$1, $2 Airport;/;
+  }
+
+  if (/;New Caledonia;/) {
+    s/ Nlle-Caledonie//;
+    s/ Ile [^;]*//;
+  }
+
+  if (/;Sweden;/) {
+    s/Flygplats/Airport/;
+  }
+
+  if (/;United States;/) {
+    s/Nexrad/NEXRAD Station/;
+  }
+
+  ### Final airport fixing...
+
+  ### The location data for several countries (including all of South
+  ### America) uses the convention "City / Airport Name"
+  if (/^(AG|AY|DA|EL|FC|FMM|FO|FX|FZ|GA|GM|HE|LI|LJ|MK|MM|MP|OI|PL|S|US|UU|UW|VV|WA|WI|WM|WR|WS|Z)/) {
+    s/;([^;]*) \/ ([^;\/]*)/;$1, $2 Airport/;
+  }
+
+  ### Some do it backwards
+  if (/^(EN|LE|MS|TT)/) {
+    s/;([^;]*) \/ ([^;\/]*)/;$1 Airport, $2/;
+  }
+
+  ### In some countries, you generally need to prefix the city name to
+  ### the airport name
+  if (/^(C|ES|GO|LF|LH|LK|LS|LT|LZ)/) {
+    s/;([^;]*) \/ ([^;\/]*)/;$1, $1-$2 Airport/;
+  }
+
+  # Some of our fixes end up resulting in "Airport Airport"
+  s/Airport Airport/Airport/;
+
+  # Remove numbers in "Foo 1", "Foo 2", "Foo Iii", etc
+  s/ [123];/;/;
+  s/ I[iv]i*([^a-z])/$1/;
+
+  print;
+}
diff --git a/data/update-locations.py b/data/update-locations.py
new file mode 100755
index 0000000..3f782b7
--- /dev/null
+++ b/data/update-locations.py
@@ -0,0 +1,734 @@
+#!/usr/bin/python
+# -*- coding: utf-8 -*-
+
+import codecs
+import locale
+import math
+import re
+import sqlite3
+import sys
+import urllib
+from xml.dom import minidom
+from xml.sax import saxutils
+
+# Magic incantation to tell python to let us output UTF-8
+sys.stdout = codecs.getwriter(locale.getpreferredencoding())(sys.stdout)
+
+# The database...
+db = sqlite3.connect('locationdb.sqlite')
+fips_codes = {}
+station_timezones = {}
+station_zones = {}
+station_radars = {}
+station_comments = {}
+station_states = {}
+
+# xml helpers
+def getChildContentByName(node, name):
+    node = node.firstChild
+    while node is not None:
+        if node.nodeName == name:
+            return node.firstChild.nodeValue
+        node = node.nextSibling
+    return None
+
+def getChildrenByName(node, name):
+    children = []
+    node = node.firstChild
+    while node is not None:
+        if hasattr(node, 'tagName') and node.tagName == name:
+            children.append(node)
+        node = node.nextSibling
+    return children
+
+def getNodeChildren(node):
+    children = []
+    node = node.firstChild
+    while node is not None:
+        if hasattr(node, 'tagName'):
+            children.append(node)
+        node = node.nextSibling
+    return children
+
+def getComment(node):
+    names = getChildrenByName(node, '_name')
+    if len(names) == 0:
+        return None
+    node = names[0].previousSibling
+    if node is None:
+        return None
+    if node.nodeType == node.TEXT_NODE and not node.nodeValue.strip():
+        node = node.previousSibling
+        if node is None:
+            return None
+    if node.nodeType == node.COMMENT_NODE:
+        return node.nodeValue.strip()
+    else:
+        return None
+
+def printComment(indent, comment, keep_newlines=False):
+    if not keep_newlines:
+        comment = re.sub(r'\n[ \t]*', r' ', comment)
+    prefix = '%s<!-- ' % indent
+    width = 72 - len(prefix)
+    if len(comment) < width and comment.find('\n') == -1:
+        print '%s<!-- %s -->' % (indent, comment)
+        return
+
+    while len(comment) > width or comment.find('\n') != -1:
+        brk = comment.find('\n', 0, width)
+        if brk == -1:
+            brk = comment.rfind(' ', 0, width)
+            if brk == -1:
+                brk == comment.find(' ')
+                if brk == -1:
+                    break
+        print '%s%s' % (prefix, comment[:brk])
+        prefix = '%s     ' % indent
+        brk += 1
+        comment = comment[brk:]
+    if len(comment):
+        print '%s%s' % (prefix, comment)
+    print '%s  -->' % indent
+
+# other helpers
+def getFipsCodes(node, container):
+    # values is a list of string values of <fips-code> elements
+    values = [node.firstChild.nodeValue for node in getChildrenByName(node, 'fips-code')]
+    # code_lists is a list of lists of individual codes
+    code_lists = [value.split("|") for value in values]
+    if code_lists:
+        for code in reduce(list.__add__, code_lists):
+            fips_codes[code] = container
+
+    return values
+
+def getFipsCountry(city):
+    if city.state_code != "" and city.state_code in fips_codes:
+        country = fips_codes[city.state_code]
+    elif city.country_code != "" and city.country_code in fips_codes:
+        country = fips_codes[city.country_code]
+    else:
+        return None
+    while not isinstance(country, Country):
+        country = country.parent
+    return country
+
+def decimal_degrees_to_dms(lat, long):
+    if lat < 0:
+        lat = -lat
+        dir = "S"
+    else:
+        dir = "N"
+    lat_deg = int(lat)
+    lat_min = int((lat - lat_deg) * 60)
+    lat_sec = int((((lat - lat_deg) * 60) - lat_min) * 60)
+    ret = "%02d-%02d" % (lat_deg, lat_min)
+    if lat_sec != 0:
+        ret += "-%02d" % lat_sec
+    ret += dir
+
+    if long < 0:
+        long = -long
+        dir = "W"
+    else:
+        dir = "E"
+    long_deg = int(long)
+    long_min = int((long - long_deg) * 60)
+    long_sec = int((((long - long_deg) * 60) - long_min) * 60)
+    ret += " %03d-%02d" % (long_deg, long_min)
+    if long_sec != 0:
+        ret += "-%02d" % long_sec
+    ret += dir
+    return ret
+
+languages = {}
+# FIXME, configurable
+iso639 = minidom.parse('/usr/share/xml/iso-codes/iso_639_3.xml')
+for lang in iso639.getElementsByTagName('iso_639_3_entry'):
+    name = lang.getAttribute('name')
+    name = re.sub(r",.*", r"", name)
+    languages[lang.getAttribute('id')] = name
+languages.setdefault('(Unknown Language)')
+
+# classes
+class LocBase:
+    def __init__(self, parent, arg):
+        self.parent = parent
+        self.contents = []
+
+        if isinstance(arg, minidom.Element):
+            self.name = getChildContentByName(arg, '_name')
+            self.iso_code = getChildContentByName(arg, 'iso-code')
+            self.tz_hint = getChildContentByName(arg, 'tz-hint')
+            self.pref_lang = getChildContentByName(arg, 'pref-lang')
+            self.zone = getChildContentByName(arg, 'zone')
+            self.radar = getChildContentByName(arg, 'radar')
+            self.coordinates = getChildContentByName(arg, 'coordinates')
+            self.fips_codes = getFipsCodes(arg, self)
+            self.comment = getComment(arg)
+        else:
+            self.name = None
+            self.iso_code = None
+            self.tz_hint = None
+            self.pref_lang = None
+            self.fips_codes = None
+            self.comment = None
+            self.zone = None
+            self.radar = None
+
+    def __cmp__(self, other):
+        return cmp(self.name, other.name)
+
+    def print_xml(self, indent):
+        if self.iso_code is not None:
+            print '%s  <iso-code>%s</iso-code>' % (indent, self.iso_code)
+        if self.pref_lang is not None:
+            print '%s  <pref-lang>%s</pref-lang>' % (indent, self.pref_lang)
+        if self.fips_codes is not None:
+            for value in self.fips_codes:
+                print '%s  <fips-code>%s</fips-code>' % (indent, value)
+        if self.tz_hint is not None:
+            print '%s  <tz-hint>%s</tz-hint>' % (indent, self.tz_hint)
+        if self.zone is not None:
+            print '%s  <zone>%s</zone>' % (indent, self.zone)
+        if self.radar is not None:
+            print '%s  <radar>%s</radar>' % (indent, self.radar)
+        if self.coordinates is not None:
+            print '%s  <coordinates>%s</coordinates>' % (indent, self.coordinates)
+        for item in self.contents:
+            item.print_xml(indent + '  ')
+
+    def station_prefixes(self):
+        return reduce(set.__or__, [x.station_prefixes() for x in self.contents], set())
+
+class Region(LocBase):
+    def __init__(self, elt):
+        LocBase.__init__(self, None, elt)
+
+    def print_xml(self, indent):
+        print '%s<region>' % indent
+        if self.comment is not None:
+            printComment(indent + '  ', self.comment)
+        print '%s  <_name>%s</_name>' % (indent, saxutils.escape(self.name))
+        LocBase.print_xml(self, indent)
+        print '%s</region>' % indent
+
+class Country(LocBase):
+    def __init__(self, elt):
+        LocBase.__init__(self, None, elt)
+        self.missing_stations = []
+        self.unknown_stations = []
+        self.missing_cities = []
+
+    def print_xml(self, indent):
+        print '%s<country>' % indent
+        if self.comment is not None:
+            printComment(indent + '  ', self.comment)
+        print '%s  <_name>%s</_name>' % (indent, saxutils.escape(self.name))
+        if len(self.missing_stations):
+            self.missing_stations.sort()
+            comment = 'Could not find cities for the following weather stations:\n'
+            for station in self.missing_stations:
+                comment += '%s - %s (%s)\n' % (station.code, station.name, station.coordinates)
+            printComment(indent + '  ', comment, True)
+        if len(self.unknown_stations):
+            self.unknown_stations.sort()
+            comment = 'Could not find information about the following stations, which may be in %s:\n' % self.name
+            for code in self.unknown_stations:
+                comment += '%s ' % code
+            printComment(indent + '  ', comment, True)
+        if len(self.missing_cities):
+            self.missing_cities.sort()
+            comment = 'Could not find weather stations for the following major cities:\n'
+            comment += self.missing_cities[0].name
+            for city in self.missing_cities[1:]:
+                comment += ', %s' % city.name
+            printComment(indent + '  ', comment, True)
+        LocBase.print_xml(self, indent)
+        print '%s</country>' % indent
+
+class State(LocBase):
+    def __init__(self, parent, elt):
+        LocBase.__init__(self, parent, elt)
+        if self.comment is None:
+            self.comment = 'Translators: this is a state/province/territory in %s' % self.parent.name
+
+    def print_xml(self, indent):
+        print '%s<state>' % indent
+        if self.comment is not None:
+            printComment(indent + '  ', self.comment)
+        print '%s  <_name>%s</_name>' % (indent, saxutils.escape(self.name))
+        LocBase.print_xml(self, indent)
+        print '%s</state>' % indent
+
+class City(LocBase):
+    def __init__(self, arg):
+        LocBase.__init__(self, None, arg)
+
+        self.name_comment = []
+
+        if isinstance(arg, tuple):
+            (self.id, self.latitude, self.longitude, elevation, importance, self.country_code, self.state_code, self.county_code, name_type, self.name_lang, short_name, long_name, flat_name) = arg
+            self.coordinates = decimal_degrees_to_dms(self.latitude, self.longitude)
+            self.is_capital = importance == 3
+            self.name = short_name or long_name
+            self.has_conventional_name = name_type == 'C'
+        elif isinstance(arg, LocBase):
+            self.name = arg.name
+            self.name_lang = None
+            self.has_conventional_name = False
+            self.id = name
+            self.country_code = arg.country_code
+            self.state_code = arg.state_code
+            self.county_code = None
+            self.is_capital = False
+            self.coordinates = arg.coordinates
+            self.latitude = arg.latitude
+            self.longitude = arg.longitude
+
+    def print_xml(self, indent):
+        self.contents.sort(lambda l1, l2: cmp(distance(self.latitude, self.longitude, l1.latitude, l1.longitude),
+                                              distance(self.latitude, self.longitude, l2.latitude, l2.longitude)))
+
+        if self.comment is None and self.parent is not None:
+            if self.is_capital:
+                country = self.parent
+                while not isinstance(country, Country):
+                    country = country.parent
+                self.comment = 'Translators: this is the capital of %s' % country.name
+            elif self.parent.parent is not None:
+                self.comment = 'Translators: this is a city in %s in %s' % \
+                               (self.parent.name, self.parent.parent.name)
+            else:
+                self.comment = 'Translators: this is a city in %s' % self.parent.name
+
+        print '%s<city>' % indent
+        if self.comment is not None:
+            printComment(indent + '  ', self.comment)
+        if len(self.name_comment):
+            printComment(indent + '  ', '\n'.join(self.name_comment), True)
+        print '%s  <_name>%s</_name>' % (indent, saxutils.escape(self.name))
+        print '%s  <coordinates>%s</coordinates>' % (indent, self.coordinates)
+        for item in self.contents:
+            item.print_xml(indent + '  ', self, len(self.contents) > 1)
+        print '%s</city>' % indent
+
+class Location(LocBase):
+    def __init__(self, arg):
+        LocBase.__init__(self, None, arg)
+
+        if isinstance(arg, tuple):
+            (self.code, self.name, self.state_code, self.country_code, self.latitude, self.longitude, elevation) = arg
+            self.coordinates = decimal_degrees_to_dms(self.latitude, self.longitude)
+            if self.code in station_timezones:
+                self.tz_hint = station_timezones[self.code]
+            if self.code in station_zones:
+                self.zone = station_zones[self.code]
+            if self.code in station_radars:
+                self.radar = station_radars[self.code]
+            if self.code in station_comments:
+                self.comment = station_comments[self.code]
+
+    def print_xml(self, indent, city=None, localize=True):
+        print '%s<location>' % indent
+        name = self.name
+        if city is not None:
+            if name.startswith("%s, " % city.name):
+                if name.startswith("%s, %s" % (city.name, city.name)):
+                    name = name[len(city.name) + 2:]
+                elif name.endswith("port") or name.endswith("Field") or \
+                         name.endswith("Terminal") or name.endswith("Base"):
+                    name = name[len(city.name) + 2:]
+            elif name.endswith(", %s" % city.name):
+                name = name[:-len(city.name) - 2]
+        #if self.comment is not None:
+        #    print '%s  <!-- %s -->' % (indent, self.comment)
+        if localize:
+            print '%s  <_name>%s</_name>' % (indent, saxutils.escape(name))
+        else:
+            print '%s  <name>%s</name>' % (indent, saxutils.escape(name))
+        print '%s  <code>%s</code>' % (indent, self.code)
+        LocBase.print_xml(self, indent)
+        print '%s</location>' % indent
+
+    def station_prefixes(self):
+        return set([self.code[:2]])
+
+def do_locations(container, state, parent_zone, parent_radar):
+    for xlocation in getChildrenByName(container, 'location'):
+        code = getChildContentByName(xlocation, 'code')
+        tz_hint = getChildContentByName(xlocation, 'tz-hint')
+        zone = getChildContentByName(xlocation, 'zone') or parent_zone
+        radar = getChildContentByName(xlocation, 'radar') or parent_radar
+        comment = getComment(xlocation)
+
+        if tz_hint is not None:
+            station_timezones[code] = tz_hint
+        if zone is not None:
+            station_zones[code] = zone
+        if radar is not None:
+            station_radars[code] = radar
+        if comment is not None:
+            station_comments[code] = comment
+        if state is not None:
+            station_states[code] = state
+
+def do_cities(container, state):
+    for xcity in getChildrenByName(container, 'city'):
+        zone = getChildContentByName(xcity, 'zone')
+        radar = getChildContentByName(xcity, 'radar')
+        do_locations(xcity, state, zone, radar)
+
+# Read the existing regions/countries/states
+old = minidom.parse('Locations.xml.in')
+
+regions = []
+for xreg in old.getElementsByTagName('region'):
+    reg = Region(xreg)
+    regions.append(reg)
+
+    for xcountry in getChildrenByName(xreg, 'country'):
+        country = Country(xcountry)
+        reg.contents.append(country)
+        do_cities(xcountry, None)
+        do_locations(xcountry, None, None, None)
+
+        for xstate in getChildrenByName(xcountry, 'state'):
+            state = State(country, xstate)
+            country.contents.append(state)
+
+            do_cities(xstate, state)
+            do_locations(xstate, state, None, None)
+
+regions.sort()
+
+# location-finding helpers
+def distance(lat1, long1, lat2, long2):
+    long1 = float(long1) * math.pi / 180.0
+    lat1  = float(lat1) * math.pi / 180.0
+    long2 = float(long2) * math.pi / 180.0
+    lat2  = float(lat2) * math.pi / 180.0
+    tmp = math.cos(lat1) * math.cos(lat2) * math.cos(long1 - long2) + math.sin(lat1) * math.sin(lat2)
+    # rounding errors can cause us to get a result out of range...
+    if tmp >= 1.0:
+        return 0.0
+    return math.acos(tmp) * 6372.795
+
+cc = db.cursor()
+cities = {}
+def get_city(id, country_code=None):
+    if id in cities:
+        return cities[id]
+
+    city = None
+    best_match = None
+
+    cc.execute("SELECT * FROM cities WHERE id=?", (id,))
+    matches = cc.fetchall()
+
+    for match in matches:
+        (name_type, language, short_name, long_name) = match[8:12]
+        if not country_code:
+            country_code = match[5]
+
+        # name type: "N" = standard, "NS" = standard in non-Latin
+        # script, "C" = conventional (ie, traditional English)
+        if name_type == 'C' and ( language == 'eng' or language == ''):
+            city = City(match)
+            best_match = match
+            break
+        elif name_type == 'N':
+            if city is None:
+                city = City(match)
+                best_match = match
+            elif not city.has_conventional_name:
+                country = getFipsCountry(city)
+                if country is not None and country.pref_lang == language:
+                    city = City(match)
+                    best_match = match
+
+    # Shouldn't happen...
+    if city is None:
+        city = City(matches[0])
+        best_match = matches[0]
+
+    # See if the city has an ambiguous name
+    (short_name, long_name) = best_match[10:12]
+    if short_name and long_name:
+        country_code = best_match[5]
+        cc.execute("SELECT COUNT(*) FROM cities WHERE short_name=? AND country_code=?", (short_name, country_code))
+        count = int(cc.fetchone()[0])
+        if count > 1:
+            city.name = long_name
+
+    # Note local names
+    localnames = {}
+    local_short_name =  None
+    script_name = None
+    for match in matches:
+        (name_type, language, short_name, long_name) = match[8:12]
+        name = short_name or long_name
+        if name_type != 'N' and name_type != 'NS':
+            continue
+        if language == 'eng' or name == city.name:
+            continue
+
+        if long_name == city.name:
+            local_short_name = short_name
+        elif language == city.name_lang and name_type == 'NS':
+            script_name = name
+        elif language in localnames:
+            if localnames[language].find(name) == -1:
+                if name_type == 'N':
+                    localnames[language] = "%s / %s" % (name, localnames[language])
+                else:
+                    localnames[language] = "%s / %s" % (localnames[language], name)
+        else:
+            localnames[language] = name
+
+    if city.has_conventional_name and (script_name is not None or len(localnames) > 0):
+        city.name_comment.append('"%s" is the traditional English name.' % city.name)
+    if local_short_name is not None:
+        country = getFipsCountry(city)
+        city.name_comment.append('This is one of several cities in %s called "%s".' % (country.name, local_short_name))
+    if script_name is not None:
+        city.name_comment.append('The name is also written "%s".' % script_name)
+    for lang in localnames:
+        if lang == "":
+            city.name_comment.append('The local name is "%s".' % localnames[lang])
+        else:
+            city.name_comment.append('The local name in %s is "%s".' % (languages[lang], localnames[lang]))
+
+    cities[id] = city
+    if country_code:
+        city.country_code = country_code
+    return city
+
+def fakeable(station):
+    # Use weather station names to fake "city" names for mostly-
+    # uninhabited regions. At the moment, that means Antarctica and
+    # United States Minor Outlying Islands
+    return station.country_code == 'AY' or \
+           station.state_code == 'US74'
+
+def add_city(city, station):
+    if city.id in stations:
+        stations[city.id].append(station.code)
+    else:
+        stations[city.id] = [station.code]
+
+    for st in city.contents:
+        if st.code == station.code:
+            return
+    city.contents.append(station)
+
+def find_city(c, city_name, station, try_nearby):
+    if fakeable(station):
+        city = City(station)
+        cities[city.id] = city
+        add_city(city, station)
+        return city
+
+    locs = []
+    shortened = False
+    while len(locs) == 0 and len(city_name) > 0:
+        flat_name = re.sub(r"[^a-zA-Z]", r"", city_name.upper())
+        if station.country_code == "":
+            c.execute("SELECT * FROM cities WHERE latitude BETWEEN ? AND ? AND longitude BETWEEN ? AND ? AND ( short_name=? OR long_name=? OR flat_name=? )", (station.latitude - 2.0, station.latitude + 2.0, station.longitude - 2.0, station.longitude + 2.0, city_name, city_name, flat_name))
+        else:
+            c.execute("SELECT * FROM cities WHERE country_code=? AND latitude BETWEEN ? AND ? AND longitude BETWEEN ? AND ? AND ( short_name=? OR long_name=? OR flat_name=? )", (station.country_code[:2], station.latitude - 2.0, station.latitude + 2.0, station.longitude - 2.0, station.longitude + 2.0, city_name, city_name, flat_name))
+        locs = c.fetchall()
+
+        # Strip out cities that are too far away; they're probably false matches
+        ok_locs = []
+        for loc in locs:
+            dist = distance(station.latitude, station.longitude, loc[1], loc[2])
+            if dist < 50.0:
+                ok_locs.append(loc)
+        locs = ok_locs
+
+        if len(locs) == 0:
+            city_name = re.sub(r"[ -]?[^ -]*$", "", city_name)
+            shortened = True
+
+    if len(locs) == 0 and try_nearby:
+        if station.country_code != "":
+            c.execute("SELECT * FROM cities WHERE country_code=? AND latitude BETWEEN ? AND ? AND longitude BETWEEN ? AND ? AND importance != -1", (station.country_code[:2], station.latitude - 1.0, station.latitude + 1.0, station.longitude - 1.0, station.longitude + 1.0))
+        else:
+            c.execute("SELECT * FROM cities WHERE latitude BETWEEN ? AND ? AND longitude BETWEEN ? AND ? AND importance != -1", (station.latitude - 1.0, station.latitude + 1.0, station.longitude - 1.0, station.longitude + 1.0))
+        locs = c.fetchall()
+
+    if len(locs) == 0:
+        return None
+
+    # FIXME: we should factor elevation into this decision as well; we
+    # don't want to use a weather station at sea level for a city in
+    # the mountains, or vice versa.
+    best_dist = 0.0
+    best_loc = None
+    for loc in locs:
+        dist = distance(station.latitude, station.longitude, loc[1], loc[2])
+        if dist > 50.0:
+            continue
+        if loc[4] > 0: # pretend important cities are closer
+            dist = dist / 5.0
+        elif loc[4] < 0: # and trivial ones are farther
+            dist = dist * 2.0
+        if best_loc == None or dist < best_dist:
+            best_loc = loc
+            best_dist = dist
+
+    if best_loc is None:
+        return None
+
+    city = get_city(best_loc[0], station.country_code)
+    add_city(city, station)
+
+    if not shortened:
+        station.name = station.name.replace(city_name, city.name)
+
+    return city
+
+observations = urllib.urlopen('http://gnome.org/~danw/observations.txt')
+recent = [obs.rstrip() for obs in observations.readlines()]
+observations.close()
+
+stations = {}
+
+c = db.cursor()
+
+# Now read in the stations, filter out the losers, and clean up the rest
+sc = db.cursor()
+sc.execute("SELECT * FROM stations");
+while True:
+    loc = sc.fetchone()
+    if loc is None:
+        break
+    station = Location(loc)
+
+    if station.code not in recent:
+        continue
+
+    city = None
+
+    # Find a match
+    location = station.name.replace(", ", " / ")
+    if location.find(" / ") != -1:
+        matched_any = False
+        na_parts = [part for part in location.split(" / ")
+                    if not part.endswith('Airport') and
+                    part.find('County') == -1]
+        for part in na_parts:
+            city = find_city(c, part, station, False)
+            if city is not None:
+                matched_any = True
+        if matched_any:
+            continue
+
+    if city is None:
+        airport = re.search(r"([^/]*?) (International |Municipal )?Airport", location)
+        if airport is not None:
+            city = find_city(c, airport.group(1), station, False)
+
+    if city is None:
+        city = find_city(c, location, station, True)
+        if city is None:
+            country = fips_codes[station.country_code]
+            country.missing_stations.append(station)
+            continue
+
+# Now do missing major cities
+c.execute("SELECT id FROM cities WHERE importance > 1");
+while True:
+    loc = c.fetchone()
+    if loc is None:
+        break
+
+    id = loc[0]
+    if id in stations:
+        continue
+    city = get_city(id)
+    country = fips_codes[city.country_code]
+
+    sc.execute("SELECT * FROM stations WHERE latitude BETWEEN ? AND ? AND longitude BETWEEN ? AND ?", (city.latitude - 1.0, city.latitude + 1.0, city.longitude - 1.0, city.longitude + 1.0))
+    best_dist = 0.0
+    best_station = None
+    for station in sc.fetchall():
+        if station[0] not in recent:
+            continue
+        dist = distance(city.latitude, city.longitude, station[4], station[5])
+        if dist > 50.0:
+            continue
+        if best_station is None or dist < best_dist:
+            best_station = station
+            best_dist = dist
+
+    if best_station is None:
+        country.missing_cities.append(city)
+        stations[id] = []
+        continue
+
+    station = Location(best_station)
+    add_city(city, station)
+    if station in country.missing_stations:
+        country.missing_stations.remove(station)
+
+# Find parent states/countries for cities
+for id in cities:
+    city = cities[id]
+    if len(city.contents) == 0:
+        continue
+    if city.parent is not None:
+        continue
+
+    if city.state_code != "" and city.state_code in fips_codes:
+        city.parent = fips_codes[city.state_code]
+    else:
+        for station in city.contents:
+            if station.code in station_states:
+                city.parent = station_states[station.code]
+                break
+        else:
+            if city.country_code != "" and city.country_code in fips_codes:
+                city.parent = fips_codes[city.country_code]
+            else:
+                print "Could not find container for city %s in %s" % (city.name, city.country_code);
+                continue
+    city.parent.contents.append(city)
+
+# Note unknown weather stations
+for id in stations:
+    for code in stations[id]:
+        if code in recent:
+            recent.remove(code)
+
+for country_code in fips_codes:
+    country = fips_codes[country_code]
+    if not isinstance(country, Country):
+        continue
+    for prefix in country.station_prefixes():
+        for station_code in recent:
+            if station_code[:2] == prefix and \
+               not station_code in [station.code for station in country.missing_stations]:
+                country.unknown_stations.append(station_code)
+    for station_code in country.unknown_stations:
+        if station_code in recent:
+            recent.remove(station_code)
+
+# Sort containers
+for code in fips_codes:
+    fips_codes[code].contents.sort()
+
+print '<?xml version="1.0" encoding="utf-8"?>'
+print '<!DOCTYPE gweather SYSTEM "locations.dtd">'
+print '<gweather format="1.0">'
+for region in regions:
+    region.contents.sort()
+    region.print_xml('  ')
+if len(recent):
+    comment = 'Could not find information about the following stations:\n'
+    for station_code in recent:
+        comment += '%s ' % station_code
+    printComment('', comment, True)
+print '</gweather>'
diff --git a/data/check-observations.py b/data/check-observations.py
new file mode 100755
index 0000000..8bd7a1d
--- /dev/null
+++ b/data/check-observations.py
@@ -0,0 +1,68 @@
+#!/usr/bin/python
+
+from ftplib import FTP
+import datetime
+import os
+import re
+import sys
+
+file = sys.argv[1]
+file_0 = '%s.0' % file
+file_1 = '%s.1' % file
+file_2 = '%s.2' % file
+
+# Rename and read in earlier observations
+os.rename(file_1, file_2)
+os.rename(file_0, file_1)
+f = open(file_1)
+recent_1 = [line.rstrip() for line in f.readlines()]
+f.close()
+f = open(file_2)
+recent_2 = [line.rstrip() for line in f.readlines()]
+f.close()
+
+# Create the regular expression for matching recent observations.
+# The listing looks like:
+#   -rw-r--r--    1 3110     0              63 May 20 17:05 ZYTX.TXT
+# We use ctime rather than strftime to avoid the month being localized
+
+today = datetime.date.today()
+stamp1 = today.ctime()[4:10].replace('  ', ' 0')
+yesterday = today - datetime.timedelta(1)
+stamp2 = yesterday.ctime()[4:10].replace('  ', ' 0')
+rex = re.compile("(%s|%s) \\d\\d:\\d\\d (....)\\.TXT$" % (stamp1, stamp2))
+
+recent_0 = []
+f = open(file_0, 'w')
+
+def check_observation(line):
+    match = rex.search(line)
+    if match is None:
+        return
+
+    obs = match.group(2)
+    recent_0.append(obs)
+    f.write('%s\n' % obs)
+
+ftp = FTP('tgftp.nws.noaa.gov')
+ftp.login()
+ftp.cwd('/data/observations/metar/stations')
+ftp.retrlines('LIST', check_observation)
+ftp.quit()
+f.close()
+
+if len(recent_0) < 3000:
+    sys.stderr.write('Error parsing METAR observations!\n')
+    sys.exit(1)
+
+recent_0.sort()
+
+recent = []
+for obs in recent_0:
+    if obs in recent_1 and obs in recent_2:
+        recent.append(obs)
+
+f = open(file, 'w')
+for obs in recent:
+    f.write('%s\n' % obs)
+f.close()
