kabc Library API Documentation

ldifconverter.cpp

00001 /* 00002 This file is part of libkabc. 00003 Copyright (c) 2003 Helge Deller <deller@kde.org> 00004 00005 This library is free software; you can redistribute it and/or 00006 modify it under the terms of the GNU Library General Public 00007 License as published by the Free Software Foundation; either 00008 version 2 of the License, or (at your option) any later version. 00009 00010 This library is distributed in the hope that it will be useful, 00011 but WITHOUT ANY WARRANTY; without even the implied warranty of 00012 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 00013 Library General Public License for more details. 00014 00015 You should have received a copy of the GNU Library General Public License 00016 along with this library; see the file COPYING.LIB. If not, write to 00017 the Free Software Foundation, Inc., 59 Temple Place - Suite 330, 00018 Boston, MA 02111-1307, USA. 00019 */ 00020 00021 00022 /* 00023 Useful links: 00024 - http://tldp.org/HOWTO/LDAP-Implementation-HOWTO/schemas.html 00025 - http://www.faqs.org/rfcs/rfc2849.html 00026 00027 Not yet handled items: 00028 - objectclass microsoftaddressbook 00029 - info, 00030 - initials, 00031 - otherfacsimiletelephonenumber, 00032 - otherpager, 00033 - physicaldeliveryofficename, 00034 */ 00035 00036 #include <qstring.h> 00037 #include <qstringlist.h> 00038 #include <qregexp.h> 00039 #include <qtextstream.h> 00040 00041 #include <klocale.h> 00042 #include <kdebug.h> 00043 #include <kmdcodec.h> 00044 00045 #include "addressee.h" 00046 #include "address.h" 00047 00048 #include "ldifconverter.h" 00049 #include "vcardconverter.h" 00050 00051 using namespace KABC; 00052 00053 /* generate LDIF stream */ 00054 00055 bool LDIFConverter::addresseeToLDIF( const AddresseeList &addrList, QString &str ) 00056 { 00057 AddresseeList::ConstIterator it; 00058 for ( it = addrList.begin(); it != addrList.end(); ++it ) { 00059 addresseeToLDIF( *it, str ); 00060 } 00061 return true; 00062 } 00063 00064 QString LDIFConverter::makeLDIFfieldString( QString formatStr, QString value, bool allowEncode ) 00065 { 00066 if ( value.isEmpty() ) 00067 return QString(); 00068 00069 // append format if not given 00070 if (formatStr.find(':') == -1) 00071 formatStr.append(": %1\n"); 00072 00073 // check if base64-encoding is needed 00074 bool printable = true; 00075 unsigned int i, len; 00076 len = value.length(); 00077 for (i = 0; i<len; ++i ) { 00078 if (!value[i].isPrint()) { 00079 printable = false; 00080 break; 00081 } 00082 } 00083 00084 if (printable) // always encode if we find special chars... 00085 printable = (value.find('\n') == -1); 00086 00087 if (!printable && allowEncode) { 00088 // encode to base64 00089 value = KCodecs::base64Encode( value.utf8() ); 00090 int p = formatStr.find(':'); 00091 if (p>=0) 00092 formatStr.insert(p, ':'); 00093 } 00094 00095 // generate the new string and split it to 72 chars/line 00096 QCString txt = (formatStr.arg(value)).utf8(); 00097 00098 if (allowEncode) { 00099 len = txt.length(); 00100 if (len && txt[len-1] == '\n') 00101 --len; 00102 i = 72; 00103 while (i < len) { 00104 txt.insert(i, "\n "); 00105 i += 72+1; 00106 len += 2; 00107 } 00108 } 00109 00110 return QString::fromUtf8(txt); 00111 } 00112 00113 00114 00115 static void ldif_out( QTextStream &t, QString formatStr, QString value, bool allowEncode = true ) 00116 { 00117 if ( value.isEmpty() ) 00118 return; 00119 00120 QString txt = LDIFConverter::makeLDIFfieldString( formatStr, value, allowEncode ); 00121 00122 // write the string 00123 t << txt; 00124 } 00125 00126 00127 bool LDIFConverter::addresseeToLDIF( const Addressee &addr, QString &str ) 00128 { 00129 if ( addr.isEmpty() ) 00130 return false; 00131 00132 QTextStream t( str, IO_WriteOnly|IO_Append ); 00133 t.setEncoding( QTextStream::UnicodeUTF8 ); 00134 00135 const Address homeAddr = addr.address( Address::Home ); 00136 const Address workAddr = addr.address( Address::Work ); 00137 00138 ldif_out( t, "dn: %1", QString( "cn=%1,mail=%2\n" ) 00139 .arg( addr.formattedName().simplifyWhiteSpace() ) 00140 .arg( addr.preferredEmail() ), false /*never encode!*/ ); 00141 ldif_out( t, "givenname: %1\n", addr.givenName() ); 00142 ldif_out( t, "sn: %1\n", addr.familyName() ); 00143 ldif_out( t, "cn: %1\n", addr.formattedName().simplifyWhiteSpace() ); 00144 ldif_out( t, "uid: %1\n", addr.uid() ); 00145 ldif_out( t, "nickname: %1\n", addr.nickName() ); 00146 ldif_out( t, "xmozillanickname: %1\n", addr.nickName() ); 00147 00148 ldif_out( t, "mail: %1\n", addr.preferredEmail() ); 00149 if ( addr.emails().count() > 1 ) 00150 ldif_out( t, "mozillasecondemail: %1\n", addr.emails()[ 1 ] ); 00151 //ldif_out( t, "mozilla_AIMScreenName: %1\n", "screen_name" ); 00152 00153 ldif_out( t, "telephonenumber: %1\n", addr.phoneNumber( PhoneNumber::Work ).number() ); 00154 ldif_out( t, "facsimiletelephonenumber: %1\n", addr.phoneNumber( PhoneNumber::Fax ).number() ); 00155 ldif_out( t, "homephone: %1\n", addr.phoneNumber( PhoneNumber::Home ).number() ); 00156 ldif_out( t, "mobile: %1\n", addr.phoneNumber( PhoneNumber::Cell ).number() ); // Netscape 7 00157 ldif_out( t, "cellphone: %1\n", addr.phoneNumber( PhoneNumber::Cell ).number() ); // Netscape 4.x 00158 ldif_out( t, "pager: %1\n", addr.phoneNumber( PhoneNumber::Pager ).number() ); 00159 ldif_out( t, "pagerphone: %1\n", addr.phoneNumber( PhoneNumber::Pager ).number() ); 00160 00161 ldif_out( t, "streethomeaddress: %1\n", homeAddr.street() ); 00162 ldif_out( t, "postalcode: %1\n", workAddr.postalCode() ); 00163 ldif_out( t, "postofficebox: %1\n", workAddr.postOfficeBox() ); 00164 00165 QStringList streets = QStringList::split( '\n', homeAddr.street() ); 00166 if ( streets.count() > 0 ) 00167 ldif_out( t, "homepostaladdress: %1\n", streets[ 0 ] ); // Netscape 7 00168 if ( streets.count() > 1 ) 00169 ldif_out( t, "mozillahomepostaladdress2: %1\n", streets[ 1 ] ); // Netscape 7 00170 ldif_out( t, "mozillahomelocalityname: %1\n", homeAddr.locality() ); // Netscape 7 00171 ldif_out( t, "mozillahomestate: %1\n", homeAddr.region() ); 00172 ldif_out( t, "mozillahomepostalcode: %1\n", homeAddr.postalCode() ); 00173 ldif_out( t, "mozillahomecountryname: %1\n", Address::ISOtoCountry(homeAddr.country()) ); 00174 ldif_out( t, "locality: %1\n", workAddr.locality() ); 00175 ldif_out( t, "streetaddress: %1\n", workAddr.street() ); // Netscape 4.x 00176 00177 streets = QStringList::split( '\n', workAddr.street() ); 00178 if ( streets.count() > 0 ) 00179 ldif_out( t, "postaladdress: %1\n", streets[ 0 ] ); 00180 if ( streets.count() > 1 ) 00181 ldif_out( t, "mozillapostaladdress2: %1\n", streets[ 1 ] ); 00182 ldif_out( t, "countryname: %1\n", Address::ISOtoCountry(workAddr.country()) ); 00183 ldif_out( t, "l: %1\n", workAddr.locality() ); 00184 ldif_out( t, "c: %1\n", Address::ISOtoCountry(workAddr.country()) ); 00185 ldif_out( t, "st: %1\n", workAddr.region() ); 00186 00187 ldif_out( t, "title: %1\n", addr.title() ); 00188 ldif_out( t, "vocation: %1\n", addr.prefix() ); 00189 ldif_out( t, "ou: %1\n", addr.role() ); 00190 ldif_out( t, "o: %1\n", addr.organization() ); 00191 ldif_out( t, "organization: %1\n", addr.organization() ); 00192 ldif_out( t, "organizationname: %1\n", addr.organization() ); 00193 ldif_out( t, "department: %1\n", addr.custom("KADDRESSBOOK", "X-Department") ); 00194 ldif_out( t, "workurl: %1\n", addr.url().prettyURL() ); 00195 ldif_out( t, "homeurl: %1\n", addr.url().prettyURL() ); 00196 ldif_out( t, "description: %1\n", addr.note() ); 00197 if (addr.revision().isValid()) 00198 ldif_out(t, "modifytimestamp: %1\n", dateToVCardString( addr.revision()) ); 00199 00200 t << "objectclass: top\n"; 00201 t << "objectclass: person\n"; 00202 t << "objectclass: organizationalPerson\n"; 00203 00204 t << "\n"; 00205 00206 return true; 00207 } 00208 00209 00210 /* convert from LDIF stream */ 00211 00212 bool LDIFConverter::LDIFToAddressee( const QString &str, AddresseeList &addrList, QDateTime dt ) 00213 { 00214 QStringList lines; 00215 00216 if (!dt.isValid()) 00217 dt = QDateTime::currentDateTime(); 00218 00219 lines = QStringList::split( QRegExp("[\x0d\x0a]"), str, false ); 00220 00221 // clean up comments and prepare folded entries and multi-line BASE64 encoded lines 00222 QStringList::Iterator last = lines.end(); 00223 for ( QStringList::Iterator it = lines.begin(); it != lines.end(); ++it ) { 00224 if ( (*it).startsWith("#") ) { // comment ? 00225 it = lines.remove(it); 00226 it--; 00227 continue; 00228 } 00229 if ( last == lines.end() ) { 00230 last = it; 00231 continue; 00232 } 00233 if ((*last).find("::")!=-1 && (*it).find(":")==-1) { // this is a multi-line BASE64 00234 *last += (*it); 00235 lines.remove(it); 00236 it = last; 00237 continue; 00238 } 00239 if ((*last).find(":")!=-1 && (*it).startsWith(" ")) { // this is a folded item 00240 *last += (*it).mid(1); 00241 lines.remove(it); 00242 it = last; 00243 continue; 00244 } 00245 last = it; 00246 } 00247 00248 // variables 00249 addrList = AddresseeList(); 00250 00251 Addressee a; 00252 Address homeAddr, workAddr; 00253 bool cont; 00254 QStringList dnList; 00255 QString dnEntry; 00256 00257 // do the loop... 00258 for ( QStringList::Iterator it = lines.begin(); it != lines.end(); ++it ) { 00259 00260 // create a new (empty) address entry 00261 a = Addressee(); 00262 a.setRevision(dt); 00263 homeAddr = Address( Address::Home ); 00264 workAddr = Address( Address::Work ); 00265 00266 // evaluate previous "dn: *" header values 00267 if (dnList.count()) { 00268 for ( QStringList::Iterator dne = dnList.begin(); dne != dnList.end(); ++dne ) { 00269 parseSingleLine( a, homeAddr, workAddr, *dne ); 00270 } 00271 } 00272 00273 // evaluate until we find another "dn: *" entry or until end of list 00274 do { 00275 cont = parseSingleLine( a, homeAddr, workAddr, *it ); 00276 if (cont && it!=lines.end()) { 00277 ++it; 00278 } 00279 } while (cont && it!=lines.end()); 00280 00281 // if the new address is not empty, append it 00282 if ( !a.formattedName().isEmpty() || !a.name().isEmpty() || 00283 !a.familyName().isEmpty() ) { 00284 if ( !homeAddr.isEmpty() ) 00285 a.insertAddress( homeAddr ); 00286 if ( !workAddr.isEmpty() ) 00287 a.insertAddress( workAddr ); 00288 addrList.append( a ); 00289 } 00290 00291 // did we reached the end of the list 00292 if ( it == lines.end() ) 00293 break; 00294 00295 // we found the "dn: cn=.." entry (e.g. "n: cn=Engelhardt Gerald,l=Frankfurt,ou=BKG,o=Bund,c=DE"). 00296 // Split it now and parse it later. 00297 dnEntry = (*it).replace( '=', ": " ); 00298 dnList = QStringList::split( ',', dnEntry, false ); 00299 dnList.pop_front(); 00300 00301 } // for()... 00302 00303 return true; 00304 } 00305 00306 bool LDIFConverter::parseSingleLine( Addressee &a, Address &homeAddr, 00307 Address &workAddr, QString &line ) 00308 { 00309 if ( line.isEmpty() ) 00310 return true; 00311 00312 QString fieldname, value; 00313 splitLine( line, fieldname, value); 00314 return evaluatePair( a, homeAddr, workAddr, fieldname, value); 00315 } 00316 00317 00318 bool LDIFConverter::splitLine( QString &line, QString &fieldname, QString &value) 00319 { 00320 int position; 00321 00322 position = line.find( "::" ); 00323 if ( position != -1 ) { 00324 // String is BASE64 encoded -> decode it now. 00325 fieldname = line.left( position ).lower(); 00326 value = QString::fromUtf8( KCodecs::base64Decode( 00327 line.mid( position + 3, line.length() - position - 2 ).latin1() ) ) 00328 .simplifyWhiteSpace(); 00329 return true; 00330 } 00331 00332 position = line.find( ":" ); 00333 if ( position != -1 ) { 00334 fieldname = line.left( position ).lower(); 00335 // Convert Utf8 string to unicode so special characters are preserved 00336 // We need this since we are reading normal strings from the file 00337 // which are not converted automatically 00338 value = QString::fromUtf8( line.mid( position + 2, line.length() - position - 2 ).latin1() ); 00339 return true; 00340 } 00341 00342 // strange: we did not find a fieldname 00343 fieldname = ""; 00344 value = line; 00345 return true; 00346 } 00347 00348 00349 bool LDIFConverter::evaluatePair( Addressee &a, Address &homeAddr, 00350 Address &workAddr, 00351 QString &fieldname, QString &value ) 00352 { 00353 if ( fieldname == QString::fromLatin1( "dn" ) ) // ignore & return false! 00354 return false; 00355 00356 if ( fieldname.startsWith("#") ) { 00357 return true; 00358 } 00359 00360 if ( fieldname.isEmpty() && !a.note().isEmpty() ) { 00361 // some LDIF export filters are borken and add additional 00362 // comments on stand-alone lines. Just add them to the notes for now. 00363 a.setNote( a.note() + "\n" + value ); 00364 return true; 00365 } 00366 00367 if ( fieldname == QString::fromLatin1( "givenname" ) ) { 00368 a.setGivenName( value ); 00369 return true; 00370 } 00371 00372 if ( fieldname == QString::fromLatin1( "xmozillanickname") || 00373 fieldname == QString::fromLatin1( "nickname") ) { 00374 a.setNickName( value ); 00375 return true; 00376 } 00377 00378 if ( fieldname == QString::fromLatin1( "sn" ) ) { 00379 a.setFamilyName( value ); 00380 return true; 00381 } 00382 00383 if ( fieldname == QString::fromLatin1( "uid" ) ) { 00384 a.setUid( value ); 00385 return true; 00386 } 00387 if ( fieldname == QString::fromLatin1( "mail" ) || 00388 fieldname == QString::fromLatin1( "mozillasecondemail" ) ) { // mozilla 00389 if ( a.emails().findIndex( value ) == -1 ) 00390 a.insertEmail( value ); 00391 return true; 00392 } 00393 00394 if ( fieldname == QString::fromLatin1( "title" ) ) { 00395 a.setTitle( value ); 00396 return true; 00397 } 00398 00399 if ( fieldname == QString::fromLatin1( "vocation" ) ) { 00400 a.setPrefix( value ); 00401 return true; 00402 } 00403 00404 if ( fieldname == QString::fromLatin1( "cn" ) ) { 00405 a.setFormattedName( value ); 00406 return true; 00407 } 00408 00409 if ( fieldname == QString::fromLatin1( "o" ) || 00410 fieldname == QString::fromLatin1( "organization" ) || // Exchange 00411 fieldname == QString::fromLatin1( "organizationname" ) ) { // Exchange 00412 a.setOrganization( value ); 00413 return true; 00414 } 00415 00416 if ( fieldname == QString::fromLatin1( "description" ) ) { 00417 addComment: 00418 if ( !a.note().isEmpty() ) 00419 a.setNote( a.note() + "\n" ); 00420 a.setNote( a.note() + value ); 00421 return true; 00422 } 00423 00424 if ( fieldname == QString::fromLatin1( "custom1" ) || 00425 fieldname == QString::fromLatin1( "custom2" ) || 00426 fieldname == QString::fromLatin1( "custom3" ) || 00427 fieldname == QString::fromLatin1( "custom4" ) ) { 00428 goto addComment; 00429 } 00430 00431 if ( fieldname == QString::fromLatin1( "homeurl" ) || 00432 fieldname == QString::fromLatin1( "workurl" ) ) { 00433 if (a.url().isEmpty()) { 00434 a.setUrl( value ); 00435 return true; 00436 } 00437 if ( a.url().prettyURL() == KURL(value).prettyURL() ) 00438 return true; 00439 // TODO: current version of kabc only supports one URL. 00440 // TODO: change this with KDE 4 00441 } 00442 00443 if ( fieldname == QString::fromLatin1( "homephone" ) ) { 00444 a.insertPhoneNumber( PhoneNumber( value, PhoneNumber::Home ) ); 00445 return true; 00446 } 00447 00448 if ( fieldname == QString::fromLatin1( "telephonenumber" ) ) { 00449 a.insertPhoneNumber( PhoneNumber( value, PhoneNumber::Work ) ); 00450 return true; 00451 } 00452 00453 if ( fieldname == QString::fromLatin1( "mobile" ) ) { // mozilla/Netscape 7 00454 a.insertPhoneNumber( PhoneNumber( value, PhoneNumber::Cell ) ); 00455 return true; 00456 } 00457 00458 if ( fieldname == QString::fromLatin1( "cellphone" ) ) { 00459 a.insertPhoneNumber( PhoneNumber( value, PhoneNumber::Cell ) ); 00460 return true; 00461 } 00462 00463 if ( fieldname == QString::fromLatin1( "pager" ) || // mozilla 00464 fieldname == QString::fromLatin1( "pagerphone" ) ) { // mozilla 00465 a.insertPhoneNumber( PhoneNumber( value, PhoneNumber::Pager ) ); 00466 return true; 00467 } 00468 00469 if ( fieldname == QString::fromLatin1( "facsimiletelephonenumber" ) ) { 00470 a.insertPhoneNumber( PhoneNumber( value, PhoneNumber::Fax ) ); 00471 return true; 00472 } 00473 00474 if ( fieldname == QString::fromLatin1( "xmozillaanyphone" ) ) { // mozilla 00475 a.insertPhoneNumber( PhoneNumber( value, PhoneNumber::Work ) ); 00476 return true; 00477 } 00478 00479 if ( fieldname == QString::fromLatin1( "street" ) || 00480 fieldname == QString::fromLatin1( "streethomeaddress" ) ) { 00481 homeAddr.setStreet( value ); 00482 return true; 00483 } 00484 00485 if ( fieldname == QString::fromLatin1( "postaladdress" ) ) { // mozilla 00486 workAddr.setStreet( value ); 00487 return true; 00488 } 00489 00490 if ( fieldname == QString::fromLatin1( "mozillapostaladdress2" ) ) { // mozilla 00491 workAddr.setStreet( workAddr.street() + QString::fromLatin1( "\n" ) + value ); 00492 return true; 00493 } 00494 00495 if ( fieldname == QString::fromLatin1( "postalcode" ) ) { 00496 workAddr.setPostalCode( value ); 00497 return true; 00498 } 00499 00500 if ( fieldname == QString::fromLatin1( "postofficebox" ) ) { 00501 workAddr.setPostOfficeBox( value ); 00502 return true; 00503 } 00504 00505 if ( fieldname == QString::fromLatin1( "homepostaladdress" ) ) { // Netscape 7 00506 homeAddr.setStreet( value ); 00507 return true; 00508 } 00509 00510 if ( fieldname == QString::fromLatin1( "mozillahomepostaladdress2" ) ) { // mozilla 00511 homeAddr.setStreet( homeAddr.street() + QString::fromLatin1( "\n" ) + value ); 00512 return true; 00513 } 00514 00515 if ( fieldname == QString::fromLatin1( "mozillahomelocalityname" ) ) { // mozilla 00516 homeAddr.setLocality( value ); 00517 return true; 00518 } 00519 00520 if ( fieldname == QString::fromLatin1( "mozillahomestate" ) ) { // mozilla 00521 homeAddr.setRegion( value ); 00522 return true; 00523 } 00524 00525 if ( fieldname == QString::fromLatin1( "mozillahomepostalcode" ) ) { // mozilla 00526 homeAddr.setPostalCode( value ); 00527 return true; 00528 } 00529 00530 if ( fieldname == QString::fromLatin1( "mozillahomecountryname" ) ) { // mozilla 00531 if ( value.length() <= 2 ) 00532 value = Address::ISOtoCountry(value); 00533 homeAddr.setCountry( value ); 00534 return true; 00535 } 00536 00537 if ( fieldname == QString::fromLatin1( "locality" ) ) { 00538 workAddr.setLocality( value ); 00539 return true; 00540 } 00541 00542 if ( fieldname == QString::fromLatin1( "streetaddress" ) ) { // Netscape 4.x 00543 workAddr.setStreet( value ); 00544 return true; 00545 } 00546 00547 if ( fieldname == QString::fromLatin1( "countryname" ) || 00548 fieldname == QString::fromLatin1( "c" ) ) { // mozilla 00549 if ( value.length() <= 2 ) 00550 value = Address::ISOtoCountry(value); 00551 workAddr.setCountry( value ); 00552 return true; 00553 } 00554 00555 if ( fieldname == QString::fromLatin1( "l" ) ) { // mozilla 00556 workAddr.setLocality( value ); 00557 return true; 00558 } 00559 00560 if ( fieldname == QString::fromLatin1( "st" ) ) { 00561 workAddr.setRegion( value ); 00562 return true; 00563 } 00564 00565 if ( fieldname == QString::fromLatin1( "ou" ) ) { 00566 a.setRole( value ); 00567 return true; 00568 } 00569 00570 if ( fieldname == QString::fromLatin1( "department" ) ) { 00571 a.insertCustom( "KADDRESSBOOK", "X-Department", value ); 00572 return true; 00573 } 00574 00575 if ( fieldname == QString::fromLatin1( "member" ) ) { 00576 // this is a mozilla list member (cn=xxx, mail=yyy) 00577 QStringList list( QStringList::split( ',', value ) ); 00578 QString name, email; 00579 00580 QStringList::Iterator it; 00581 for ( it = list.begin(); it != list.end(); ++it ) { 00582 if ( (*it).startsWith( "cn=" ) ) 00583 name = (*it).mid( 3 ).stripWhiteSpace(); 00584 if ( (*it).startsWith( "mail=" ) ) 00585 email = (*it).mid( 5 ).stripWhiteSpace(); 00586 } 00587 if ( !name.isEmpty() && !email.isEmpty() ) 00588 email = " <" + email + ">"; 00589 a.insertEmail( name + email ); 00590 a.insertCategory( i18n( "List of Emails" ) ); 00591 return true; 00592 } 00593 00594 if ( fieldname == QString::fromLatin1( "modifytimestamp" ) ) { 00595 if (value == QString::fromLatin1("0Z")) // ignore 00596 return true; 00597 QDateTime dt = VCardStringToDate( value ); 00598 if ( dt.isValid() ) { 00599 a.setRevision(dt); 00600 return true; 00601 } 00602 } 00603 00604 if ( fieldname == QString::fromLatin1( "objectclass" ) ) // ignore 00605 return true; 00606 00607 kdWarning() << QString("LDIFConverter: Unknown field for '%1': '%2=%3'\n") 00608 .arg(a.formattedName()).arg(fieldname).arg(value); 00609 00610 return true; 00611 } 00612
KDE Logo
This file is part of the documentation for kabc Library Version 3.2.3.
Documentation copyright © 1996-2004 the KDE developers.
Generated on Mon Aug 30 22:56:01 2004 by doxygen 1.3.8 written by Dimitri van Heesch, © 1997-2003