#!/usr/bin/perl

# m2bz: Mantis to Bugzilla Database Migration Tool
# =================================================
#
# Migration Tool version:     20030608.1342
# Required Mantis version:    0.17.5
# Required Bugzilla version:  2.16.3
#
# (C) 2003 Linksystem München <info@link-m.de>
# Maintained by Julian Mehnle <julian@mehnle.net>
#
# Distributed under the terms of -- at your option -- any of the following
# licenses:
# - GNU General Public License (version 2)
#   Read the GNU GPL here: <http://www.gnu.org/licenses/gpl.html>
# - Mozilla Public License (version 1.1)
#   Read the MPL here: <http://www.mozilla.org/MPL>
#
# Get the latest version and read the docs here:
# <http://www.mehnle.net/software/m2bz>
# 
# $Id: m2bz.pl,v 1.6 2003/06/20 19:33:13 julian Exp $
#
# $Log: m2bz.pl,v $
# Revision 1.6  2003/06/20 19:33:13  julian
# - m2bz is now dual-licensed under the GPL 2 as well as the MPL 1.1.
#
# Revision 1.3  2003/06/08 13:45:24  julian
# First completely functional and tested version, with limited localization
# support -- ready for first release!
#
###############################################################################

# Terms (Class names, Object name member names)
# ==============================================
#
# Mantis and Bugzilla use mostly confusing and unfitting and even partially
# inconsistent terms, so I decided to coin my own:
#
# My term             | Mantis term         | Bugzilla term
# --------------------+---------------------+--------------------
# Class names
# - - - - - - - - - - + - - - - - - - - - - + - - - - - - - - - -
# user                | user                | user, profile
# project             | project             | product,
#                     |                     | program
# project milestone   | -                   | product milestone
# project version     | project version     | program version
# project component   | project category    | program component
# bug                 | bug                 | bug
# bug comment         | bug description,    | bug comment
#                     | bug steps to repro  |
#                     | bug addl info       |
# bug attachemnt      | bug file            | bug attachment
# --------------------+---------------------+--------------------
# Object name member names
# - - - - - - - - - - + - - - - - - - - - - + - - - - - - - - - -
# user name           | username            | login_name
# project name        | project name        | product
# pj milestone name   | -                   | pd milestone value
# pj version name     | pj version          | pd version value
# pj component name   | pj category         | pd component value
# --------------------+---------------------+--------------------
#
# For more object member name mappings, please see the read_objects() and
# write_objects() calls in the code, the syntax is { my => mantis/bugzilla }.
#
# Other terms:
#
# l10n = localization
#
###############################################################################

use v5.6;

use warnings;
use diagnostics;
use strict;

use Getopt::Mixed;
use DBI;
use HTML::TreeBuilder;
use HTML::FormatText;

# Declarations and Constants:
###############################################################################

use constant TRUE       => (0 == 0);
use constant FALSE      => not TRUE;

use constant DEFAULTS   => {
    hostname            => 'localhost',
    port                => 3306,
    
    mantis_database     => 'mantis',
    mantis_username     => 'root',
    mantis_password     => '',
    
    bugzilla_database   => 'bugzilla',
    bugzilla_username   => 'root',
    bugzilla_password   => ''
};

use constant TEXT_L10N  => {
    # Priorities:
#    'wishlist'          => 'Wunschliste',
#    'low'               => 'niedrig',
#    'normal'            => 'normal',
#    'high'              => 'hoch',
#    'immediate'         => 'sofort',
    
    # Severities:
#    'enhancement'       => 'Erweiterung',
#    'text'              => 'Text',
#    'trivial'           => 'trivial',
#    'minor'             => 'unbedeutend',
#    'normal'            => 'normal',
#    'major'             => 'schwerwiegend',
#    'critical'          => 'kritisch',
#    'blocker'           => 'blockierend',
    
    # Stati:
#    'UNCONFIRMED'       => 'UNBESTAETIGT',
#    'NEW'               => 'NEU',
#    'ASSIGNED'          => 'ZUGEWIESEN',
#    'RESOLVED'          => 'ERLEDIGT',
#    'CLOSED'            => 'GESCHLOSSEN',
    
    # Resolutions:
#    'FIXED'             => 'GELOEST',
#    'WORKSFORME'        => 'UNREPRODUZIERBAR',
#    'WONTFIX'           => 'PAL',
#    'DUPLICATE'         => 'DOPPELT',
#    'INVALID'           => 'UNGUELTIG',
#    'LATER'             => 'SPAETER',
    
    # Miscellaneous:
#    'unknown'           => 'unbekannt',
#    'Account disabled'  => 'Konto deaktiviert',
#    'Steps to reproduce'
#			=> 'Schritte zur Reproduzierung',
#    'Additional information'
#			=> 'Zusätzliche Informationen'
};

