feat: complete field parity, harden export/import, add tests

Export/Import improvements:
- Replace pagination with direct SQL query (db_sqlite_get_all_chat)
- Wrap import in SQL transaction with rollback on error
- Add fsync before fclose in export for data safety
- Sort merged output by timestamp with secondary key (stanza_id, from_jid)
- Export archive_id and marked_read from SQLite (lossless migration)
- Add progress indication every 500 messages during write/import
- Expand dedup key body prefix from 64 to 256 chars
- Fix g_slist_append O(n²) → g_slist_prepend + g_slist_reverse O(n)

Field parity (to_jid, to_resource, marked_read):
- Add fields to ff_parsed_line_t struct
- Write/parse to:|to_res:|read: metadata tags in flatfile format
- Pass to_resource through _ff_add_message and all callers
- Add marked_read to ProfMessage struct with -1 default (unset)
- Preserve fields across export/import round-trips

Tests (19 new: 11 unit + 8 functional):
- Unit: to_jid_and_marked_read, bracket_in_stanza_id, backslash_in_resource,
  mucpm_type, all_enc_types, crlf_handling, to_jid_special_chars,
  multiple_lines, parsed_line_free_null_safe, no_space_rejected,
  unclosed_bracket
- Functional: export_idempotent_no_duplicates, export_lmc_correction_survives,
  switch_preserves_old_backend_data, export_all_contacts,
  import_double_dedup, verify_after_export,
  switch_backends_independent_messages, export_empty_contact
- Rebalance test groups: move Chat Session from Group 3 to Group 4
  (25/33/30/27 instead of 25/33/36/21)
- Remove hardcoded test counts from group comments

Man page:
- Document /history switch sqlite|flatfile
This commit is contained in:
2026-03-09 12:14:41 +03:00
parent 9f3020e40a
commit 507ef91b5f
16 changed files with 1037 additions and 146 deletions

View File

@@ -818,6 +818,111 @@ db_backend_sqlite(void)
return &sqlite_backend;
}
void
db_sqlite_begin_transaction(void)
{
if (!g_chatlog_database)
return;
char* err_msg = NULL;
if (SQLITE_OK != sqlite3_exec(g_chatlog_database, "BEGIN TRANSACTION", NULL, 0, &err_msg)) {
if (err_msg) {
log_error("SQLite BEGIN TRANSACTION failed: %s", err_msg);
sqlite3_free(err_msg);
}
}
}
void
db_sqlite_end_transaction(void)
{
if (!g_chatlog_database)
return;
char* err_msg = NULL;
if (SQLITE_OK != sqlite3_exec(g_chatlog_database, "END TRANSACTION", NULL, 0, &err_msg)) {
if (err_msg) {
log_error("SQLite END TRANSACTION failed: %s", err_msg);
sqlite3_free(err_msg);
}
}
}
void
db_sqlite_rollback_transaction(void)
{
if (!g_chatlog_database)
return;
char* err_msg = NULL;
if (SQLITE_OK != sqlite3_exec(g_chatlog_database, "ROLLBACK", NULL, 0, &err_msg)) {
if (err_msg) {
log_error("SQLite ROLLBACK failed: %s", err_msg);
sqlite3_free(err_msg);
}
}
}
GSList*
db_sqlite_get_all_chat(const gchar* const contact_barejid)
{
GSList* result = NULL;
if (!g_chatlog_database)
return NULL;
const Jid* myjid = connection_get_jid();
if (!myjid || !myjid->barejid)
return NULL;
sqlite3_stmt* stmt = NULL;
auto_sqlite char* query = sqlite3_mprintf(
"SELECT COALESCE(B.`message`, A.`message`) AS message, "
"A.`timestamp`, A.`from_jid`, A.`from_resource`, A.`to_jid`, A.`to_resource`, "
"A.`type`, A.`encryption`, A.`stanza_id`, A.`archive_id`, A.`marked_read` "
"FROM `ChatLogs` AS A "
"LEFT JOIN `ChatLogs` AS B ON (A.`replaced_by_db_id` = B.`id` AND A.`from_jid` = B.`from_jid`) "
"WHERE (A.`replaces_db_id` IS NULL) "
"AND ((A.`from_jid` = %Q AND A.`to_jid` = %Q) OR (A.`from_jid` = %Q AND A.`to_jid` = %Q)) "
"ORDER BY A.`timestamp` ASC",
contact_barejid, myjid->barejid, myjid->barejid, contact_barejid);
if (!query)
return NULL;
if (!_db_prepare_ctx(query, &stmt, "db_sqlite_get_all_chat()"))
return NULL;
while (sqlite3_step(stmt) == SQLITE_ROW) {
char* message = (char*)sqlite3_column_text(stmt, 0);
char* date = (char*)sqlite3_column_text(stmt, 1);
char* from_jid = (char*)sqlite3_column_text(stmt, 2);
char* from_resource = (char*)sqlite3_column_text(stmt, 3);
char* to_jid = (char*)sqlite3_column_text(stmt, 4);
char* to_resource = (char*)sqlite3_column_text(stmt, 5);
char* type = (char*)sqlite3_column_text(stmt, 6);
char* encryption = (char*)sqlite3_column_text(stmt, 7);
char* id = (char*)sqlite3_column_text(stmt, 8);
char* archive_id = (char*)sqlite3_column_text(stmt, 9);
int marked_read_raw = sqlite3_column_type(stmt, 10) == SQLITE_NULL ? -1 : sqlite3_column_int(stmt, 10);
ProfMessage* msg = message_init();
msg->id = _db_strdup(id);
msg->stanzaid = _db_strdup(archive_id);
msg->from_jid = jid_create_from_bare_and_resource(from_jid, from_resource);
msg->to_jid = jid_create_from_bare_and_resource(to_jid, to_resource);
msg->plain = _db_strdup(message);
if (!msg->plain)
msg->plain = strdup("");
msg->timestamp = g_date_time_new_from_iso8601(date, NULL);
msg->type = _get_message_type_type(type);
msg->enc = _get_message_enc_type(encryption);
msg->marked_read = marked_read_raw;
result = g_slist_prepend(result, msg);
}
sqlite3_finalize(stmt);
return g_slist_reverse(result);
}
GSList*
db_sqlite_list_contacts(void)
{