use constant MAP_BUG_PRIORITY => {
    10 => 'wishlist',		# Mantis: none		Bugzilla: P5
    20 => 'low',		# Mantis: low		Bugzilla: P4
    30 => 'normal',		# Mantis: normal	Bugzilla: P3
    40 => 'high',		# Mantis: high		Bugzilla: P2
    50 => 'immediate',		# Mantis: urgent	Bugzilla: P1
    60 => 'immediate'		# Mantis: immediate	Bugzilla: P1
};

use constant MAP_BUG_SEVERITY => {
    10 => 'enhancement',	# Mantis: feature
    20 => 'trivial',		# Mantis: trivial
    30 => 'normal',		# Mantis: text
    40 => 'minor',		# Mantis: tweak
    50 => 'normal',		# Mantis: minor
    60 => 'major',		# Mantis: major
    70 => 'critical',		# Mantis: crash
    80 => 'blocker'		# Mantis: block
};

use constant MAP_BUG_STATUS => {
    10 => 'UNCONFIRMED',	# Mantis: new
    20 => 'UNCONFIRMED',	# Mantis: feedback
    30 => 'UNCONFIRMED',	# Mantis: acknowledged
    40 => 'NEW',		# Mantis: confirmed
    50 => 'ASSIGNED',		# Mantis: assigned
    60 => undef,		# Mantis: (unused)
    70 => undef,		# Mantis: (unused)
    80 => 'RESOLVED',		# Mantis: resolved
    90 => 'CLOSED'		# Mantis: closed
};

use constant MAP_BUG_RESOLUTION => {
    10 => '',			# Mantis: open
    20 => 'FIXED',		# Mantis: fixed
    30 => '',			# Mantis: reopened
    40 => 'WORKSFORME',		# Mantis: unable to duplicate
    50 => 'WONTFIX',		# Mantis: not fixable
    60 => 'DUPLICATE',		# Mantis: duplicate
    70 => 'INVALID',		# Mantis: not a bug
    80 => 'LATER',		# Mantis: suspended
    90 => 'WONTFIX'		# Mantis: won't fix
};

sub syntax;

our (
    $opt_help,
    $opt_hostname,
    $opt_port,
    $opt_mantis_database,
    $opt_mantis_username,
    $opt_mantis_password,
    $opt_bugzilla_database,
    $opt_bugzilla_username,
    $opt_bugzilla_password
);

my $mantis;
my $bugzilla;

my $html_renderer;

my $users               = {};
my $projects            = {};
my $project_milestones  = {};
my $project_versions    = {};
my $project_components  = {};
my $bugs                = {};
my $bug_duplicates      = {};
my $bug_comments        = {};
my $bug_attachments     = {};

# Main program:
###############################################################################

# Parse command-line options:
############################################################

Getopt::Mixed::getOptions(qw(
    help	h>help
    hostname=s
    port=i
    mantis-database=s
    mantis-username=s
    mantis-password=s
    bugzilla-database=s
    bugzilla-username=s
    bugzilla-password=s
));

if (defined($opt_help)) { syntax(); }

foreach my $opt (keys(%{(DEFAULTS)})) {
    eval("
	\$opt_$opt = DEFAULTS->{$opt} if not defined(\$opt_$opt);
    ");
}

# Create helper objects:
############################################################

$html_renderer = HTML::FormatText->new(leftmargin => 0, rightmargin => 78);

# Connect to databases:
############################################################

$mantis = DBI->connect(
    "DBI:".
	"mysql:" .
	"dbname=" . $opt_mantis_database . ";" .
	"host=" . $opt_hostname . ";" .
	"port=" . $opt_port,
    $opt_mantis_username,
    $opt_mantis_password
) or 
    die("Unable to connect to database:\n$DBI::errstr");

$bugzilla = DBI->connect(
    "DBI:".
	"mysql:" .
	"dbname=" . $opt_bugzilla_database . ";" .
	"host=" . $opt_hostname . ";" .
	"port=" . $opt_port,
    $opt_bugzilla_username,
    $opt_bugzilla_password
) or 
    die("Unable to connect to database:\n$DBI::errstr");

# Read objects:
############################################################

# Read users:
########################################

read_objects(
    $mantis,
    'mantis_user_table',
    {
	id          => 'id+0',
	name        => 'username',
	password    => 'password',
	disabled    => 'not enabled'
    },
    $users
);

# Read projects:
########################################

read_objects(
    $mantis,
    'mantis_project_table',
    {
	id          => 'id+0',
	name        => 'name',
	description => 'description',
	disabled    => 'not enabled'
    },
    $projects
);

# Read project versions:
########################################

read_objects(
    $mantis,
    'mantis_project_version_table',
    {
	id          => 'concat_ws(\'\t\', project_id+0, version)',
	name        => 'version',
	project     => 'project_id+0'
    },
    $project_versions
);

# Read project components:
########################################

read_objects(
    $mantis,
    'mantis_project_category_table',
    {
	id          => 'concat_ws(\'\t\', project_id+0, category)',
	name        => 'category',
	project     => 'project_id+0'
    },
    $project_components
);

# Read bugs:
########################################

read_objects(
    $mantis,
    'mantis_bug_table AS mb INNER JOIN mantis_bug_text_table AS mbt ON (mb.bug_text_id = mbt.id)',
    {
	id          => 'mb.id+0',
	project     => 'project_id+0',
	version     => 'version',
	component   => 'category',
	short_description
		    => 'summary',
	description => 'description',
	description_time
		    => 'date_submitted',
	description_s2r
		    => 'steps_to_reproduce',
	description_s2r_time
		    => 'date_add(date_submitted, interval 1 second)',
	description_addl
		    => 'additional_information',
	description_addl_time
		    => 'date_add(date_submitted, interval 2 second)',
	reporter    => 'reporter_id+0',
	handler     => 'handler_id+0',
	duplicate_of_bug
		    => 'duplicate_id+0',
	priority    => 'priority',
	severity    => 'severity',
	status      => 'status',
	resolution  => 'resolution',
	creation_time
		    => 'date_submitted',
	modification_time
		    => 'last_updated',
	operating_system
		    => 'os',
	platform    => 'platform',
	vote_count  => 'votes'
    },
    $bugs
);

# Read bug comments:
########################################

read_objects(
    $mantis,
    'mantis_bugnote_table AS mb INNER JOIN mantis_bugnote_text_table AS mbt ON (mb.bugnote_text_id = mbt.id)',
    {
	id          => 'concat_ws(\'\t\', bug_id+0, reporter_id+0, date_submitted)',
	bug         => 'bug_id+0',
	creation_time
		    => 'date_submitted',
	reporter    => 'reporter_id+0',
	content     => 'mbt.note'
    },
    $bug_comments
);

# Read bug attachments:
########################################

read_objects(
    $mantis,
    'mantis_bug_file_table',
    {
	id          => 'id+0',
	bug         => 'bug_id+0',
	description => 'description',
	filename    => 'filename',
	mime_type   => 'file_type',
	creation_time
	            => 'date_added',
	content     => 'content'
    },
    $bug_attachments
);

# Transform and write objects:
############################################################

# Transform and write users:
########################################

$users->{unknown} = {
    id          => 'unknown',
    name        => localize('unknown'),
    password    => '',
    disabled    => sql_bool(TRUE)
};

foreach my $user (values(%$users)) {
    $user->{disabled_text} = (
	$user->{disabled} ? localize('Account disabled') : ''
    );
    $user->{permissions} = '96';
    $user->{user_admin_permissions} = '0';
}

write_objects(
    $bugzilla,
    'profiles',
    {
	name        => 'login_name',
	password    => 'cryptpassword',
	disabled_text
		    => 'disabledtext',
	permissions => 'groupset',
	user_admin_permissions
		    => 'blessgroupset'
    },
    $users,
    TRUE
);

# Transform and write projects:
########################################

foreach my $project (values(%$projects)) {
    # Unescape project name:
    $project->{name} = unescape($project->{name});
    # Unescape project description (but don't deHTMLize it):
    $project->{description} = unescape($project->{description});
    
    $project->{milestone_uri} = '';
    $project->{vote_count_to_confirm} = 1;
    $project->{default_milestone} = localize('unknown');
    
    # Build project default milestone:
    $project_milestones->{"$project->{id}\t" . localize('unknown')} = {
	id          => "$project->{id}\t" . localize('unknown'),
	name        => localize('unknown'),
	project     => $project->{id}
    };
    
    # Build project default version if none exists:
    $project_versions->{"$project->{id}\t" . localize('unknown')} = {
	id          => "$project->{id}\t" . localize('unknown'),
	name        => localize('unknown'),
	project     => $project->{id}
    };
}

write_objects(
    $bugzilla,
    'products',
    {
	name        => 'product',
	description => 'description',
	disabled    => 'disallownew',
	milestone_uri
		    => 'milestoneurl',
	vote_count_to_confirm
		    => 'votestoconfirm',
	default_milestone
		    => 'defaultmilestone'
    },
    $projects,
    TRUE
);

# Transform and write project
# milestones:
########################################

foreach my $project_milestone (values(%$project_milestones)) {
    # Don't unescape project milestone name!
    #$project_milestone->{name} = unescape($project_milestone->{name});
    
    # De-reference project name:
    $project_milestone->{project} = $projects->{$project_milestone->{project}}{name};
}

write_objects(
    $bugzilla,
    'milestones',
    {
	name        => 'value',
	project     => 'product'
    },
    $project_milestones
);

# Transform and write project versions:
########################################

foreach my $project_version (values(%$project_versions)) {
    # Don't unescape project version name!
    #$project_version->{name} = unescape($project_version->{name});
    
    # De-reference project name:
    $project_version->{project} = $projects->{$project_version->{project}}{name};
}

write_objects(
    $bugzilla,
    'versions',
    {
	name        => 'value',
	project     => 'program',
    },
    $project_versions
);

# Transform and write project
# components:
########################################

foreach my $project_component (values(%$project_components)) {
    # Don't unescape project component name!
    #$project_component->{name} = unescape($project_component->{name});
    
    $project_component->{maintainer} = $users->{unknown}{new_id};
    $project_component->{description} = '';
    
    # De-reference project name:
    $project_component->{project} = $projects->{$project_component->{project}}{name};
}

write_objects(
    $bugzilla,
    'components',
    {
	name        => 'value',
	project     => 'program',
	maintainer  => 'initialowner'
    },
    $project_components
);

# Transform and write bugs:
########################################

foreach my $bug (values(%$bugs)) {
    # Set 'unknown' version if none specified:
    $bug->{version} = localize('unknown')
	if not $bug->{version};
    
    # Set bug default target milestone:
    $bug->{target_milestone} = localize('unknown');
    
    # Unescape short description:
    $bug->{short_description} = unescape($bug->{short_description});
    
    # Translate priority:
    $bug->{priority} = localize(MAP_BUG_PRIORITY->{$bug->{priority}});
    # Translate severity:
    $bug->{severity} = localize(MAP_BUG_SEVERITY->{$bug->{severity}});
    # Translate status:
    $bug->{status} = localize(MAP_BUG_STATUS->{$bug->{status}});
    # Translate resolution:
    $bug->{resolution} = localize(MAP_BUG_RESOLUTION->{$bug->{resolution}});
    
    $bug->{operating_system} = 'All';
    $bug->{platform} = 'All';
    $bug->{status_whiteboard} = '';
    $bug->{keywords} = '';
    $bug->{diff_time} = $bug->{modification_time};
    
    # Bug should not be in the 'unconfirmed' state if it has enough votes:
    $bug->{status} = localize('NEW')
	if  $bug->{status} eq localize('UNCONFIRMED') and
	    $bug->{vote_count} >= $projects->{$bug->{project}}{vote_count_to_confirm};
    
    # If bug is in the 'new', 'assigned', or 'reopened' states, it must have
    # been confirmed:
    $bug->{was_ever_confirmed} = sql_bool(
	grep($bug->{status} eq localize($_), qw(NEW ASSIGNED REOPENED))
    );

    # Build bug comment from Mantis bug "description":
    $bug_comments->{"$bug->{id}\t$bug->{reporter}\t$bug->{description_time}"} = {
	id          => "$bug->{id}\t$bug->{reporter}\t$bug->{description_time}",
	bug         => $bug->{id},
	creation_time
		    => $bug->{description_time},
	reporter    => $bug->{reporter},
	content     => $bug->{description}
    }
	if $bug->{description};
    
    # Build bug comment from Mantis bug "steps to reproduce":
    $bug_comments->{"$bug->{id}\t$bug->{reporter}\t$bug->{description_s2r_time}"} = {
	id          => "$bug->{id}\t$bug->{reporter}\t$bug->{description_s2r_time}",
	bug         => $bug->{id},
	creation_time
		    => $bug->{description_s2r_time},
	reporter    => $bug->{reporter},
	content     => localize('Steps to reproduce') . ":\n\n$bug->{description_s2r}"
    }
	if $bug->{description_s2r};
    
    # Build bug comment from Mantis bug "additional information":
    $bug_comments->{"$bug->{id}\t$bug->{reporter}\t$bug->{description_addl_time}"} = {
	id          => "$bug->{id}\t$bug->{reporter}\t$bug->{description_addl_time}",
	bug         => $bug->{id},
	creation_time
		    => $bug->{description_addl_time},
	reporter    => $bug->{reporter},
	content     => localize('Additional information') . ":\n\n$bug->{description_addl}"
    }
	if $bug->{description_addl};
    
    # Build duplicate entries:
    $bug_duplicates->{"$bug->{id}\t$bug->{duplicate_of_bug}"} = {
	id          => "$bug->{id}\t$bug->{duplicate_of_bug}",
	bug         => $bug->{id},
	duplicate_of_bug
		    => $bug->{duplicate_of_bug}
    }
	if $bug->{duplicate_of_bug};
    
    # De-reference project name:
    $bug->{project} = $projects->{$bug->{project}}{name};
    
    # Translate reporter id:
    $bug->{reporter} = $users->{$bug->{reporter}}{new_id} if $bug->{reporter};
    $bug->{reporter} = $users->{unknown}{new_id}
	if not defined($bug->{reporter}) or not $bug->{reporter};
    
    # Translate handler id:
    $bug->{handler} = $users->{$bug->{handler}}{new_id} if $bug->{handler};
    $bug->{handler} = $users->{unknown}{new_id}
	if not defined($bug->{handler}) or not $bug->{handler};
}

write_objects(
    $bugzilla,
    'bugs',
    {
	project     => 'product',
	version     => 'version',
	component   => 'component',
	target_milestone
		    => 'target_milestone',
	short_description
		    => 'short_desc',
	reporter    => 'reporter',
	handler     => 'assigned_to',
	priority    => 'priority',
	severity    => 'bug_severity',
	status      => 'bug_status',
	resolution  => 'resolution',
	creation_time
		    => 'creation_ts',
	modification_time
		    => 'delta_ts',
	operating_system
		    => 'op_sys',
	platform    => 'rep_platform',
	vote_count  => 'votes',
	status_whiteboard
		    => 'status_whiteboard',
	keywords    => 'keywords',
	diff_time   => 'lastdiffed',
	was_ever_confirmed
		    => 'everconfirmed'
    },
    $bugs,
    TRUE,
    sub { $a <=> $b }
);

# Transform and write bug duplicates:
########################################

foreach my $bug_duplicate (values(%$bug_duplicates)) {
    # Translate bug duplicate bug id:
    $bug_duplicate->{bug} = $bugs->{$bug_duplicate->{bug}}{new_id};
    # Translate bug duplicate duplicate-of-bug id:
    $bug_duplicate->{duplicate_of_bug} = $bugs->{$bug_duplicate->{duplicate_of_bug}}{new_id};
}

write_objects(
    $bugzilla,
    'duplicates',
    {
	bug         => 'dupe',
	duplicate_of_bug
		    => 'dupe_of'
    },
    $bug_duplicates
);

# Transform and write bug comments:
########################################

foreach my $bug_comment (values(%$bug_comments)) {
    # Translate bug id:
    $bug_comment->{bug} = $bugs->{$bug_comment->{bug}}{new_id}
	if $bug_comment->{bug};
    if (not defined($bug_comment->{bug}) or not $bug_comment->{bug}) {
	# Drop bug comment if associated bug does not exist:
	delete($bug_comments->{$bug_comment->{id}});
	next;
    }
    
    # Translate reporter id:
    $bug_comment->{reporter} = $users->{$bug_comment->{reporter}}{new_id}
	if $bug_comment->{reporter};
    $bug_comment->{reporter} = $users->{unknown}{new_id}
	if not defined($bug_comment->{reporter}) or not $bug_comment->{reporter};
    
    # Render 'Mantis-HTML':
    $bug_comment->{content} = unescape(
	render_mantis_html($html_renderer, $bug_comment->{content})
    );
}

write_objects(
    $bugzilla,
    'longdescs',
    {
	bug         => 'bug_id',
	creation_time
		    => 'bug_when',
	reporter    => 'who',
	content     => 'thetext'
    },
    $bug_comments
);

# Transform and write bug attachments:
########################################

foreach my $bug_attachment (values(%$bug_attachments)) {
    # Set filename as description if no description exists:
    $bug_attachment->{description} = $bug_attachment->{filename}
	if not defined($bug_attachment->{description}) or not $bug_attachment->{description};
    
    # Instate attachment submitter id:
    $bug_attachment->{reporter} = $bugs->{$bug_attachment->{bug}}{reporter};
    
    # Translate bug id:
    $bug_attachment->{bug} = $bugs->{$bug_attachment->{bug}}{new_id};
}

write_objects(
    $bugzilla,
    'attachments',
    {
	bug         => 'bug_id',
	reporter    => 'submitter_id',
	description => 'description',
	filename    => 'filename',
	mime_type   => 'mimetype',
	creation_time
	            => 'creation_ts',
	content     => 'thedata'
    },
    $bug_attachments,
    TRUE
);

# Clean up:
############################################################

$bugzilla->disconnect();

$mantis->disconnect();

exit(0);

# Helper functions:
###############################################################################

sub syntax {
    print(<<EOT);
Syntax: m2bz.pl [OPTION]...

Options:
  -h, --help            Display this help and exit.

  --hostname            Host running the MySQL databases (default: localhost)
  --port                Port on which MySQL is listening (default: 3306)

  --mantis-database     Name of the Mantis database (default: mantis),
  --mantis-username     Username and password used to connect to the Mantis
  --mantis-password     database (default: root, <empty>)
	
  --bugzilla-database   Name of the Bugzilla database (default: bugzilla),
  --bugzilla-username   Username and password used to connect to the Bugzilla
  --bugzilla-password   database (default: root, <empty>)

EOT
    exit(1);
}

sub sql_bool {
    return shift() ? '1' : '0';
}

sub prettify_sql {
    my ($sql) = @_;
    $sql =~ s/^\s*(.*?)\s*$/$1/s;
    $sql =~ s/\s+/ /g;
    return $sql;
}

sub read_objects {
    my ($dbh, $tables, $members, $objects) = @_;
    
    my $query = prettify_sql(
	'SELECT ' .
	    join(', ', map("$members->{$_} AS $_", keys(%$members))) . ' ' .
	'FROM ' .
	    $tables
    );
    
    my $sth = $dbh->prepare($query)
	or die("Query (\"$query\") preparation failed:\n$DBI::errstr");
    $sth->execute()
	or die("Query (\"$query\") execution failed:\n$DBI::errstr");
    
    my $object;
    while (defined($object = $sth->fetchrow_hashref())) {
	$objects->{$object->{id}} = {
	    map { $_ => $object->{$_} } keys(%$members)
	};
    }
    
    $sth->finish();
}

sub write_objects {
    my ($dbh, $tables, $members, $objects, $get_new_id, $sort) = @_;
    
    $get_new_id = defined($get_new_id) && $get_new_id;
    
    my $query = prettify_sql(
	'INSERT INTO ' .
	    $tables . ' ' .
	'(' .
	    join(', ', values(%$members)) .
	') ' .
	'VALUES (' .
	    join(', ', map('?', values(%$members))) .
	')'
    );

    my $sth = $dbh->prepare($query)
	or die("Query (\"$query\") preparation failed:\n$DBI::errstr");
    
    foreach my $object (
	defined($sort) ?
	    @$objects{ sort($sort keys(%$objects)) }
	:   values(%$objects)
    ) {
	$sth->execute(
	    map($object->{$_}, keys(%$members))
	)
	    or print(STDERR "Insertion of object '$object->{id}' into table(s) '$tables' failed.\n");
	
	$object->{new_id} = $dbh->selectrow_array('SELECT last_insert_id()')
	    if $get_new_id;
    }
    
    $sth->finish();
}

sub localize {
    my ($text) = @_;
    
    return $text
	if not defined($text) or not exists(TEXT_L10N->{$text});
    
    return TEXT_L10N->{$text};
}

sub unescape {
    my ($text) = @_;

    # Unescape "\." sequences:
    $text =~ s/\\([\'\"\\])/$1/g;

    return $text;
}

sub render_mantis_html {
    my ($html_renderer, $mantis_html) = @_;
    
    # HTML-ize linefeeds:
    $mantis_html =~ s/\x0a/<br>/g;
    
    my $html_tree = HTML::TreeBuilder->new_from_content($mantis_html);
    
    return $html_renderer->format($html_tree);
}
