Compare commits
13 Commits
feat/ai-js
...
0d38a0a2cb
| Author | SHA1 | Date | |
|---|---|---|---|
|
0d38a0a2cb
|
|||
|
71f9ab5007
|
|||
|
06463b75b4
|
|||
|
507ef91b5f
|
|||
|
9f3020e40a
|
|||
|
7e320ccdb0
|
|||
|
b5fe714a89
|
|||
|
1f3d3117bf
|
|||
|
23723376c6
|
|||
|
16ae7fb85c
|
|||
|
d25f90899c
|
|||
|
89b90e5cf2
|
|||
|
31e8c30c34
|
13
CHANGELOG
13
CHANGELOG
@@ -1,3 +1,16 @@
|
||||
0.16.0 (unreleased)
|
||||
===================
|
||||
|
||||
Changes:
|
||||
- Add flat-file database backend as alternative to SQLite for message history.
|
||||
Stores messages as human-readable plain text files. Configure with `/privacy logging flatfile`.
|
||||
Files are stored in ~/.local/share/profanity/flatlog/.
|
||||
- Add `/history verify [<jid>]` command to check integrity of stored message
|
||||
history (works with both SQLite and flat-file backends).
|
||||
- Add vtable-based database backend abstraction allowing pluggable storage.
|
||||
- Add `make check-functional-flatfile` target to run functional tests with
|
||||
flat-file backend.
|
||||
|
||||
0.15.0 (2025-03-27)
|
||||
===================
|
||||
|
||||
|
||||
@@ -123,6 +123,20 @@ Test your changes with the following tools to find mistakes.
|
||||
|
||||
Run `make check` to run the unit tests with your current configuration or `./ci-build.sh` to check with different switches passed to configure.
|
||||
|
||||
### flat-file backend tests
|
||||
|
||||
To run functional tests with the flat-file database backend (instead of SQLite):
|
||||
|
||||
```bash
|
||||
make check-functional-flatfile
|
||||
```
|
||||
|
||||
Or manually for a single group:
|
||||
|
||||
```bash
|
||||
PROF_FLATFILE=1 PROF_TEST_GROUP=1 ./tests/functionaltests/functionaltests 1
|
||||
```
|
||||
|
||||
### valgrind
|
||||
We provide a suppressions file `prof.supp`. It is a combination of the suppressions for shipped with glib2, python and custom rules.
|
||||
|
||||
|
||||
@@ -41,7 +41,6 @@ RUN pacman -S --needed --noconfirm \
|
||||
python \
|
||||
wget \
|
||||
sqlite \
|
||||
json-glib \
|
||||
valgrind \
|
||||
gdk-pixbuf2 \
|
||||
qrencode
|
||||
|
||||
@@ -35,7 +35,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python-dev-is-python3 \
|
||||
valgrind \
|
||||
libsqlite3-dev \
|
||||
libjson-glib-dev \
|
||||
libgdk-pixbuf-2.0-dev \
|
||||
libqrencode-dev
|
||||
|
||||
|
||||
@@ -38,7 +38,6 @@ RUN dnf install -y \
|
||||
readline-devel \
|
||||
openssl-devel \
|
||||
sqlite-devel \
|
||||
json-glib-devel \
|
||||
valgrind \
|
||||
gdk-pixbuf2-devel \
|
||||
qrencode-devel
|
||||
|
||||
@@ -37,7 +37,6 @@ RUN zypper --non-interactive in --no-recommends \
|
||||
python310-devel \
|
||||
readline-devel \
|
||||
sqlite3-devel \
|
||||
json-glib-devel \
|
||||
valgrind \
|
||||
gdk-pixbuf-devel \
|
||||
qrencode-devel
|
||||
|
||||
@@ -34,7 +34,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python-dev-is-python3 \
|
||||
valgrind \
|
||||
libsqlite3-dev \
|
||||
libjson-glib-dev \
|
||||
libgdk-pixbuf-2.0-dev \
|
||||
libqrencode-dev
|
||||
|
||||
|
||||
49
Makefile.am
49
Makefile.am
@@ -3,6 +3,10 @@ core_sources = \
|
||||
src/log.c src/common.c \
|
||||
src/chatlog.c src/chatlog.h \
|
||||
src/database.h src/database.c \
|
||||
src/database_flatfile.c src/database_flatfile.h \
|
||||
src/database_flatfile_parser.c \
|
||||
src/database_flatfile_verify.c \
|
||||
src/database_export.c \
|
||||
src/log.h src/profanity.c src/common.h \
|
||||
src/profanity.h src/xmpp/chat_session.c \
|
||||
src/xmpp/chat_session.h src/xmpp/muc.c src/xmpp/muc.h src/xmpp/jid.h src/xmpp/jid.c \
|
||||
@@ -33,7 +37,6 @@ core_sources = \
|
||||
src/ui/window_list.c src/ui/window_list.h \
|
||||
src/ui/rosterwin.c src/ui/occupantswin.c \
|
||||
src/ui/buffer.c src/ui/buffer.h \
|
||||
src/ui/aiwin.c \
|
||||
src/ui/chatwin.c \
|
||||
src/ui/mucwin.c \
|
||||
src/ui/privwin.c \
|
||||
@@ -75,8 +78,7 @@ core_sources = \
|
||||
src/plugins/themes.c src/plugins/themes.h \
|
||||
src/plugins/settings.c src/plugins/settings.h \
|
||||
src/plugins/disco.c src/plugins/disco.h \
|
||||
src/ui/tray.h src/ui/tray.c \
|
||||
src/ai/ai_client.h src/ai/ai_client.c
|
||||
src/ui/tray.h src/ui/tray.c
|
||||
|
||||
unittest_sources = \
|
||||
src/xmpp/contact.c src/xmpp/contact.h src/common.c \
|
||||
@@ -87,7 +89,6 @@ unittest_sources = \
|
||||
src/xmpp/chat_state.h src/xmpp/chat_state.c \
|
||||
src/xmpp/roster_list.c src/xmpp/roster_list.h \
|
||||
src/xmpp/xmpp.h src/xmpp/form.c \
|
||||
src/ai/ai_client.h src/ai/ai_client.c \
|
||||
src/ui/ui.h \
|
||||
src/otr/otr.h \
|
||||
src/pgp/gpg.h \
|
||||
@@ -96,6 +97,8 @@ unittest_sources = \
|
||||
src/omemo/crypto.h \
|
||||
src/omemo/store.h \
|
||||
src/xmpp/vcard.h src/xmpp/vcard_funcs.h \
|
||||
src/database_flatfile.h \
|
||||
src/database_flatfile_parser.c \
|
||||
src/command/cmd_defs.h src/command/cmd_defs.c \
|
||||
src/command/cmd_funcs.h src/command/cmd_funcs.c \
|
||||
src/command/cmd_ac.h src/command/cmd_ac.c \
|
||||
@@ -135,7 +138,6 @@ unittest_sources = \
|
||||
tests/unittests/xmpp/stub_message.c \
|
||||
tests/unittests/ui/stub_ui.c tests/unittests/ui/stub_ui.h \
|
||||
tests/unittests/ui/stub_vcardwin.c \
|
||||
tests/unittests/ui/stub_ai.c \
|
||||
tests/unittests/log/stub_log.c \
|
||||
tests/unittests/chatlog/stub_chatlog.c \
|
||||
tests/unittests/database/stub_database.c \
|
||||
@@ -172,7 +174,8 @@ unittest_sources = \
|
||||
tests/unittests/test_callbacks.c tests/unittests/test_callbacks.h \
|
||||
tests/unittests/test_plugins_disco.c tests/unittests/test_plugins_disco.h \
|
||||
tests/unittests/test_forced_encryption.c tests/unittests/test_forced_encryption.h \
|
||||
tests/unittests/test_ai_client.c tests/unittests/test_ai_client.h \
|
||||
tests/unittests/test_database_export.c tests/unittests/test_database_export.h \
|
||||
tests/unittests/test_database_stress.c tests/unittests/test_database_stress.h \
|
||||
tests/unittests/unittests.c
|
||||
|
||||
functionaltest_sources = \
|
||||
@@ -190,9 +193,11 @@ functionaltest_sources = \
|
||||
tests/functionaltests/test_software.c tests/functionaltests/test_software.h \
|
||||
tests/functionaltests/test_muc.c tests/functionaltests/test_muc.h \
|
||||
tests/functionaltests/test_disconnect.c tests/functionaltests/test_disconnect.h \
|
||||
tests/functionaltests/test_history.c tests/functionaltests/test_history.h \
|
||||
tests/functionaltests/test_lastactivity.c tests/functionaltests/test_lastactivity.h \
|
||||
tests/functionaltests/test_autoping.c tests/functionaltests/test_autoping.h \
|
||||
tests/functionaltests/test_disco.c tests/functionaltests/test_disco.h \
|
||||
tests/functionaltests/test_autoping.c tests/functionaltests/test_autoping.h \
|
||||
tests/functionaltests/test_disco.c tests/functionaltests/test_disco.h \
|
||||
tests/functionaltests/test_export_import.c tests/functionaltests/test_export_import.h \
|
||||
tests/functionaltests/functionaltests.c
|
||||
|
||||
main_source = src/main.c
|
||||
@@ -262,6 +267,12 @@ core_sources += $(omemo_sources)
|
||||
unittest_sources += $(omemo_unittest_sources)
|
||||
endif
|
||||
|
||||
sqlite_sources = src/database_sqlite.c
|
||||
|
||||
if BUILD_SQLITE
|
||||
core_sources += $(sqlite_sources)
|
||||
endif
|
||||
|
||||
all_c_sources = $(core_sources) $(unittest_sources) \
|
||||
$(pgp_sources) $(pgp_unittest_sources) \
|
||||
$(otr4_sources) $(otr_unittest_sources) \
|
||||
@@ -296,7 +307,7 @@ TESTS = tests/unittests/unittests
|
||||
check_PROGRAMS = tests/unittests/unittests
|
||||
tests_unittests_unittests_CPPFLAGS = -I$(srcdir)/tests
|
||||
tests_unittests_unittests_SOURCES = $(unittest_sources)
|
||||
tests_unittests_unittests_LDADD = -lcmocka $(json-glib_LIBS)
|
||||
tests_unittests_unittests_LDADD = -lcmocka
|
||||
|
||||
# Functional tests require libstabber.
|
||||
# They are only built when libstabber is available.
|
||||
@@ -336,6 +347,26 @@ check-functional-parallel: tests/functionaltests/functionaltests
|
||||
grep -E 'PASSED|FAILED|Running' $(builddir)/test-logs/group*.log || true; \
|
||||
if [ $$failed -ne 0 ]; then echo "FUNCTIONAL TESTS FAILED"; exit 1; fi; \
|
||||
echo "All functional test groups passed!"
|
||||
|
||||
# Run functional tests with the flat-file database backend
|
||||
# Usage: make check-functional-flatfile
|
||||
check-functional-flatfile: tests/functionaltests/functionaltests
|
||||
@echo "Running functional tests with flat-file backend ($(words $(FUNC_TEST_GROUPS)) groups)..."
|
||||
@mkdir -p $(builddir)/test-logs $(builddir)/test-files
|
||||
@pids=""; \
|
||||
for g in $(FUNC_TEST_GROUPS); do \
|
||||
PROF_FLATFILE=1 ./tests/functionaltests/functionaltests $$g > $(builddir)/test-logs/group$$g-flatfile.log 2>&1 & \
|
||||
pids="$$pids $$!"; \
|
||||
done; \
|
||||
failed=0; i=1; \
|
||||
for pid in $$pids; do \
|
||||
wait $$pid || { echo "Group $$i FAILED (flatfile)"; cat $(builddir)/test-logs/group$$i-flatfile.log; failed=1; }; \
|
||||
i=$$((i + 1)); \
|
||||
done; \
|
||||
echo "=== Flat-file Test Results Summary ==="; \
|
||||
grep -E 'PASSED|FAILED|Running' $(builddir)/test-logs/group*-flatfile.log || true; \
|
||||
if [ $$failed -ne 0 ]; then echo "FLAT-FILE FUNCTIONAL TESTS FAILED"; exit 1; fi; \
|
||||
echo "All flat-file functional test groups passed!"
|
||||
endif
|
||||
endif
|
||||
|
||||
|
||||
@@ -204,13 +204,6 @@ case "$ARCH" in
|
||||
""
|
||||
)
|
||||
source /etc/profile.d/debuginfod.sh 2>/dev/null || true
|
||||
|
||||
if grep -q 'ID=arch' /etc/os-release 2>/dev/null && [ -f /etc/makepkg.conf ]; then
|
||||
echo "--> [Parity Mode] Simulating Pikaur collision..."
|
||||
set -a
|
||||
source /etc/makepkg.conf
|
||||
set +a
|
||||
fi
|
||||
;;
|
||||
darwin*)
|
||||
# 4 configurations for parallel CI
|
||||
|
||||
23
configure.ac
23
configure.ac
@@ -94,12 +94,17 @@ PKG_CHECK_MODULES([curl], [libcurl >= 7.62.0], [],
|
||||
[AC_CHECK_LIB([curl], [main], [],
|
||||
[AC_MSG_ERROR([libcurl 7.62.0 or higher is required])])])
|
||||
|
||||
PKG_CHECK_MODULES([SQLITE], [sqlite3 >= 3.22.0], [],
|
||||
[AC_MSG_ERROR([sqlite3 3.22.0 or higher is required])])
|
||||
### sqlite (optional — can be disabled with --without-sqlite)
|
||||
AC_ARG_WITH([sqlite],
|
||||
[AS_HELP_STRING([--without-sqlite], [build without SQLite support (flat-file backend only)])],
|
||||
[], [with_sqlite=yes])
|
||||
|
||||
## Check for json-glib (JSON parsing for AI responses)
|
||||
PKG_CHECK_MODULES([json-glib], [json-glib-1.0 >= 1.6.0], [],
|
||||
[AC_MSG_ERROR([json-glib 1.6.0 or higher is required])])
|
||||
AS_IF([test "x$with_sqlite" != "xno"],
|
||||
[PKG_CHECK_MODULES([SQLITE], [sqlite3 >= 3.22.0],
|
||||
[AC_DEFINE([HAVE_SQLITE], [1], [SQLite support])],
|
||||
[AC_MSG_ERROR([sqlite3 3.22.0 or higher is required (use --without-sqlite to disable)])])],
|
||||
[AC_MSG_NOTICE([Building without SQLite — flat-file backend only])])
|
||||
AM_CONDITIONAL([BUILD_SQLITE], [test "x$with_sqlite" != "xno"])
|
||||
|
||||
ACX_PTHREAD([], [AC_MSG_ERROR([pthread is required])])
|
||||
AS_IF([test "x$PTHREAD_CC" != x], [ CC="$PTHREAD_CC" ])
|
||||
@@ -391,11 +396,12 @@ AC_SUBST([FORKPTY_LIB])
|
||||
## Default parameters
|
||||
AM_CFLAGS="$AM_CFLAGS -Wall -Wextra -Wformat=2 -Wno-format-zero-length"
|
||||
AM_CFLAGS="$AM_CFLAGS -Wno-deprecated-declarations -Wno-unused-parameter -Wno-missing-field-initializers -Wno-sign-compare -Wno-cast-function-type"
|
||||
AM_CFLAGS="$AM_CFLAGS -Wpointer-arith"
|
||||
AM_CFLAGS="$AM_CFLAGS -Wnull-dereference -Wpointer-arith"
|
||||
AM_CFLAGS="$AM_CFLAGS -Wimplicit-function-declaration"
|
||||
AM_CFLAGS="$AM_CFLAGS -Wundef"
|
||||
AM_CFLAGS="$AM_CFLAGS -Wfloat-equal -Wredundant-decls"
|
||||
AM_CFLAGS="$AM_CFLAGS -fstack-protector-strong -fno-common"
|
||||
AM_CFLAGS="$AM_CFLAGS -D_FORTIFY_SOURCE=2"
|
||||
AM_CFLAGS="$AM_CFLAGS -std=gnu99 -ggdb3"
|
||||
|
||||
# GCC-specific warnings (not supported by clang) — test each one
|
||||
@@ -433,11 +439,11 @@ AS_IF([test "x$PACKAGE_STATUS" = xdevelopment],
|
||||
AS_IF([test "x$PLATFORM" = xosx],
|
||||
[AM_CFLAGS="$AM_CFLAGS -Qunused-arguments"])
|
||||
|
||||
AM_CFLAGS="$AM_CFLAGS $PTHREAD_CFLAGS $glib_CFLAGS $gio_CFLAGS $curl_CFLAGS ${SQLITE_CFLAGS} $json_glib_CFLAGS"
|
||||
AM_CFLAGS="$AM_CFLAGS $PTHREAD_CFLAGS $glib_CFLAGS $gio_CFLAGS $curl_CFLAGS ${SQLITE_CFLAGS}"
|
||||
AM_CFLAGS="$AM_CFLAGS $libnotify_CFLAGS ${GTK_CFLAGS} $python_CFLAGS"
|
||||
AM_CFLAGS="$AM_CFLAGS -DTHEMES_PATH=\"\\\"$THEMES_PATH\\\"\" -DICONS_PATH=\"\\\"$ICONS_PATH\\\"\" -DGLOBAL_PYTHON_PLUGINS_PATH=\"\\\"$GLOBAL_PYTHON_PLUGINS_PATH\\\"\" -DGLOBAL_C_PLUGINS_PATH=\"\\\"$GLOBAL_C_PLUGINS_PATH\\\"\""
|
||||
|
||||
LIBS="$glib_LIBS $gio_LIBS $PTHREAD_LIBS $curl_LIBS $libnotify_LIBS $python_LIBS ${GTK_LIBS} ${SQLITE_LIBS} $json_glib_LIBS $LIBS"
|
||||
LIBS="$glib_LIBS $gio_LIBS $PTHREAD_LIBS $curl_LIBS $libnotify_LIBS $python_LIBS ${GTK_LIBS} ${SQLITE_LIBS} $LIBS"
|
||||
|
||||
AC_SUBST(AM_LDFLAGS)
|
||||
AC_SUBST(AM_CFLAGS)
|
||||
@@ -451,6 +457,7 @@ AC_OUTPUT
|
||||
AC_MSG_NOTICE([Summary of build options:
|
||||
PLATFORM : $target_os
|
||||
PACKAGE_STATUS : $PACKAGE_STATUS
|
||||
SQLite support : $with_sqlite
|
||||
GTK_VERSION : $GTK_VERSION
|
||||
LIBS : $LIBS
|
||||
Install themes : $THEMES_INSTALL
|
||||
|
||||
@@ -1,314 +0,0 @@
|
||||
# JSON-GLib Integration Plan for Issue #15
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines the complete plan for integrating json-glib into the CProof (profanity) build system and codebase. The integration replaces the `strstr`-based JSON parsing in [`_parse_ai_response()`](src/ai/ai_client.c:587) with proper json-glib parsing, and adds comprehensive unit tests.
|
||||
|
||||
---
|
||||
|
||||
## Current State Analysis
|
||||
|
||||
### What Has Already Been Done
|
||||
|
||||
| Component | Status | Details |
|
||||
|-----------|--------|---------|
|
||||
| `configure.ac` | DONE | `PKG_CHECK_MODULES([json-glib], [json-glib-1.0 >= 1.6.0])` added at line 101-102 |
|
||||
| `src/ai/ai_client.c` | DONE | `#include <json-glib/json-glib.h>` added at line 24; [`_parse_ai_response()`](src/ai/ai_client.c:587) rewritten using json-glib |
|
||||
| `Dockerfile.ubuntu` | DONE | `libjson-glib-dev` added at line 37 |
|
||||
| `Dockerfile.debian` | DONE | `libjson-glib-dev` added at line 38 |
|
||||
|
||||
### What Is Missing (Root Causes of `bootstrap.sh` Failure)
|
||||
|
||||
1. **`Makefile.am` missing json-glib flags:**
|
||||
- `AM_CFLAGS` at line 272 does not include `$(json-glib_CFLAGS)`
|
||||
- `tests_unittests_unittests_LDADD` at line 299 does not include `$(json-glib_LIBS)`
|
||||
- The main `profanity` binary target also needs `$(json-glib_LIBS)` in its link flags
|
||||
|
||||
2. **No unit tests for `_parse_ai_response()`:**
|
||||
- [`test_ai_client.c`](tests/unittests/test_ai_client.c) has 464 lines of tests but none for the JSON parsing function
|
||||
- The function is `static`, so it cannot be tested directly; a test harness or refactoring is needed
|
||||
|
||||
3. **`bootstrap.sh` failure:**
|
||||
- [`bootstrap.sh`](bootstrap.sh) is a simple script: `mkdir -p m4 && autoreconf -i "$@"`
|
||||
- The failure is caused by `autoreconf` failing due to missing json-glib m4 macros in the `m4/` directory, or the `configure.ac` referencing `PKG_CHECK_MODULES` without the proper `ax_pthread.m4` or json-glib pkg-config files being available in the build environment
|
||||
|
||||
---
|
||||
|
||||
## Detailed Plan
|
||||
|
||||
### 1. Build System Changes
|
||||
|
||||
#### 1.1 `configure.ac` — No Changes Needed
|
||||
|
||||
The json-glib check is already present:
|
||||
|
||||
```autoconf
|
||||
PKG_CHECK_MODULES([json-glib], [json-glib-1.0 >= 1.6.0], [],
|
||||
[AC_MSG_ERROR([json-glib 1.6.0 or higher is required])])
|
||||
```
|
||||
|
||||
This generates the variables `json-glib_CFLAGS` and `json-glib_LIBS` for use in `Makefile.am`.
|
||||
|
||||
#### 1.2 `Makefile.am` — Changes Required
|
||||
|
||||
**File:** [`Makefile.am`](Makefile.am)
|
||||
|
||||
**Change 1: Add json-glib_CFLAGS to AM_CFLAGS (line 272)**
|
||||
|
||||
```makefile
|
||||
# Current (line 272):
|
||||
AM_CFLAGS = @AM_CFLAGS@ -I$(srcdir)/src
|
||||
|
||||
# Change to:
|
||||
AM_CFLAGS = @AM_CFLAGS@ $(json-glib_CFLAGS) -I$(srcdir)/src
|
||||
```
|
||||
|
||||
**Change 2: Add json-glib_LIBS to profanity linking (line 275)**
|
||||
|
||||
The `profanity` binary target needs json-glib_LIBS. Since `profanity_SOURCES` is defined at line 275 and no explicit `profanity_LDADD` exists, we need to add one:
|
||||
|
||||
```makefile
|
||||
# Add after line 275:
|
||||
profanity_LDADD = $(json-glib_LIBS) $(LIBS)
|
||||
```
|
||||
|
||||
**Change 3: Add json-glib_LIBS to test linking (line 299)**
|
||||
|
||||
```makefile
|
||||
# Current (line 299):
|
||||
tests_unittests_unittests_LDADD = -lcmocka
|
||||
|
||||
# Change to:
|
||||
tests_unittests_unittests_LDADD = -lcmocka $(json-glib_LIBS)
|
||||
```
|
||||
|
||||
#### 1.3 Why `bootstrap.sh` Fails
|
||||
|
||||
The [`bootstrap.sh`](bootstrap.sh) script runs `autoreconf -i`, which regenerates the `configure` script from `configure.ac`. The failure occurs because:
|
||||
|
||||
1. **pkg-config dependency:** `PKG_CHECK_MODULES` requires `pkg-config` to be available at configure-time (not build-time). The `autoreconf` step itself should succeed, but the resulting `configure` script will fail if `pkg-config --modversion json-glib-1.0 >= 1.6.0` cannot find the json-glib installation.
|
||||
|
||||
2. **Likely root cause:** The build environment (Docker container or host) does not have `json-glib` development files installed, or `pkg-config` cannot locate the `json-glib-1.0.pc` file.
|
||||
|
||||
**Fix:** Ensure `libjson-glib-dev` (Debian/Ubuntu) or `json-glib` (Arch/Fedora) is installed in the build environment before running `bootstrap.sh`.
|
||||
|
||||
---
|
||||
|
||||
### 2. Source Code Changes
|
||||
|
||||
#### 2.1 `src/ai/ai_client.c` — No Changes Needed
|
||||
|
||||
The [`_parse_ai_response()`](src/ai/ai_client.c:587) function has already been rewritten using json-glib:
|
||||
|
||||
```c
|
||||
static gchar*
|
||||
_parse_ai_response(const gchar* response_json)
|
||||
{
|
||||
// ... json-glib parsing with three fallback strategies:
|
||||
// Try 1: Perplexity /v1/agent format
|
||||
// Try 2: OpenAI /v1/chat/completions format
|
||||
// Try 3: Fallback "text" field
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.2 `src/ai/ai_client.h` — No Changes Needed
|
||||
|
||||
The header file already includes `<glib.h>` and defines all necessary types. No json-glib-specific types are exposed (json-glib is an implementation detail).
|
||||
|
||||
---
|
||||
|
||||
### 3. Test Changes
|
||||
|
||||
#### 3.1 `tests/unittests/test_ai_client.c` — New Tests Required
|
||||
|
||||
The current test file has 464 lines covering provider management, session handling, JSON escaping, and autocomplete. **No tests exist for `_parse_ai_response()`** because it is `static`.
|
||||
|
||||
**Option A: Refactor `_parse_ai_response` to be testable (Recommended)**
|
||||
|
||||
Make the function non-static and add a test wrapper:
|
||||
|
||||
```c
|
||||
// In ai_client.h, add:
|
||||
gchar* ai_parse_ai_response(const gchar* response_json);
|
||||
|
||||
// In ai_client.c, change:
|
||||
// static gchar* _parse_ai_response(...) → gchar* ai_parse_ai_response(...)
|
||||
```
|
||||
|
||||
**Option B: Use a test harness with `extern` declaration**
|
||||
|
||||
Add to test file:
|
||||
```c
|
||||
extern gchar* _parse_ai_response(const gchar* response_json);
|
||||
```
|
||||
|
||||
**Recommended tests to add:**
|
||||
|
||||
| Test Name | Description |
|
||||
|-----------|-------------|
|
||||
| `test_ai_parse_response_null` | NULL input returns NULL |
|
||||
| `test_ai_parse_response_empty` | Empty string returns NULL |
|
||||
| `test_ai_parse_response_invalid_json` | Malformed JSON returns NULL |
|
||||
| `test_ai_parse_response_openai_format` | Valid OpenAI `/v1/chat/completions` response |
|
||||
| `test_ai_parse_response_openai_empty_choices` | Response with empty choices array |
|
||||
| `test_ai_parse_response_perplexity_format` | Valid Perplexity `/v1/agent` response |
|
||||
| `test_ai_parse_response_perplexity_empty_output` | Response with empty output array |
|
||||
| `test_ai_parse_response_text_fallback` | Response with only "text" field |
|
||||
| `test_ai_parse_response_unicode_content` | Content with unicode characters |
|
||||
| `test_ai_parse_response_multiline_content` | Content with newlines and tabs |
|
||||
| `test_ai_parse_response_html_content` | Content with HTML tags |
|
||||
| `test_ai_parse_response_error_format` | Response with "error" field (not "choices") |
|
||||
|
||||
#### 3.2 `tests/unittests/test_ai_client.h` — Update Required
|
||||
|
||||
Add declarations for new test functions:
|
||||
|
||||
```c
|
||||
/* JSON parsing tests */
|
||||
void test_ai_parse_response_null(void** state);
|
||||
void test_ai_parse_response_empty(void** state);
|
||||
void test_ai_parse_response_invalid_json(void** state);
|
||||
void test_ai_parse_response_openai_format(void** state);
|
||||
void test_ai_parse_response_openai_empty_choices(void** state);
|
||||
void test_ai_parse_response_perplexity_format(void** state);
|
||||
void test_ai_parse_response_perplexity_empty_output(void** state);
|
||||
void test_ai_parse_response_text_fallback(void** state);
|
||||
void test_ai_parse_response_unicode_content(void** state);
|
||||
void test_ai_parse_response_multiline_content(void** state);
|
||||
void test_ai_parse_response_html_content(void** state);
|
||||
void test_ai_parse_response_error_format(void** state);
|
||||
```
|
||||
|
||||
#### 3.3 `tests/unittests/unittests.c` — Update Required
|
||||
|
||||
Add new test entries after the existing AI client tests (around line 696):
|
||||
|
||||
```c
|
||||
// After line 696 (test_ai_json_escape_backslash_quote):
|
||||
cmocka_unit_test(test_ai_parse_response_null),
|
||||
cmocka_unit_test(test_ai_parse_response_empty),
|
||||
cmocka_unit_test(test_ai_parse_response_invalid_json),
|
||||
cmocka_unit_test(test_ai_parse_response_openai_format),
|
||||
cmocka_unit_test(test_ai_parse_response_openai_empty_choices),
|
||||
cmocka_unit_test(test_ai_parse_response_perplexity_format),
|
||||
cmocka_unit_test(test_ai_parse_response_perplexity_empty_output),
|
||||
cmocka_unit_test(test_ai_parse_response_text_fallback),
|
||||
cmocka_unit_test(test_ai_parse_response_unicode_content),
|
||||
cmocka_unit_test(test_ai_parse_response_multiline_content),
|
||||
cmocka_unit_test(test_ai_parse_response_html_content),
|
||||
cmocka_unit_test(test_ai_parse_response_error_format),
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Dockerfile Changes
|
||||
|
||||
#### 4.1 `Dockerfile.ubuntu` — No Changes Needed
|
||||
|
||||
`libjson-glib-dev` is already present at line 37.
|
||||
|
||||
#### 4.2 `Dockerfile.debian` — No Changes Needed
|
||||
|
||||
`libjson-glib-dev` is already present at line 38.
|
||||
|
||||
#### 4.3 `Dockerfile.fedora` — Check Required
|
||||
|
||||
Verify `json-glib-devel` is installed. If not, add:
|
||||
|
||||
```dockerfile
|
||||
json-glib-devel \
|
||||
```
|
||||
|
||||
#### 4.4 `Dockerfile.arch` — Check Required
|
||||
|
||||
Verify `json-glib` is installed. If not, add:
|
||||
|
||||
```dockerfile
|
||||
json-glib \
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. Bootstrap.sh Fix
|
||||
|
||||
#### 5.1 Current State
|
||||
|
||||
[`bootstrap.sh`](bootstrap.sh):
|
||||
```sh
|
||||
#!/bin/sh
|
||||
mkdir -p m4
|
||||
autoreconf -i "$@"
|
||||
```
|
||||
|
||||
#### 5.2 Root Cause Analysis
|
||||
|
||||
The script itself is correct. The failure is due to one of:
|
||||
|
||||
1. **Missing `json-glib-1.0.pc`**: `pkg-config` cannot find json-glib in the build environment
|
||||
2. **Missing `m4/ax_pthread.m4`**: The `configure.ac` uses `ACX_PTHREAD` which requires the `ax_pthread.m4` macro
|
||||
3. **Missing `m4/` macros**: Other autoconf macros referenced in `configure.ac` are not present
|
||||
|
||||
#### 5.3 Fix Steps
|
||||
|
||||
1. **Ensure json-glib-dev is installed** in the build environment
|
||||
2. **Verify m4 macros exist:**
|
||||
```bash
|
||||
ls -la m4/
|
||||
# Should contain: ax_pthread.m4, ax_valgrind_check.m4, etc.
|
||||
```
|
||||
3. **If m4 macros are missing**, they need to be copied from the system:
|
||||
```bash
|
||||
# For Debian/Ubuntu:
|
||||
cp /usr/share/autoconf-archive/m4/ax_pthread.m4 m4/
|
||||
cp /usr/share/autoconf-archive/m4/ax_valgrind_check.m4 m4/
|
||||
```
|
||||
4. **Test bootstrap.sh manually:**
|
||||
```bash
|
||||
./bootstrap.sh
|
||||
./configure
|
||||
make
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[1. Fix Makefile.am] --> B[2. Verify bootstrap.sh works]
|
||||
B --> C[3. Add test functions to test_ai_client.c]
|
||||
C --> D[4. Update test_ai_client.h]
|
||||
D --> E[5. Register tests in unittests.c]
|
||||
E --> F[6. Verify Dockerfiles have json-glib-dev]
|
||||
F --> G[7. Run make check to verify all tests pass]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary of Changes
|
||||
|
||||
| File | Change Type | Lines Affected |
|
||||
|------|-------------|----------------|
|
||||
| `Makefile.am` | Modify | Line 272 (AM_CFLAGS), Line 275 (add profanity_LDADD), Line 299 (LDADD) |
|
||||
| `src/ai/ai_client.c` | None | Already complete |
|
||||
| `src/ai/ai_client.h` | None | Already complete |
|
||||
| `tests/unittests/test_ai_client.c` | Add | ~200 lines of new test functions |
|
||||
| `tests/unittests/test_ai_client.h` | Add | ~12 new declarations |
|
||||
| `tests/unittests/unittests.c` | Add | ~12 new test entries |
|
||||
| `Dockerfile.ubuntu` | None | Already has libjson-glib-dev |
|
||||
| `Dockerfile.debian` | None | Already has libjson-glib-dev |
|
||||
| `Dockerfile.fedora` | Check | Verify json-glib-devel present |
|
||||
| `Dockerfile.arch` | Check | Verify json-glib present |
|
||||
| `bootstrap.sh` | Diagnose | Fix is environmental, not script change |
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
- [ ] `Makefile.am` updated with `$(json-glib_CFLAGS)` and `$(json-glib_LIBS)`
|
||||
- [ ] `bootstrap.sh` runs successfully
|
||||
- [ ] `./configure` completes without json-glib errors
|
||||
- [ ] `make` compiles without errors
|
||||
- [ ] `make check` runs all tests including new json-glib tests
|
||||
- [ ] All existing tests still pass
|
||||
- [ ] Docker builds succeed with json-glib integration
|
||||
@@ -197,7 +197,47 @@ Configuration for
|
||||
.B Profanity
|
||||
is stored in
|
||||
.I $XDG_CONFIG_HOME/profanity/profrc
|
||||
, details on commands for configuring Profanity can be found at <https://profanity-im.github.io/reference.html> or the respective built\-in help or man pages.
|
||||
, details on commands for configuring Profanity can be found at <https://profanity-im.github.io/reference.html> or the respective built\-in help or man pages..SS Message History Storage
|
||||
By default, message history is stored in an SQLite database. An alternative flat-file backend
|
||||
stores messages as human-readable plain text files that can be edited with any text editor.
|
||||
.PP
|
||||
To enable flat-file logging, set in
|
||||
.IR profrc :
|
||||
.PP
|
||||
.EX
|
||||
[logging]
|
||||
dblog=flatfile
|
||||
.EE
|
||||
.PP
|
||||
Or use the command:
|
||||
.B /privacy logging flatfile
|
||||
.PP
|
||||
Flat-file logs are stored under
|
||||
.IR $XDG_DATA_HOME/profanity/flatlog/ ,
|
||||
organized as
|
||||
.IR {account_jid}/{contact_jid}/history.log .
|
||||
.PP
|
||||
Each line has the format:
|
||||
.br
|
||||
.EX
|
||||
{ISO8601} [{type}|{enc}|id:{id}|aid:{archive_id}|corrects:{id}] {sender}: {message}
|
||||
.EE
|
||||
.PP
|
||||
Use
|
||||
.B /history verify
|
||||
to check integrity of stored history (both SQLite and flat-file).
|
||||
.PP
|
||||
Use
|
||||
.B /history export [<jid>]
|
||||
to copy messages from SQLite to flat-file format (merge with existing data), or
|
||||
.B /history import [<jid>]
|
||||
to copy from flat-file to SQLite.
|
||||
Both operations skip duplicates.
|
||||
Omit the JID to process all contacts.
|
||||
.PP
|
||||
Use
|
||||
.B /history switch sqlite|flatfile
|
||||
to change the active database backend at runtime without reconnecting.
|
||||
.SH BUGS
|
||||
Bugs can either be reported by raising an issue at the Github issue tracker:
|
||||
.br
|
||||
|
||||
@@ -49,6 +49,13 @@ grlog=true
|
||||
maxsize=1048580
|
||||
rotate=true
|
||||
shared=true
|
||||
# Database backend for message history:
|
||||
# on - SQLite database (default)
|
||||
# off - no message logging
|
||||
# redact - store with redacted message content
|
||||
# flatfile - plain text files, editable with any text editor
|
||||
# stored in ~/.local/share/profanity/flatlog/
|
||||
dblog=on
|
||||
|
||||
[otr]
|
||||
warn=true
|
||||
|
||||
@@ -1,839 +0,0 @@
|
||||
/*
|
||||
* ai_client.c - AI client for interacting with OpenAI-compatible API providers
|
||||
*
|
||||
* Supports multiple providers (OpenAI, Perplexity, etc.) with per-provider
|
||||
* API keys, custom endpoints, and model selection.
|
||||
*
|
||||
* Copyright (C) 2026 CProof Developers
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later WITH OpenSSL-exception
|
||||
*/
|
||||
// vim: expandtab:ts=4:sts=4:sw=4
|
||||
|
||||
#include "ai_client.h"
|
||||
#include "common.h"
|
||||
#include "log.h"
|
||||
#include "config/preferences.h"
|
||||
#include "profanity.h"
|
||||
#include "tools/autocomplete.h"
|
||||
#include "ui/ui.h"
|
||||
#include "ui/window_list.h"
|
||||
|
||||
#include <curl/curl.h>
|
||||
#include <glib.h>
|
||||
#include <json-glib/json-glib.h>
|
||||
#include <string.h>
|
||||
|
||||
/* Default providers */
|
||||
#define DEFAULT_OPENAI_URL "https://api.openai.com/"
|
||||
#define DEFAULT_PERPLEXITY_URL "https://api.perplexity.ai/"
|
||||
|
||||
/* Global state */
|
||||
static GHashTable* providers = NULL;
|
||||
static GHashTable* provider_keys = NULL;
|
||||
static Autocomplete providers_ac = NULL;
|
||||
|
||||
/* ========================================================================
|
||||
* Curl helpers
|
||||
* ======================================================================== */
|
||||
|
||||
struct curl_response_t
|
||||
{
|
||||
gchar* data;
|
||||
size_t size;
|
||||
};
|
||||
|
||||
static size_t
|
||||
_write_callback(void* ptr, size_t size, size_t nmemb, void* userdata)
|
||||
{
|
||||
size_t realsize = size * nmemb;
|
||||
struct curl_response_t* res = (struct curl_response_t*)userdata;
|
||||
|
||||
/* Limit response size to 10MB to prevent OOM */
|
||||
if (res->size + realsize > 10 * 1024 * 1024) {
|
||||
log_error("[AI-THREAD] Response too large, truncating");
|
||||
return realsize;
|
||||
}
|
||||
|
||||
gchar* new_data = g_realloc(res->data, res->size + realsize + 1);
|
||||
if (!new_data) {
|
||||
log_error("[AI-THREAD] Failed to allocate memory for response");
|
||||
return realsize;
|
||||
}
|
||||
|
||||
res->data = new_data;
|
||||
memcpy(res->data + res->size, ptr, realsize);
|
||||
res->size += realsize;
|
||||
res->data[res->size] = '\0';
|
||||
|
||||
return realsize;
|
||||
}
|
||||
|
||||
/* ========================================================================
|
||||
* Thread-safe UI display helpers
|
||||
* ======================================================================== */
|
||||
|
||||
/**
|
||||
* Validate aiwin pointer by checking if it still exists in the window list.
|
||||
* Detects if the original window was freed (TOCTOU protection).
|
||||
* Returns the validated pointer if valid, NULL if window was closed.
|
||||
*/
|
||||
static ProfAiWin*
|
||||
_aiwin_validate(gpointer user_data)
|
||||
{
|
||||
if (!user_data) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
if (!wins_ai_exists((ProfAiWin*)user_data)) {
|
||||
log_warning("[AI-THREAD] aiwin=%p no longer exists — window was closed", (void*)user_data);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Pointer is valid */
|
||||
return (ProfAiWin*)user_data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display an error message in the AI window (thread-safe).
|
||||
* @param user_data The original user_data pointer (may be NULL)
|
||||
* @param error_msg The error message to display
|
||||
*/
|
||||
static void
|
||||
_aiwin_display_error(gpointer user_data, const gchar* error_msg)
|
||||
{
|
||||
ProfAiWin* aiwin = _aiwin_validate(user_data);
|
||||
if (!aiwin) {
|
||||
log_warning("[AI-THREAD] Cannot display error: aiwin is invalid or window was closed (msg: %s)", error_msg);
|
||||
return;
|
||||
}
|
||||
pthread_mutex_lock(&lock);
|
||||
aiwin_display_error(aiwin, error_msg);
|
||||
pthread_mutex_unlock(&lock);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a response message in the AI window (thread-safe).
|
||||
* @param user_data The original user_data pointer (may be NULL)
|
||||
* @param response The response message to display
|
||||
*/
|
||||
static void
|
||||
_aiwin_display_response(gpointer user_data, const gchar* response)
|
||||
{
|
||||
ProfAiWin* aiwin = _aiwin_validate(user_data);
|
||||
if (!aiwin) {
|
||||
log_warning("[AI-THREAD] Cannot display response: aiwin is invalid or window was closed");
|
||||
return;
|
||||
}
|
||||
pthread_mutex_lock(&lock);
|
||||
aiwin_display_response(aiwin, response);
|
||||
pthread_mutex_unlock(&lock);
|
||||
}
|
||||
|
||||
/* ========================================================================
|
||||
* JSON helpers
|
||||
* ======================================================================== */
|
||||
|
||||
gchar*
|
||||
ai_json_escape(const gchar* str)
|
||||
{
|
||||
if (!str)
|
||||
return g_strdup("");
|
||||
|
||||
GString* result = g_string_new("");
|
||||
for (const gchar* p = str; *p; p++) {
|
||||
switch (*p) {
|
||||
case '"':
|
||||
g_string_append(result, "\\\"");
|
||||
break;
|
||||
case '\\':
|
||||
g_string_append(result, "\\\\");
|
||||
break;
|
||||
case '\b':
|
||||
g_string_append(result, "\\b");
|
||||
break;
|
||||
case '\f':
|
||||
g_string_append(result, "\\f");
|
||||
break;
|
||||
case '\n':
|
||||
g_string_append(result, "\\n");
|
||||
break;
|
||||
case '\r':
|
||||
g_string_append(result, "\\r");
|
||||
break;
|
||||
case '\t':
|
||||
g_string_append(result, "\\t");
|
||||
break;
|
||||
default:
|
||||
g_string_append_c(result, *p);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return g_string_free(result, FALSE);
|
||||
}
|
||||
|
||||
/* ========================================================================
|
||||
* Provider Management
|
||||
* ======================================================================== */
|
||||
|
||||
static AIProvider*
|
||||
ai_provider_new(const gchar* name, const gchar* api_url, const gchar* org_id)
|
||||
{
|
||||
AIProvider* provider = g_new0(AIProvider, 1);
|
||||
provider->name = g_strdup(name);
|
||||
provider->api_url = g_strdup(api_url ? api_url : "");
|
||||
provider->org_id = g_strdup(org_id);
|
||||
provider->project_id = NULL;
|
||||
provider->models = NULL;
|
||||
provider->ref_count = 1;
|
||||
return provider;
|
||||
}
|
||||
|
||||
static AIProvider*
|
||||
ai_provider_ref(AIProvider* provider)
|
||||
{
|
||||
if (provider) {
|
||||
g_atomic_int_inc(&provider->ref_count);
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
void
|
||||
ai_provider_unref(AIProvider* provider)
|
||||
{
|
||||
if (!provider)
|
||||
return;
|
||||
|
||||
if (!g_atomic_int_dec_and_test(&provider->ref_count)) {
|
||||
return;
|
||||
}
|
||||
|
||||
g_free(provider->name);
|
||||
g_free(provider->api_url);
|
||||
g_free(provider->org_id);
|
||||
g_free(provider->project_id);
|
||||
|
||||
GList* curr = provider->models;
|
||||
while (curr) {
|
||||
g_free(curr->data);
|
||||
curr = g_list_next(curr);
|
||||
}
|
||||
g_list_free(provider->models);
|
||||
|
||||
g_free(provider);
|
||||
}
|
||||
|
||||
static void
|
||||
ai_load_keys(void)
|
||||
{
|
||||
if (!provider_keys) {
|
||||
provider_keys = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, g_free);
|
||||
}
|
||||
|
||||
GList* tokens = prefs_ai_list_tokens();
|
||||
if (!tokens) {
|
||||
return;
|
||||
}
|
||||
|
||||
GList* curr = tokens;
|
||||
while (curr) {
|
||||
gchar* provider = (gchar*)curr->data;
|
||||
gchar* key = prefs_ai_get_token(provider);
|
||||
if (key && strlen(key) > 0) {
|
||||
g_hash_table_insert(provider_keys, g_strdup(provider), g_strdup(key));
|
||||
}
|
||||
g_free(key);
|
||||
curr = g_list_next(curr);
|
||||
}
|
||||
|
||||
prefs_free_ai_tokens(tokens);
|
||||
log_info("Loaded %d saved API keys from config", g_hash_table_size(provider_keys));
|
||||
}
|
||||
|
||||
void
|
||||
ai_client_init(void)
|
||||
{
|
||||
if (providers)
|
||||
return; /* Already initialized */
|
||||
|
||||
curl_global_init(CURL_GLOBAL_ALL);
|
||||
|
||||
/* Create hash tables */
|
||||
providers = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, (GDestroyNotify)ai_provider_unref);
|
||||
provider_keys = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, g_free);
|
||||
|
||||
/* Create autocomplete for provider names */
|
||||
providers_ac = autocomplete_new();
|
||||
|
||||
/* Add default providers */
|
||||
ai_add_provider("openai", DEFAULT_OPENAI_URL, NULL);
|
||||
ai_add_provider("perplexity", DEFAULT_PERPLEXITY_URL, NULL);
|
||||
|
||||
/* Load saved API keys from config */
|
||||
ai_load_keys();
|
||||
|
||||
log_info("AI client initialized with default providers: openai, perplexity");
|
||||
}
|
||||
|
||||
void
|
||||
ai_client_shutdown(void)
|
||||
{
|
||||
if (!providers)
|
||||
return;
|
||||
|
||||
g_hash_table_destroy(providers);
|
||||
g_hash_table_destroy(provider_keys);
|
||||
providers = NULL;
|
||||
provider_keys = NULL;
|
||||
|
||||
if (providers_ac) {
|
||||
autocomplete_free(providers_ac);
|
||||
providers_ac = NULL;
|
||||
}
|
||||
|
||||
curl_global_cleanup();
|
||||
log_info("AI client shutdown");
|
||||
}
|
||||
|
||||
AIProvider*
|
||||
ai_get_provider(const gchar* name)
|
||||
{
|
||||
if (!name || !providers)
|
||||
return NULL;
|
||||
return g_hash_table_lookup(providers, name);
|
||||
}
|
||||
|
||||
AIProvider*
|
||||
ai_add_provider(const gchar* name, const gchar* api_url, const gchar* org_id)
|
||||
{
|
||||
if (!name || !api_url)
|
||||
return NULL;
|
||||
|
||||
if (!providers) {
|
||||
ai_client_init();
|
||||
}
|
||||
|
||||
/* Check if provider already exists */
|
||||
AIProvider* existing = g_hash_table_lookup(providers, name);
|
||||
if (existing) {
|
||||
/* Update existing provider */
|
||||
g_free(existing->api_url);
|
||||
existing->api_url = g_strdup(api_url);
|
||||
g_free(existing->org_id);
|
||||
existing->org_id = g_strdup(org_id);
|
||||
log_info("Updated provider: %s", name);
|
||||
return ai_provider_ref(existing);
|
||||
}
|
||||
|
||||
/* Create new provider (ref_count=1 owned by hash table) */
|
||||
AIProvider* provider = ai_provider_new(name, api_url, org_id);
|
||||
g_hash_table_insert(providers, g_strdup(name), provider);
|
||||
|
||||
/* Sync autocomplete */
|
||||
autocomplete_add(providers_ac, name);
|
||||
|
||||
log_info("Added provider: %s (URL: %s)", name, api_url);
|
||||
return provider; /* Caller gets non-owning pointer; hash table owns ref */
|
||||
}
|
||||
|
||||
gboolean
|
||||
ai_remove_provider(const gchar* name)
|
||||
{
|
||||
if (!name || !providers)
|
||||
return FALSE;
|
||||
|
||||
/* Sync autocomplete before removing */
|
||||
autocomplete_remove(providers_ac, name);
|
||||
|
||||
return g_hash_table_remove(providers, name);
|
||||
}
|
||||
|
||||
GList*
|
||||
ai_list_providers(void)
|
||||
{
|
||||
if (!providers)
|
||||
return NULL;
|
||||
|
||||
GList* result = NULL;
|
||||
GHashTableIter iter;
|
||||
gpointer key, value;
|
||||
|
||||
g_hash_table_iter_init(&iter, providers);
|
||||
while (g_hash_table_iter_next(&iter, &key, &value)) {
|
||||
result = g_list_append(result, value);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/* ========================================================================
|
||||
* Provider autocomplete state
|
||||
* ======================================================================== */
|
||||
|
||||
/* Stateful provider name finder with case-sensitive matching.
|
||||
* Maintains position in sorted list for deterministic tab-completion cycling.
|
||||
* The autocomplete is kept in sync via ai_add_provider/ai_remove_provider. */
|
||||
gchar*
|
||||
ai_providers_find(const char* const search_str, gboolean previous, void* context)
|
||||
{
|
||||
/* Initialize autocomplete on first use */
|
||||
if (!providers_ac) {
|
||||
providers_ac = autocomplete_new();
|
||||
}
|
||||
|
||||
/* NULL search_str is treated as empty string for cycling */
|
||||
const char* effective_search = (search_str != NULL) ? search_str : "";
|
||||
|
||||
/* Use stateful autocomplete */
|
||||
return autocomplete_complete(providers_ac, effective_search, FALSE, previous);
|
||||
}
|
||||
|
||||
gchar*
|
||||
ai_get_provider_key(const gchar* provider_name)
|
||||
{
|
||||
if (!provider_name || !provider_keys)
|
||||
return NULL;
|
||||
gchar* key = g_hash_table_lookup(provider_keys, provider_name);
|
||||
return g_strdup(key);
|
||||
}
|
||||
|
||||
void
|
||||
ai_set_provider_key(const gchar* provider_name, const gchar* api_key)
|
||||
{
|
||||
if (!provider_name)
|
||||
return;
|
||||
|
||||
if (!provider_keys) {
|
||||
provider_keys = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, g_free);
|
||||
}
|
||||
|
||||
if (api_key) {
|
||||
g_hash_table_insert(provider_keys, g_strdup(provider_name), g_strdup(api_key));
|
||||
/* Persist to config file */
|
||||
prefs_ai_set_token(provider_name, api_key);
|
||||
log_info("API key set for provider: %s", provider_name);
|
||||
} else {
|
||||
g_hash_table_remove(provider_keys, provider_name);
|
||||
/* Remove from config file */
|
||||
prefs_ai_remove_token(provider_name);
|
||||
log_info("API key removed for provider: %s", provider_name);
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================================================
|
||||
* Session Management
|
||||
* ======================================================================== */
|
||||
|
||||
AISession*
|
||||
ai_session_create(const gchar* provider_name, const gchar* model)
|
||||
{
|
||||
if (!provider_name || !model)
|
||||
return NULL;
|
||||
|
||||
AIProvider* provider = ai_get_provider(provider_name);
|
||||
if (!provider) {
|
||||
log_error("Provider not found: %s", provider_name);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
AISession* session = g_new0(AISession, 1);
|
||||
session->provider_name = g_strdup(provider_name);
|
||||
session->provider = ai_provider_ref(provider);
|
||||
session->model = g_strdup(model);
|
||||
session->api_key = ai_get_provider_key(provider_name);
|
||||
session->history = NULL;
|
||||
session->ref_count = 1;
|
||||
|
||||
log_info("AI session created: %s/%s", provider_name, model);
|
||||
return session;
|
||||
}
|
||||
|
||||
AISession*
|
||||
ai_session_ref(AISession* session)
|
||||
{
|
||||
if (session) {
|
||||
g_atomic_int_inc(&session->ref_count);
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
void
|
||||
ai_session_unref(AISession* session)
|
||||
{
|
||||
if (!session)
|
||||
return;
|
||||
|
||||
if (!g_atomic_int_dec_and_test(&session->ref_count)) {
|
||||
return;
|
||||
}
|
||||
|
||||
g_free(session->provider_name);
|
||||
ai_provider_unref(session->provider);
|
||||
g_free(session->model);
|
||||
g_free(session->api_key);
|
||||
|
||||
GList* curr = session->history;
|
||||
while (curr) {
|
||||
AIMessage* msg = curr->data;
|
||||
g_free(msg->role);
|
||||
g_free(msg->content);
|
||||
g_free(msg);
|
||||
curr = g_list_next(curr);
|
||||
}
|
||||
g_list_free(session->history);
|
||||
|
||||
g_free(session);
|
||||
log_debug("AI session destroyed");
|
||||
}
|
||||
|
||||
void
|
||||
ai_session_add_message(AISession* session, const gchar* role, const gchar* content)
|
||||
{
|
||||
if (!session || !role || !content)
|
||||
return;
|
||||
|
||||
AIMessage* msg = g_new0(AIMessage, 1);
|
||||
msg->role = g_strdup(role);
|
||||
msg->content = g_strdup(content);
|
||||
session->history = g_list_append(session->history, msg);
|
||||
|
||||
log_debug("Added %s message to session (total: %d)", role, g_list_length(session->history));
|
||||
}
|
||||
|
||||
void
|
||||
ai_session_clear_history(AISession* session)
|
||||
{
|
||||
if (!session)
|
||||
return;
|
||||
|
||||
GList* curr = session->history;
|
||||
while (curr) {
|
||||
AIMessage* msg = curr->data;
|
||||
g_free(msg->role);
|
||||
g_free(msg->content);
|
||||
g_free(msg);
|
||||
curr = g_list_next(curr);
|
||||
}
|
||||
g_list_free(session->history);
|
||||
session->history = NULL;
|
||||
|
||||
log_info("AI session history cleared");
|
||||
}
|
||||
|
||||
const gchar*
|
||||
ai_session_get_model(AISession* session)
|
||||
{
|
||||
if (!session)
|
||||
return NULL;
|
||||
return session->model;
|
||||
}
|
||||
|
||||
void
|
||||
ai_session_set_model(AISession* session, const gchar* model)
|
||||
{
|
||||
if (!session || !model)
|
||||
return;
|
||||
|
||||
g_free(session->model);
|
||||
session->model = g_strdup(model);
|
||||
log_info("Session model changed to: %s", model);
|
||||
}
|
||||
|
||||
/* ========================================================================
|
||||
* API Request Handling
|
||||
* ======================================================================== */
|
||||
|
||||
static gchar*
|
||||
_build_json_payload(AISession* session, const gchar* prompt)
|
||||
{
|
||||
|
||||
/* OpenAI-compatible Responses API format:
|
||||
* {"model": "...", "input": [...], "stream": false, "store": false}
|
||||
* store:false prevents providers from storing/using requests for training */
|
||||
GString* messages_json = g_string_new("");
|
||||
GList* curr = session->history;
|
||||
|
||||
while (curr) {
|
||||
AIMessage* msg = curr->data;
|
||||
auto_gchar gchar* escaped_content = ai_json_escape(msg->content);
|
||||
auto_gchar gchar* escaped_role = ai_json_escape(msg->role);
|
||||
|
||||
if (messages_json->len > 0) {
|
||||
g_string_append_c(messages_json, ',');
|
||||
}
|
||||
g_string_append_printf(messages_json, "{\"role\":\"%s\",\"content\":\"%s\"}",
|
||||
escaped_role, escaped_content);
|
||||
|
||||
curr = g_list_next(curr);
|
||||
}
|
||||
|
||||
/* Add the new user message */
|
||||
auto_gchar gchar* escaped_prompt = ai_json_escape(prompt);
|
||||
if (messages_json->len > 0) {
|
||||
g_string_append_c(messages_json, ',');
|
||||
}
|
||||
g_string_append_printf(messages_json, "{\"role\":\"user\",\"content\":\"%s\"}", escaped_prompt);
|
||||
|
||||
auto_gchar gchar* escaped_model = ai_json_escape(session->model);
|
||||
gchar* json_payload = g_strdup_printf(
|
||||
"{\"model\":\"%s\",\"input\":[%s],\"stream\":false,\"store\":false}",
|
||||
escaped_model, messages_json->str);
|
||||
|
||||
g_string_free(messages_json, TRUE);
|
||||
return json_payload;
|
||||
}
|
||||
|
||||
/** @brief Parse AI response JSON and extract content string.
|
||||
*
|
||||
* Tries multiple JSON paths to handle different provider response formats:
|
||||
* 1. Perplexity /v1/agent: {"output":[{"content":[{"text":"..."}]}]}
|
||||
* 2. OpenAI /v1/chat/completions: {"choices":[{"message":{"content":"..."}}]}
|
||||
* 3. Fallback: root-level "text" field
|
||||
* 4. Fallback: root-level "content" field
|
||||
*
|
||||
* @param response_json The JSON response string
|
||||
* @return Newly allocated content string, or NULL on failure
|
||||
*/
|
||||
gchar*
|
||||
ai_parse_response(const gchar* response_json)
|
||||
{
|
||||
if (!response_json || strlen(response_json) == 0) {
|
||||
log_warning("[AI-THREAD] Empty or NULL response JSON");
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Parse JSON using json-glib */
|
||||
auto_gchar gchar* error_msg = NULL;
|
||||
JsonParser* parser = json_parser_new();
|
||||
if (!json_parser_load_from_data(parser, response_json, -1, &error_msg)) {
|
||||
log_warning("[AI-THREAD] Failed to parse AI response JSON: %s", error_msg ? error_msg : "unknown error");
|
||||
g_object_unref(parser);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
JsonNode* root = json_parser_get_root(parser);
|
||||
if (!root || !JSON_IS_OBJECT(json_node_get_object(root))) {
|
||||
log_warning("[AI-THREAD] Invalid AI response JSON: root is not an object");
|
||||
g_object_unref(parser);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
JsonObject* root_obj = json_node_get_object(root);
|
||||
gchar* content = NULL;
|
||||
|
||||
/* Try 1: Perplexity /v1/agent format
|
||||
* {"output":[{"content":[{"text":"...","type":"output_text"}]}]} */
|
||||
JsonNode* output_node = json_object_get_member(root_obj, "output");
|
||||
if (output_node && JSON_IS_ARRAY(json_node_get_array(output_node))) {
|
||||
JsonArray* output_arr = json_node_get_array(output_node);
|
||||
if (json_array_get_length(output_arr) > 0) {
|
||||
JsonObject* first_output = json_array_get_object_element(output_arr, 0);
|
||||
JsonNode* content_node = json_object_get_member(first_output, "content");
|
||||
if (content_node && JSON_IS_ARRAY(json_node_get_array(content_node))) {
|
||||
JsonArray* content_arr = json_node_get_array(content_node);
|
||||
if (json_array_get_length(content_arr) > 0) {
|
||||
JsonObject* first_content = json_array_get_object_element(content_arr, 0);
|
||||
JsonNode* text_node = json_object_get_member(first_content, "text");
|
||||
if (text_node && JSON_IS_VALUE(text_node)) {
|
||||
content = g_strdup(json_node_get_string(text_node));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Try 2: OpenAI /v1/chat/completions format
|
||||
* {"choices":[{"message":{"content":"..."}}]} */
|
||||
if (!content) {
|
||||
JsonNode* choices_node = json_object_get_member(root_obj, "choices");
|
||||
if (choices_node && JSON_IS_ARRAY(json_node_get_array(choices_node))) {
|
||||
JsonArray* choices_arr = json_node_get_array(choices_node);
|
||||
if (json_array_get_length(choices_arr) > 0) {
|
||||
JsonObject* first_choice = json_array_get_object_element(choices_arr, 0);
|
||||
JsonNode* message_node = json_object_get_member(first_choice, "message");
|
||||
if (message_node && JSON_IS_OBJECT(json_node_get_object(message_node))) {
|
||||
JsonObject* message = json_node_get_object(message_node);
|
||||
JsonNode* content_member = json_object_get_member(message, "content");
|
||||
if (content_member && JSON_IS_VALUE(content_member)) {
|
||||
content = g_strdup(json_node_get_string(content_member));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Try 3: Fallback - look for "text" field anywhere in the response
|
||||
* This handles edge cases where the response shape differs */
|
||||
if (!content) {
|
||||
JsonNode* text_node = json_object_get_member(root_obj, "text");
|
||||
if (text_node && JSON_IS_VALUE(text_node)) {
|
||||
content = g_strdup(json_node_get_string(text_node));
|
||||
}
|
||||
}
|
||||
|
||||
/* Try 4: Fallback - look for "content" field at root level */
|
||||
if (!content) {
|
||||
JsonNode* content_node = json_object_get_member(root_obj, "content");
|
||||
if (content_node && JSON_IS_VALUE(content_node)) {
|
||||
content = g_strdup(json_node_get_string(content_node));
|
||||
}
|
||||
}
|
||||
|
||||
g_object_unref(parser);
|
||||
|
||||
if (!content) {
|
||||
log_warning("[AI-THREAD] Could not extract content from AI response");
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
static gpointer
|
||||
_ai_request_thread(gpointer data)
|
||||
{
|
||||
log_debug("[AI-THREAD] Starting AI request thread");
|
||||
|
||||
/* Data is an array: [0]=session, [1]=prompt, [2]=user_data */
|
||||
gpointer* args = (gpointer*)data;
|
||||
AISession* session = (AISession*)args[0];
|
||||
auto_gchar gchar* prompt = args[1];
|
||||
gpointer user_data = args[2];
|
||||
|
||||
log_debug("[AI-THREAD] Session: %s/%s", session->provider_name, session->model);
|
||||
log_debug("[AI-THREAD] API key length: %zu", session->api_key ? strlen(session->api_key) : 0);
|
||||
|
||||
/* Check for API key first */
|
||||
if (!session->api_key || strlen(session->api_key) == 0) {
|
||||
auto_gchar gchar* error_msg = g_strdup_printf("No API key set for provider '%s'. Use '/ai set token %s <key>' to configure.",
|
||||
session->provider_name, session->provider_name);
|
||||
log_error("AI request failed for %s/%s: %s", session->provider_name, session->model, error_msg);
|
||||
_aiwin_display_error(user_data, error_msg);
|
||||
g_free(args);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
CURL* curl = curl_easy_init();
|
||||
log_debug("[AI-THREAD] Curl initialized: %s", curl ? "OK" : "FAILED");
|
||||
if (!curl) {
|
||||
log_error("AI request failed for %s/%s: Failed to initialize curl",
|
||||
session->provider_name, session->model);
|
||||
_aiwin_display_error(user_data, "Failed to initialize curl.");
|
||||
g_free(args);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Add user message to history FIRST */
|
||||
ai_session_add_message(session, "user", prompt);
|
||||
log_debug("[AI-THREAD] Added user message to history");
|
||||
|
||||
/* Build JSON payload (includes the message we just added) */
|
||||
log_debug("[AI-THREAD] Building JSON payload...");
|
||||
auto_gchar gchar* json_payload = _build_json_payload(session, prompt);
|
||||
log_debug("[AI-THREAD] JSON payload: %s", json_payload);
|
||||
|
||||
/* Set up headers */
|
||||
struct curl_slist* headers = NULL;
|
||||
auto_gchar gchar* auth_header = g_strdup_printf("Authorization: Bearer %s", session->api_key);
|
||||
headers = curl_slist_append(headers, "Content-Type: application/json");
|
||||
headers = curl_slist_append(headers, auth_header);
|
||||
|
||||
/* Add organization header if configured */
|
||||
if (session->provider && session->provider->org_id && strlen(session->provider->org_id) > 0) {
|
||||
auto_gchar gchar* org_header = g_strdup_printf("OpenAI-Organization: %s", session->provider->org_id);
|
||||
headers = curl_slist_append(headers, org_header);
|
||||
}
|
||||
|
||||
/* Response buffer */
|
||||
struct curl_response_t response;
|
||||
response.data = g_new0(gchar, 1);
|
||||
response.size = 0;
|
||||
|
||||
/* Configure request */
|
||||
const gchar* api_url = session->provider ? session->provider->api_url : DEFAULT_OPENAI_URL;
|
||||
auto_gchar gchar* request_url = g_strdup_printf("%s%sv1/responses", api_url, g_str_has_suffix(api_url, "/") ? "" : "/");
|
||||
log_debug("[AI-THREAD] API URL: %s", api_url);
|
||||
log_debug("[AI-THREAD] API Request URL: %s", request_url);
|
||||
log_debug("[AI-THREAD] Model: %s", session->model);
|
||||
curl_easy_setopt(curl, CURLOPT_URL, request_url);
|
||||
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
|
||||
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, json_payload);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, _write_callback);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response);
|
||||
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 60L);
|
||||
|
||||
CURLcode res = curl_easy_perform(curl);
|
||||
log_debug("[AI-THREAD] curl_easy_perform completed, res: %d", res);
|
||||
|
||||
/* Get HTTP response code */
|
||||
long http_code = 0;
|
||||
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
|
||||
log_debug("[AI-THREAD] HTTP response code: %ld", http_code);
|
||||
|
||||
if (res != CURLE_OK) {
|
||||
auto_gchar gchar* error_msg = g_strdup(curl_easy_strerror(res));
|
||||
log_error("AI request failed for %s/%s: %s", session->provider_name, session->model, error_msg);
|
||||
_aiwin_display_error(user_data, error_msg);
|
||||
} else if (http_code >= 400) {
|
||||
/* Handle HTTP errors */
|
||||
log_debug("[AI-THREAD] HTTP error response body (%zu bytes): %s",
|
||||
response.size, response.data ? response.data : "NULL");
|
||||
auto_gchar gchar* error_msg = g_strdup_printf("HTTP %ld: %s", http_code,
|
||||
response.data ? response.data : "Unknown error");
|
||||
log_error("AI request failed for %s/%s: %s", session->provider_name, session->model, error_msg);
|
||||
_aiwin_display_error(user_data, error_msg);
|
||||
} else {
|
||||
/* Parse response - transfer ownership to auto_gchar for cleanup */
|
||||
log_debug("[AI-THREAD] Raw API response (%zu bytes): %s", response.size, response.data ? response.data : "NULL");
|
||||
auto_gchar gchar* response_data = response.data;
|
||||
response.data = NULL;
|
||||
auto_gchar gchar* content = _parse_ai_response(response_data);
|
||||
if (content) {
|
||||
/* Add assistant response to history */
|
||||
ai_session_add_message(session, "assistant", content);
|
||||
_aiwin_display_response(user_data, content);
|
||||
} else {
|
||||
log_error("AI response parse failed for %s/%s: %.200s...",
|
||||
session->provider_name, session->model, response_data);
|
||||
_aiwin_display_error(user_data, "Failed to parse AI response.");
|
||||
}
|
||||
}
|
||||
|
||||
curl_slist_free_all(headers);
|
||||
curl_easy_cleanup(curl);
|
||||
|
||||
g_free(args);
|
||||
|
||||
return NULL;
|
||||
}
|
||||
|
||||
gboolean
|
||||
ai_send_prompt(AISession* session, const gchar* prompt, gpointer user_data)
|
||||
{
|
||||
log_debug("[AI-PROMPT] ENTER: session=%p, prompt='%s', user_data=%p",
|
||||
(void*)session, prompt, user_data);
|
||||
|
||||
if (!session || !prompt) {
|
||||
log_error("[AI-PROMPT] FAIL: invalid session or prompt");
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
/* Prepare thread arguments: [0]=session, [1]=prompt, [2]=user_data */
|
||||
gpointer* args = g_new0(gpointer, 3);
|
||||
args[0] = ai_session_ref(session);
|
||||
args[1] = g_strdup(prompt);
|
||||
args[2] = user_data;
|
||||
log_debug("[AI-PROMPT] Prepared args, creating thread...");
|
||||
|
||||
GThread* thread = g_thread_new("ai_request", _ai_request_thread, args);
|
||||
if (!thread) {
|
||||
g_free(args[1]);
|
||||
ai_session_unref(session);
|
||||
g_free(args);
|
||||
log_error("[AI-PROMPT] FAIL: g_thread_new returned NULL");
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
log_debug("[AI-PROMPT] Thread created successfully: %p", (void*)thread);
|
||||
g_thread_unref(thread);
|
||||
return TRUE;
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
#ifndef AI_CLIENT_H
|
||||
#define AI_CLIENT_H
|
||||
|
||||
#include <glib.h>
|
||||
|
||||
/**
|
||||
* @brief AI message structure for conversation history.
|
||||
*/
|
||||
typedef struct ai_message_t
|
||||
{
|
||||
gchar* role; /* "user" or "assistant" */
|
||||
gchar* content; /* Message content */
|
||||
} AIMessage;
|
||||
|
||||
/**
|
||||
* @brief AI provider configuration.
|
||||
*/
|
||||
typedef struct ai_provider_t
|
||||
{
|
||||
gchar* name; /* Provider name (e.g., "openai", "perplexity") */
|
||||
gchar* api_url; /* API endpoint URL */
|
||||
gchar* org_id; /* Optional organization ID */
|
||||
gchar* project_id; /* Optional project ID (for some providers) */
|
||||
GList* models; /* List of available models (gchar*) */
|
||||
gint32 ref_count; /* Reference count (atomic) */
|
||||
} AIProvider;
|
||||
|
||||
/**
|
||||
* @brief AI chat session structure.
|
||||
*/
|
||||
typedef struct ai_session_t
|
||||
{
|
||||
gchar* provider_name; /* Provider name */
|
||||
AIProvider* provider; /* Provider configuration */
|
||||
gchar* model; /* Model identifier (e.g., "gpt-4", "sonar") */
|
||||
gchar* api_key; /* API key for this session */
|
||||
GList* history; /* Conversation history (GList of AIMessage*) */
|
||||
gint32 ref_count; /* Reference count (atomic) */
|
||||
} AISession;
|
||||
|
||||
/* ========================================================================
|
||||
* Provider Management
|
||||
* ======================================================================== */
|
||||
|
||||
/**
|
||||
* Initialize the AI client and load default providers.
|
||||
*/
|
||||
void ai_client_init(void);
|
||||
|
||||
/**
|
||||
* Shutdown the AI client and free resources.
|
||||
*/
|
||||
void ai_client_shutdown(void);
|
||||
|
||||
/**
|
||||
* Get a provider by name.
|
||||
* @param name The provider name (e.g., "openai", "perplexity")
|
||||
* @return AIProvider*, or NULL if not found
|
||||
*/
|
||||
AIProvider* ai_get_provider(const gchar* name);
|
||||
|
||||
/**
|
||||
* Add or update a provider configuration.
|
||||
* @param name The provider name
|
||||
* @param api_url The API endpoint URL
|
||||
* @param org_id Optional organization ID (can be NULL)
|
||||
* @return New AIProvider* (caller must unref when done)
|
||||
*/
|
||||
AIProvider* ai_add_provider(const gchar* name, const gchar* api_url, const gchar* org_id);
|
||||
|
||||
/**
|
||||
* Remove a provider by name.
|
||||
* @param name The provider name
|
||||
* @return TRUE if provider was removed, FALSE if not found
|
||||
*/
|
||||
gboolean ai_remove_provider(const gchar* name);
|
||||
|
||||
/**
|
||||
* Decrement the reference count of a provider.
|
||||
* @param provider The provider to unreference
|
||||
*/
|
||||
void ai_provider_unref(AIProvider* provider);
|
||||
|
||||
/**
|
||||
* List all configured providers.
|
||||
* @return GList of AIProvider* (caller must not free the list or providers,
|
||||
* and must not unref the returned providers)
|
||||
*/
|
||||
GList* ai_list_providers(void);
|
||||
|
||||
/**
|
||||
* Find a provider name for autocomplete.
|
||||
* @param search_str The search string
|
||||
* @param previous Whether to go to previous match
|
||||
* @param context Unused
|
||||
* @return Provider name, or NULL if not found
|
||||
*/
|
||||
gchar* ai_providers_find(const char* const search_str, gboolean previous, void* context);
|
||||
|
||||
/**
|
||||
* Get the API key for a provider.
|
||||
* @param provider_name The provider name
|
||||
* @return The API key, or NULL if not set (caller must free)
|
||||
*/
|
||||
gchar* ai_get_provider_key(const gchar* provider_name);
|
||||
|
||||
/**
|
||||
* Set the API key for a provider.
|
||||
* @param provider_name The provider name
|
||||
* @param api_key The API key to set
|
||||
*/
|
||||
void ai_set_provider_key(const gchar* provider_name, const gchar* api_key);
|
||||
|
||||
/* ========================================================================
|
||||
* Session Management
|
||||
* ======================================================================== */
|
||||
|
||||
/**
|
||||
* Create a new AI session with the specified provider and model.
|
||||
* @param provider_name The provider name (e.g., "openai")
|
||||
* @param model The model identifier (e.g., "gpt-4")
|
||||
* @return New AISession*, or NULL on failure
|
||||
*/
|
||||
AISession* ai_session_create(const gchar* provider_name, const gchar* model);
|
||||
|
||||
/**
|
||||
* Increment the reference count of an AI session.
|
||||
* @param session The session to reference
|
||||
* @return The same session pointer
|
||||
*/
|
||||
AISession* ai_session_ref(AISession* session);
|
||||
|
||||
/**
|
||||
* Decrement the reference count and free the session when it reaches zero.
|
||||
* @param session The session to unreference
|
||||
*/
|
||||
void ai_session_unref(AISession* session);
|
||||
|
||||
/**
|
||||
* Add a message to the session history.
|
||||
* @param session The session
|
||||
* @param role The message role ("user" or "assistant")
|
||||
* @param content The message content
|
||||
*/
|
||||
void ai_session_add_message(AISession* session, const gchar* role, const gchar* content);
|
||||
|
||||
/**
|
||||
* Clear the conversation history.
|
||||
* @param session The session
|
||||
*/
|
||||
void ai_session_clear_history(AISession* session);
|
||||
|
||||
/**
|
||||
* Get the current model for a session.
|
||||
* @param session The session
|
||||
* @return The model name (caller must not free)
|
||||
*/
|
||||
const gchar* ai_session_get_model(AISession* session);
|
||||
|
||||
/**
|
||||
* Set the model for a session.
|
||||
* @param session The session
|
||||
* @param model The model name
|
||||
*/
|
||||
void ai_session_set_model(AISession* session, const gchar* model);
|
||||
|
||||
/* ========================================================================
|
||||
* Request Handling
|
||||
* ======================================================================== */
|
||||
|
||||
/**
|
||||
* Send a prompt to the AI provider asynchronously.
|
||||
* @param session The AI session containing provider and model
|
||||
* @param prompt The prompt to send
|
||||
* @param user_data User data (ProfAiWin* for UI display)
|
||||
* @return TRUE if the request was successfully queued, FALSE otherwise
|
||||
*/
|
||||
gboolean ai_send_prompt(AISession* session, const gchar* prompt,
|
||||
gpointer user_data);
|
||||
|
||||
/**
|
||||
* Escape a string for JSON embedding.
|
||||
* @param str The string to escape
|
||||
* @return Newly allocated escaped string (caller must free)
|
||||
*/
|
||||
gchar* ai_json_escape(const gchar* str);
|
||||
|
||||
#endif /* AI_CLIENT_H */
|
||||
@@ -66,8 +66,6 @@
|
||||
#include "omemo/omemo.h"
|
||||
#endif
|
||||
|
||||
#include "ai/ai_client.h"
|
||||
|
||||
static char* _sub_autocomplete(ProfWin* window, const char* const input, gboolean previous);
|
||||
static char* _notify_autocomplete(ProfWin* window, const char* const input, gboolean previous);
|
||||
static char* _theme_autocomplete(ProfWin* window, const char* const input, gboolean previous);
|
||||
@@ -139,7 +137,6 @@ static char* _strophe_autocomplete(ProfWin* window, const char* const input, gbo
|
||||
static char* _adhoc_cmd_autocomplete(ProfWin* window, const char* const input, gboolean previous);
|
||||
static char* _vcard_autocomplete(ProfWin* window, const char* const input, gboolean previous);
|
||||
static char* _force_encryption_autocomplete(ProfWin* window, const char* const input, gboolean previous);
|
||||
static char* _ai_autocomplete(ProfWin* window, const char* const input, gboolean previous);
|
||||
|
||||
static char* _script_autocomplete_func(const char* const prefix, gboolean previous, void* context);
|
||||
|
||||
@@ -278,12 +275,11 @@ static Autocomplete logging_ac;
|
||||
static Autocomplete logging_group_ac;
|
||||
static Autocomplete privacy_ac;
|
||||
static Autocomplete privacy_log_ac;
|
||||
static Autocomplete history_ac;
|
||||
static Autocomplete history_switch_ac;
|
||||
static Autocomplete color_ac;
|
||||
static Autocomplete correction_ac;
|
||||
static Autocomplete avatar_ac;
|
||||
static Autocomplete ai_subcommands_ac;
|
||||
static Autocomplete ai_set_subcommands_ac;
|
||||
static Autocomplete ai_remove_subcommands_ac;
|
||||
static Autocomplete url_ac;
|
||||
static Autocomplete executable_ac;
|
||||
static Autocomplete executable_param_ac;
|
||||
@@ -435,6 +431,8 @@ static Autocomplete* all_acs[] = {
|
||||
&logging_group_ac,
|
||||
&privacy_ac,
|
||||
&privacy_log_ac,
|
||||
&history_ac,
|
||||
&history_switch_ac,
|
||||
&color_ac,
|
||||
&correction_ac,
|
||||
&avatar_ac,
|
||||
@@ -458,10 +456,7 @@ static Autocomplete* all_acs[] = {
|
||||
&vcard_togglable_param_ac,
|
||||
&vcard_address_type_ac,
|
||||
&force_encryption_ac,
|
||||
&force_encryption_policy_ac,
|
||||
&ai_subcommands_ac,
|
||||
&ai_set_subcommands_ac,
|
||||
&ai_remove_subcommands_ac
|
||||
&force_encryption_policy_ac
|
||||
};
|
||||
|
||||
static GHashTable* ac_funcs = NULL;
|
||||
@@ -1147,6 +1142,17 @@ cmd_ac_init(void)
|
||||
autocomplete_add(privacy_log_ac, "on");
|
||||
autocomplete_add(privacy_log_ac, "off");
|
||||
autocomplete_add(privacy_log_ac, "redact");
|
||||
autocomplete_add(privacy_log_ac, "flatfile");
|
||||
|
||||
autocomplete_add(history_ac, "on");
|
||||
autocomplete_add(history_ac, "off");
|
||||
autocomplete_add(history_ac, "switch");
|
||||
autocomplete_add(history_ac, "verify");
|
||||
autocomplete_add(history_ac, "export");
|
||||
autocomplete_add(history_ac, "import");
|
||||
|
||||
autocomplete_add(history_switch_ac, "sqlite");
|
||||
autocomplete_add(history_switch_ac, "flatfile");
|
||||
|
||||
autocomplete_add(logging_group_ac, "on");
|
||||
autocomplete_add(logging_group_ac, "off");
|
||||
@@ -1162,19 +1168,6 @@ cmd_ac_init(void)
|
||||
autocomplete_add(correction_ac, "off");
|
||||
autocomplete_add(correction_ac, "char");
|
||||
|
||||
autocomplete_add(ai_subcommands_ac, "set");
|
||||
autocomplete_add(ai_subcommands_ac, "remove");
|
||||
autocomplete_add(ai_subcommands_ac, "start");
|
||||
autocomplete_add(ai_subcommands_ac, "clear");
|
||||
autocomplete_add(ai_subcommands_ac, "correct");
|
||||
autocomplete_add(ai_subcommands_ac, "providers");
|
||||
|
||||
autocomplete_add(ai_set_subcommands_ac, "provider");
|
||||
autocomplete_add(ai_set_subcommands_ac, "token");
|
||||
autocomplete_add(ai_set_subcommands_ac, "org");
|
||||
|
||||
autocomplete_add(ai_remove_subcommands_ac, "provider");
|
||||
|
||||
autocomplete_add(avatar_ac, "set");
|
||||
autocomplete_add(avatar_ac, "disable");
|
||||
autocomplete_add(avatar_ac, "get");
|
||||
@@ -1450,7 +1443,6 @@ cmd_ac_init(void)
|
||||
g_hash_table_insert(ac_funcs, "/wins", _wins_autocomplete);
|
||||
g_hash_table_insert(ac_funcs, "/wintitle", _wintitle_autocomplete);
|
||||
g_hash_table_insert(ac_funcs, "/force-encryption", _force_encryption_autocomplete);
|
||||
g_hash_table_insert(ac_funcs, "/ai", _ai_autocomplete);
|
||||
}
|
||||
|
||||
void
|
||||
@@ -1799,7 +1791,7 @@ _cmd_ac_complete_params(ProfWin* window, const char* const input, gboolean previ
|
||||
|
||||
// autocomplete boolean settings
|
||||
gchar* boolean_choices[] = { "/beep", "/states", "/outtype", "/flash", "/splash",
|
||||
"/history", "/vercheck", "/privileges", "/wrap",
|
||||
"/vercheck", "/privileges", "/wrap",
|
||||
"/carbons", "/slashguard", "/mam", "/silence" };
|
||||
|
||||
for (int i = 0; i < ARRAY_SIZE(boolean_choices); i++) {
|
||||
@@ -1809,6 +1801,16 @@ _cmd_ac_complete_params(ProfWin* window, const char* const input, gboolean previ
|
||||
}
|
||||
}
|
||||
|
||||
result = autocomplete_param_with_ac(input, "/history switch", history_switch_ac, TRUE, previous);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
result = autocomplete_param_with_ac(input, "/history", history_ac, TRUE, previous);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// autocomplete nickname in chat rooms
|
||||
if (window->type == WIN_MUC) {
|
||||
ProfMucWin* mucwin = (ProfMucWin*)window;
|
||||
@@ -4438,68 +4440,5 @@ _force_encryption_autocomplete(ProfWin* window, const char* const input, gboolea
|
||||
}
|
||||
|
||||
result = autocomplete_param_with_ac(input, "/force-encryption", force_encryption_ac, TRUE, previous);
|
||||
return result;
|
||||
}
|
||||
|
||||
static char*
|
||||
_ai_autocomplete(ProfWin* window, const char* const input, gboolean previous)
|
||||
{
|
||||
char* result = NULL;
|
||||
|
||||
/* Top-level /ai <subcommand> autocomplete (e.g., /ai s<tab> -> /ai set) */
|
||||
result = autocomplete_param_with_func(input, "/ai set provider", ai_providers_find, previous, NULL);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// /ai set token <provider> <token> - autocomplete provider names
|
||||
result = autocomplete_param_with_func(input, "/ai set token", ai_providers_find, previous, NULL);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// /ai set org <provider> <org_id> - autocomplete provider names
|
||||
result = autocomplete_param_with_func(input, "/ai set org", ai_providers_find, previous, NULL);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// /ai set <subcommand> - autocomplete subcommands
|
||||
result = autocomplete_param_with_ac(input, "/ai set", ai_set_subcommands_ac, TRUE, previous);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// /ai remove provider <name> - autocomplete provider names
|
||||
result = autocomplete_param_with_func(input, "/ai remove provider", ai_providers_find, previous, NULL);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
result = autocomplete_param_with_ac(input, "/ai remove", ai_remove_subcommands_ac, TRUE, previous);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// /ai start [<provider>/<model>] - autocomplete provider names
|
||||
result = autocomplete_param_with_func(input, "/ai start", ai_providers_find, previous, NULL);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// /ai clear [<provider>] - autocomplete provider names
|
||||
result = autocomplete_param_with_func(input, "/ai clear", ai_providers_find, previous, NULL);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// /ai correct <provider> <text>
|
||||
result = autocomplete_param_with_func(input, "/ai correct", ai_providers_find, previous, NULL);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
result = autocomplete_param_with_ac(input, "/ai", ai_subcommands_ac, FALSE, previous);
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -1875,18 +1875,30 @@ static const struct cmd_t command_defs[] = {
|
||||
},
|
||||
|
||||
{ CMD_PREAMBLE("/history",
|
||||
parse_args, 1, 1, &cons_history_setting)
|
||||
parse_args, 1, 2, &cons_history_setting)
|
||||
CMD_MAINFUNC(cmd_history)
|
||||
CMD_TAGS(
|
||||
CMD_TAG_UI,
|
||||
CMD_TAG_CHAT)
|
||||
CMD_SYN(
|
||||
"/history on|off")
|
||||
"/history on|off",
|
||||
"/history switch sqlite|flatfile",
|
||||
"/history verify [<jid>]",
|
||||
"/history export [<jid>]",
|
||||
"/history import [<jid>]")
|
||||
CMD_DESC(
|
||||
"Switch chat history on or off, /logging chat will automatically be enabled when this setting is on. "
|
||||
"When history is enabled, previous messages are shown in chat windows.")
|
||||
"When history is enabled, previous messages are shown in chat windows. "
|
||||
"Use 'switch' to change the active database backend at runtime without reconnecting. "
|
||||
"Use 'verify' to check integrity of stored message history. "
|
||||
"Use 'export' to copy messages from SQLite to flat-file format, or 'import' to copy from flat-file to SQLite. "
|
||||
"Both export and import merge with existing data (duplicates are skipped).")
|
||||
CMD_ARGS(
|
||||
{ "on|off", "Enable or disable showing chat history." })
|
||||
{ "on|off", "Enable or disable showing chat history." },
|
||||
{ "switch sqlite|flatfile", "Switch the active database backend at runtime." },
|
||||
{ "verify [<jid>]", "Verify integrity of message history. Optionally specify a JID to check only one contact." },
|
||||
{ "export [<jid>]", "Export SQLite history to flat-file format. Optionally specify a JID to export only one contact." },
|
||||
{ "import [<jid>]", "Import flat-file history into SQLite. Optionally specify a JID to import only one contact." })
|
||||
},
|
||||
|
||||
{ CMD_PREAMBLE("/log",
|
||||
@@ -2718,7 +2730,7 @@ static const struct cmd_t command_defs[] = {
|
||||
CMD_TAG_CHAT,
|
||||
CMD_TAG_DISCOVERY)
|
||||
CMD_SYN(
|
||||
"/privacy logging on|redact|off",
|
||||
"/privacy logging on|redact|off|flatfile",
|
||||
"/privacy os on|off")
|
||||
CMD_DESC(
|
||||
"Configure privacy settings. "
|
||||
@@ -2726,12 +2738,13 @@ static const struct cmd_t command_defs[] = {
|
||||
"clientid to set the client identification name "
|
||||
"session_alarm to configure an alarm when more clients log in.")
|
||||
CMD_ARGS(
|
||||
{ "logging on|redact|off", "Switch chat logging. This will also disable logging in the internally used SQL database. Your messages will not be saved anywhere locally. This might have unintended consequences, such as not being able to decrypt OMEMO encrypted messages received later via MAM, and should be used with caution." },
|
||||
{ "logging on|redact|off|flatfile", "Switch chat logging. 'on' uses SQLite database (default). 'flatfile' stores messages as plain text files in ~/.local/share/profanity/flatlog/ that can be manually edited with any text editor. 'off' disables logging entirely. 'redact' stores messages with content replaced by '[redacted]'. Note: 'off' might have unintended consequences, such as not being able to decrypt OMEMO encrypted messages received later via MAM. The 'flatfile' setting takes effect on next connection." },
|
||||
{ "os on|off", "Choose whether to include the OS name if a user asks for software information (XEP-0092)." }
|
||||
)
|
||||
CMD_EXAMPLES(
|
||||
"/privacy",
|
||||
"/privacy logging off",
|
||||
"/privacy logging flatfile",
|
||||
"/privacy os off")
|
||||
},
|
||||
|
||||
@@ -2771,61 +2784,6 @@ static const struct cmd_t command_defs[] = {
|
||||
"/force-encryption policy block")
|
||||
},
|
||||
|
||||
{ CMD_PREAMBLE("/ai",
|
||||
parse_args, 0, 5, NULL)
|
||||
CMD_SUBFUNCS(
|
||||
{ "set", cmd_ai_set },
|
||||
{ "remove", cmd_ai_remove },
|
||||
{ "start", cmd_ai_start },
|
||||
{ "clear", cmd_ai_clear },
|
||||
{ "correct", cmd_ai_correct },
|
||||
{ "providers", cmd_ai_providers })
|
||||
CMD_MAINFUNC(cmd_ai)
|
||||
CMD_TAGS(
|
||||
CMD_TAG_CHAT)
|
||||
CMD_SYN(
|
||||
"/ai",
|
||||
"/ai set provider <name> <url>",
|
||||
"/ai set token <provider> <token>",
|
||||
"/ai set org <provider> <org_id>",
|
||||
"/ai remove provider <name>",
|
||||
"/ai providers",
|
||||
"/ai providers-list",
|
||||
"/ai start [<provider>/<model>]",
|
||||
"/ai model <model>",
|
||||
"/ai clear",
|
||||
"/ai correct <message>")
|
||||
CMD_DESC(
|
||||
"Interact with AI models via OpenAI-compatible APIs. "
|
||||
"Supports multiple providers (openai, perplexity, custom). "
|
||||
"Each provider has its own API key and endpoint configuration. "
|
||||
"Chat history is maintained per session and not persisted locally.")
|
||||
CMD_ARGS(
|
||||
{ "", "Display current AI settings and configured providers" },
|
||||
{ "set provider <name> <url>", "Add or update a provider with custom API endpoint" },
|
||||
{ "set token <provider> <token>", "Set API token for a provider (e.g., openai, perplexity)" },
|
||||
{ "set org <provider> <org_id>", "Set organization ID for a provider (optional)" },
|
||||
{ "remove provider <name>", "Remove a custom provider" },
|
||||
{ "providers", "List all available providers" },
|
||||
{ "providers-list", "List configured providers with keys status" },
|
||||
{ "start [<provider>/<model>]", "Start new AI chat (e.g., openai/gpt-4o)" },
|
||||
{ "model <model>", "Change model in current chat window" },
|
||||
{ "clear", "Clear current chat history" },
|
||||
{ "correct <message>", "Correct last user message and get new response" })
|
||||
CMD_EXAMPLES(
|
||||
"/ai",
|
||||
"/ai set token openai sk-xxx",
|
||||
"/ai set token perplexity pplx-xxx",
|
||||
"/ai set provider custom https://my-api.com/v1/chat/completions",
|
||||
"/ai set org openai my-org-id",
|
||||
"/ai remove provider custom",
|
||||
"/ai start openai/gpt-4o",
|
||||
"/ai start perplexity/sonar",
|
||||
"/ai providers-list",
|
||||
"/ai clear",
|
||||
"/ai correct I meant something else")
|
||||
},
|
||||
|
||||
// NEXT-COMMAND (search helper)
|
||||
};
|
||||
|
||||
|
||||
@@ -81,6 +81,7 @@
|
||||
#include "tools/bookmark_ignore.h"
|
||||
#include "tools/editor.h"
|
||||
#include "plugins/plugins.h"
|
||||
#include "ui/inputwin.h"
|
||||
#include "ui/ui.h"
|
||||
#include "ui/window_list.h"
|
||||
#include "xmpp/avatar.h"
|
||||
@@ -116,8 +117,6 @@
|
||||
#include "tools/clipboard.h"
|
||||
#endif
|
||||
|
||||
#include "ai/ai_client.h"
|
||||
|
||||
#ifdef HAVE_PYTHON
|
||||
#include "plugins/python_plugins.h"
|
||||
#endif
|
||||
@@ -6687,6 +6686,11 @@ cmd_privacy(ProfWin* window, const char* const command, gchar** args)
|
||||
} else if (g_strcmp0(arg, "redact") == 0) {
|
||||
cons_show("Messages are going to be redacted.");
|
||||
prefs_set_string(PREF_DBLOG, arg);
|
||||
} else if (g_strcmp0(arg, "flatfile") == 0) {
|
||||
cons_show("Using flat-file backend for message logging. Takes effect on next connection.");
|
||||
prefs_set_string(PREF_DBLOG, arg);
|
||||
prefs_set_boolean(PREF_CHLOG, TRUE);
|
||||
prefs_set_boolean(PREF_HISTORY, TRUE);
|
||||
} else {
|
||||
cons_bad_cmd_usage(command);
|
||||
return TRUE;
|
||||
@@ -6731,6 +6735,125 @@ cmd_history(ProfWin* window, const char* const command, gchar** args)
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
if (g_strcmp0(args[0], "switch") == 0) {
|
||||
jabber_conn_status_t conn_status = connection_get_status();
|
||||
if (conn_status != JABBER_CONNECTED) {
|
||||
cons_show_error("You must be connected to switch database backend.");
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
if (args[1] == NULL) {
|
||||
cons_bad_cmd_usage(command);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
if (g_strcmp0(args[1], "sqlite") != 0 && g_strcmp0(args[1], "flatfile") != 0) {
|
||||
cons_show_error("Backend must be 'sqlite' or 'flatfile'.");
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
if (active_db_backend && g_strcmp0(active_db_backend->name, args[1]) == 0) {
|
||||
cons_show("Already using '%s' backend.", args[1]);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
cons_show("Switching database backend to '%s'...", args[1]);
|
||||
if (log_database_switch_backend(args[1])) {
|
||||
cons_show("Database backend switched to '%s'.", args[1]);
|
||||
} else {
|
||||
cons_show_error("Failed to switch database backend.");
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
if (g_strcmp0(args[0], "verify") == 0) {
|
||||
const gchar* contact_jid = args[1]; // may be NULL (verify all)
|
||||
cons_show("Verifying history integrity...");
|
||||
|
||||
GSList* issues = log_database_verify_integrity(contact_jid);
|
||||
|
||||
int errors = 0, warnings = 0, infos = 0;
|
||||
for (GSList* l = issues; l; l = l->next) {
|
||||
integrity_issue_t* issue = l->data;
|
||||
const char* level_str;
|
||||
switch (issue->level) {
|
||||
case INTEGRITY_ERROR:
|
||||
level_str = "ERROR";
|
||||
errors++;
|
||||
break;
|
||||
case INTEGRITY_WARNING:
|
||||
level_str = "WARN";
|
||||
warnings++;
|
||||
break;
|
||||
case INTEGRITY_INFO:
|
||||
level_str = "INFO";
|
||||
infos++;
|
||||
break;
|
||||
default:
|
||||
level_str = "???";
|
||||
break;
|
||||
}
|
||||
if (issue->line > 0) {
|
||||
if (issue->level == INTEGRITY_ERROR) {
|
||||
cons_show_error("[%s] %s:%d — %s", level_str, issue->file, issue->line, issue->message);
|
||||
} else {
|
||||
cons_show("[%s] %s:%d — %s", level_str, issue->file, issue->line, issue->message);
|
||||
}
|
||||
} else {
|
||||
if (issue->level == INTEGRITY_ERROR) {
|
||||
cons_show_error("[%s] %s — %s", level_str, issue->file, issue->message);
|
||||
} else {
|
||||
cons_show("[%s] %s — %s", level_str, issue->file, issue->message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!issues) {
|
||||
cons_show("Verification complete: no issues found.");
|
||||
} else {
|
||||
cons_show("Verification complete: %d error(s), %d warning(s), %d info(s).",
|
||||
errors, warnings, infos);
|
||||
g_slist_free_full(issues, (GDestroyNotify)integrity_issue_free);
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
if (g_strcmp0(args[0], "export") == 0) {
|
||||
#ifdef HAVE_SQLITE
|
||||
const gchar* contact_jid = args[1]; // may be NULL (export all)
|
||||
if (contact_jid) {
|
||||
cons_show("Exporting SQLite history for %s to flat-file...", contact_jid);
|
||||
} else {
|
||||
cons_show("Exporting all SQLite history to flat-file...");
|
||||
}
|
||||
int n = log_database_export_to_flatfile(contact_jid);
|
||||
if (n >= 0) {
|
||||
cons_show("Export complete: %d message(s) exported.", n);
|
||||
}
|
||||
#else
|
||||
cons_show_error("Export requires SQLite support. Rebuild with --with-sqlite.");
|
||||
#endif
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
if (g_strcmp0(args[0], "import") == 0) {
|
||||
#ifdef HAVE_SQLITE
|
||||
const gchar* contact_jid = args[1]; // may be NULL (import all)
|
||||
if (contact_jid) {
|
||||
cons_show("Importing flat-file history for %s into SQLite...", contact_jid);
|
||||
} else {
|
||||
cons_show("Importing all flat-file history into SQLite...");
|
||||
}
|
||||
int n = log_database_import_from_flatfile(contact_jid);
|
||||
if (n >= 0) {
|
||||
cons_show("Import complete: %d message(s) imported.", n);
|
||||
}
|
||||
#else
|
||||
cons_show_error("Import requires SQLite support. Rebuild with --with-sqlite.");
|
||||
#endif
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
_cmd_set_boolean_preference(args[0], "Chat history", PREF_HISTORY);
|
||||
|
||||
// if set to on, set chlog (/logging chat on)
|
||||
@@ -8327,7 +8450,7 @@ _cmd_execute_default(ProfWin* window, const char* inp)
|
||||
}
|
||||
|
||||
// handle non commands in non chat or plugin windows
|
||||
if (window->type != WIN_CHAT && window->type != WIN_MUC && window->type != WIN_PRIVATE && window->type != WIN_PLUGIN && window->type != WIN_XML && window->type != WIN_AI) {
|
||||
if (window->type != WIN_CHAT && window->type != WIN_MUC && window->type != WIN_PRIVATE && window->type != WIN_PLUGIN && window->type != WIN_XML) {
|
||||
cons_show("Unknown command: %s", inp);
|
||||
cons_alert(NULL);
|
||||
return TRUE;
|
||||
@@ -8373,12 +8496,6 @@ _cmd_execute_default(ProfWin* window, const char* inp)
|
||||
connection_send_stanza(inp);
|
||||
break;
|
||||
}
|
||||
case WIN_AI:
|
||||
{
|
||||
ProfAiWin* aiwin = (ProfAiWin*)window;
|
||||
cl_ev_send_ai_msg(aiwin, inp, NULL);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -10647,298 +10764,6 @@ cmd_vcard_save(ProfWin* window, const char* const command, gchar** args)
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
gboolean
|
||||
cmd_ai(ProfWin* window, const char* const command, gchar** args)
|
||||
{
|
||||
if (args[0] == NULL) {
|
||||
// Display current AI settings
|
||||
cons_show("AI Chat - OpenAI-compatible API client");
|
||||
cons_show("");
|
||||
|
||||
// List available providers
|
||||
GList* providers = ai_list_providers();
|
||||
cons_show("Available providers:");
|
||||
for (GList* curr = providers; curr; curr = g_list_next(curr)) {
|
||||
AIProvider* provider = curr->data;
|
||||
auto_gchar gchar* key = ai_get_provider_key(provider->name);
|
||||
cons_show(" %s (URL: %s, Key: %s)",
|
||||
provider->name,
|
||||
provider->api_url,
|
||||
key ? "set" : "not set");
|
||||
}
|
||||
g_list_free(providers);
|
||||
|
||||
cons_show("");
|
||||
cons_show("Use '/ai start <provider>/<model>' to begin a chat.");
|
||||
cons_show("Available models: https://models.litellm.ai/");
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
cons_bad_cmd_usage(command);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
gboolean
|
||||
cmd_ai_set(ProfWin* window, const char* const command, gchar** args)
|
||||
{
|
||||
if (args[1] == NULL) {
|
||||
cons_bad_cmd_usage(command);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
if (g_strcmp0(args[1], "provider") == 0) {
|
||||
// /ai set provider <name> <url>
|
||||
if (g_strv_length(args) < 4) {
|
||||
cons_bad_cmd_usage(command);
|
||||
return TRUE;
|
||||
}
|
||||
if (ai_add_provider(args[2], args[3], NULL)) {
|
||||
cons_show("Provider '%s' configured with URL: %s", args[2], args[3]);
|
||||
} else {
|
||||
cons_show_error("Failed to configure provider '%s'.", args[2]);
|
||||
}
|
||||
cons_show("");
|
||||
return TRUE;
|
||||
} else if (g_strcmp0(args[1], "token") == 0) {
|
||||
// /ai set token <provider> <token>
|
||||
if (g_strv_length(args) < 4) {
|
||||
cons_bad_cmd_usage(command);
|
||||
return TRUE;
|
||||
}
|
||||
ai_set_provider_key(args[2], args[3]);
|
||||
cons_show("API token set for provider: %s", args[2]);
|
||||
cons_show("");
|
||||
return TRUE;
|
||||
} else if (g_strcmp0(args[1], "org") == 0) {
|
||||
// /ai set org <provider> <org_id>
|
||||
if (g_strv_length(args) < 4) {
|
||||
cons_bad_cmd_usage(command);
|
||||
return TRUE;
|
||||
}
|
||||
AIProvider* provider = ai_get_provider(args[2]);
|
||||
if (provider) {
|
||||
g_free(provider->org_id);
|
||||
provider->org_id = g_strdup(args[3]);
|
||||
cons_show("Organization ID set for provider: %s", args[2]);
|
||||
} else {
|
||||
cons_show("Provider '%s' not found. Add it first with '/ai set provider'.", args[2]);
|
||||
}
|
||||
cons_show("");
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
cons_bad_cmd_usage(command);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
gboolean
|
||||
cmd_ai_remove(ProfWin* window, const char* const command, gchar** args)
|
||||
{
|
||||
// /ai remove provider <name>
|
||||
if (g_strcmp0(args[1], "provider") != 0) {
|
||||
cons_bad_cmd_usage(command);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
if (g_strv_length(args) < 3) {
|
||||
cons_bad_cmd_usage(command);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
if (ai_remove_provider(args[2])) {
|
||||
cons_show("Provider '%s' removed.", args[2]);
|
||||
} else {
|
||||
cons_show("Provider '%s' not found.", args[2]);
|
||||
}
|
||||
cons_show("");
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
gboolean
|
||||
cmd_ai_start(ProfWin* window, const char* const command, gchar** args)
|
||||
{
|
||||
// /ai start [<provider>/<model>]
|
||||
const gchar* provider_model = (g_strv_length(args) >= 2) ? args[1] : NULL;
|
||||
|
||||
auto_gchar gchar* owned_provider_name = NULL;
|
||||
const gchar* provider_name = "openai";
|
||||
const gchar* model = "gpt-4o";
|
||||
|
||||
if (provider_model) {
|
||||
const gchar* slash = strchr(provider_model, '/');
|
||||
if (slash) {
|
||||
owned_provider_name = g_strndup(provider_model, slash - provider_model);
|
||||
provider_name = owned_provider_name;
|
||||
model = slash + 1;
|
||||
} else {
|
||||
// Just a model name, use default provider
|
||||
model = provider_model;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if provider exists
|
||||
AIProvider* provider = ai_get_provider(provider_name);
|
||||
if (!provider) {
|
||||
cons_show_error("Provider '%s' not found. Use '/ai set provider %s <url>' to add it.",
|
||||
provider_name, provider_name);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
// Check for API key
|
||||
gchar* api_key = ai_get_provider_key(provider_name);
|
||||
if (!api_key || strlen(api_key) == 0) {
|
||||
cons_show_error("No API key set for provider '%s'. Use '/ai set token %s <key>' first.",
|
||||
provider_name, provider_name);
|
||||
g_free(api_key);
|
||||
return TRUE;
|
||||
}
|
||||
g_free(api_key);
|
||||
|
||||
// Create new AI session
|
||||
AISession* session = ai_session_create(provider_name, model);
|
||||
if (!session) {
|
||||
log_error("[AI-CMD] Failed to create AI session");
|
||||
cons_show_error("Failed to create AI session for %s/%s.", provider_name, model);
|
||||
return TRUE;
|
||||
}
|
||||
log_debug("[AI-CMD] AI session created successfully");
|
||||
|
||||
// Create AI chat window
|
||||
log_debug("[AI-CMD] Calling wins_new_ai()...");
|
||||
ProfWin* ai_win = wins_new_ai(session);
|
||||
if (!ai_win) {
|
||||
log_error("[AI-CMD] wins_new_ai() returned NULL");
|
||||
cons_show_error("Failed to create AI chat window.");
|
||||
ai_session_unref(session);
|
||||
return TRUE;
|
||||
}
|
||||
log_debug("[AI-CMD] wins_new_ai() returned successfully, window type: %d", ai_win->type);
|
||||
|
||||
// Add welcome message to the AI window
|
||||
log_debug("[AI-CMD] Adding welcome messages...");
|
||||
win_println(ai_win, THEME_DEFAULT, "-", "AI Chat: %s/%s", provider_name, model);
|
||||
win_println(ai_win, THEME_DEFAULT, "-", "Type your message and press Enter to start a conversation.");
|
||||
log_debug("[AI-CMD] Welcome messages added");
|
||||
|
||||
// Focus the new window
|
||||
log_debug("[AI-CMD] Calling ui_focus_win()...");
|
||||
ui_focus_win(ai_win);
|
||||
log_debug("[AI-CMD] ui_focus_win() returned");
|
||||
|
||||
cons_show("Started AI chat: %s/%s", provider_name, model);
|
||||
cons_show("");
|
||||
log_debug("[AI-CMD] AI start command completed");
|
||||
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
gboolean
|
||||
cmd_ai_clear(ProfWin* window, const char* const command, gchar** args)
|
||||
{
|
||||
// /ai clear
|
||||
ProfAiWin* aiwin = (window && window->type == WIN_AI) ? (ProfAiWin*)window : wins_get_ai();
|
||||
|
||||
if (aiwin) {
|
||||
assert(aiwin->memcheck == PROFAIWIN_MEMCHECK);
|
||||
if (aiwin->session) {
|
||||
ai_session_clear_history(aiwin->session);
|
||||
}
|
||||
cons_show("Chat history cleared.");
|
||||
} else {
|
||||
cons_show("No active AI chat window to clear.");
|
||||
}
|
||||
cons_show("");
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
gboolean
|
||||
cmd_ai_correct(ProfWin* window, const char* const command, gchar** args)
|
||||
{
|
||||
// /ai correct <message>
|
||||
// Join all arguments from args[1] onwards to support multi-word prompts
|
||||
auto_gchar gchar* prompt = g_strjoinv(" ", &args[1]);
|
||||
if (prompt == NULL || strlen(prompt) == 0) {
|
||||
cons_bad_cmd_usage(command);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
// Get current AI window
|
||||
ProfAiWin* aiwin = wins_get_ai();
|
||||
if (!aiwin) {
|
||||
cons_show("No active AI chat window. Use '/ai start <provider>/<model>' first.");
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
assert(aiwin->memcheck == PROFAIWIN_MEMCHECK);
|
||||
|
||||
if (!aiwin->session) {
|
||||
cons_show("No active session in this chat window.");
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
// Get the last user message and replace it
|
||||
GList* history = aiwin->session->history;
|
||||
GList* last_user_msg = NULL;
|
||||
for (GList* curr = history; curr; curr = g_list_next(curr)) {
|
||||
AIMessage* msg = curr->data;
|
||||
if (g_strcmp0(msg->role, "user") == 0) {
|
||||
last_user_msg = curr;
|
||||
}
|
||||
}
|
||||
|
||||
if (!last_user_msg) {
|
||||
cons_show("No user messages in this chat to correct.");
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
// Replace the last user message
|
||||
AIMessage* msg = last_user_msg->data;
|
||||
g_free(msg->content);
|
||||
msg->content = g_strdup(prompt);
|
||||
|
||||
// Resend the prompt
|
||||
cons_show("Correcting message...");
|
||||
ai_send_prompt(aiwin->session, prompt, aiwin);
|
||||
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
gboolean
|
||||
cmd_ai_providers(ProfWin* window, const char* const command, gchar** args)
|
||||
{
|
||||
if (args[1] == NULL || g_strcmp0(args[1], "list") != 0) {
|
||||
// List all available providers
|
||||
cons_show("Available AI providers:");
|
||||
cons_show("");
|
||||
cons_show(" openai - OpenAI API (https://api.openai.com)");
|
||||
cons_show(" perplexity - Perplexity API (https://api.perplexity.ai)");
|
||||
cons_show("");
|
||||
cons_show("Add custom providers with: /ai set provider <name> <url>");
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
// List configured providers with key status
|
||||
cons_show("Configured providers:");
|
||||
cons_show("");
|
||||
|
||||
GList* providers = ai_list_providers();
|
||||
for (GList* curr = providers; curr; curr = g_list_next(curr)) {
|
||||
AIProvider* provider = curr->data;
|
||||
gchar* key = ai_get_provider_key(provider->name);
|
||||
cons_show(" %s", provider->name);
|
||||
cons_show(" URL: %s", provider->api_url);
|
||||
cons_show(" Key: %s", key ? "configured" : "NOT configured");
|
||||
if (provider->org_id && strlen(provider->org_id) > 0) {
|
||||
cons_show(" Org: %s", provider->org_id);
|
||||
}
|
||||
cons_show("");
|
||||
g_free(key);
|
||||
}
|
||||
g_list_free(providers);
|
||||
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
gboolean
|
||||
cmd_force_encryption(ProfWin* window, const char* const command, gchar** args)
|
||||
{
|
||||
|
||||
@@ -166,13 +166,6 @@ gboolean cmd_console(ProfWin* window, const char* const command, gchar** args);
|
||||
gboolean cmd_command_list(ProfWin* window, const char* const command, gchar** args);
|
||||
gboolean cmd_command_exec(ProfWin* window, const char* const command, gchar** args);
|
||||
gboolean cmd_change_password(ProfWin* window, const char* const command, gchar** args);
|
||||
gboolean cmd_ai(ProfWin* window, const char* const command, gchar** args);
|
||||
gboolean cmd_ai_set(ProfWin* window, const char* const command, gchar** args);
|
||||
gboolean cmd_ai_remove(ProfWin* window, const char* const command, gchar** args);
|
||||
gboolean cmd_ai_start(ProfWin* window, const char* const command, gchar** args);
|
||||
gboolean cmd_ai_clear(ProfWin* window, const char* const command, gchar** args);
|
||||
gboolean cmd_ai_correct(ProfWin* window, const char* const command, gchar** args);
|
||||
gboolean cmd_ai_providers(ProfWin* window, const char* const command, gchar** args);
|
||||
|
||||
gboolean cmd_plugins(ProfWin* window, const char* const command, gchar** args);
|
||||
gboolean cmd_plugins_sourcepath(ProfWin* window, const char* const command, gchar** args);
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
#define DIR_OMEMO "omemo"
|
||||
#define DIR_PLUGINS "plugins"
|
||||
#define DIR_DATABASE "database"
|
||||
#define DIR_FLATLOG "flatlog"
|
||||
#define DIR_DOWNLOADS "downloads"
|
||||
#define DIR_EDITOR "editor"
|
||||
#define DIR_CERTS "certs"
|
||||
|
||||
@@ -66,7 +66,6 @@
|
||||
#define PREF_GROUP_MUC "muc"
|
||||
#define PREF_GROUP_PLUGINS "plugins"
|
||||
#define PREF_GROUP_EXECUTABLES "executables"
|
||||
#define PREF_GROUP_AI "ai"
|
||||
|
||||
#define INPBLOCK_DEFAULT 1000
|
||||
|
||||
@@ -1707,85 +1706,6 @@ _save_prefs(void)
|
||||
save_keyfile(&prefs_prof_keyfile);
|
||||
}
|
||||
|
||||
/* ========================================================================
|
||||
* AI Token Management
|
||||
* ======================================================================== */
|
||||
|
||||
gboolean
|
||||
prefs_ai_set_token(const char* const provider, const char* const token)
|
||||
{
|
||||
if (!provider)
|
||||
return FALSE;
|
||||
|
||||
if (!prefs)
|
||||
return FALSE;
|
||||
|
||||
if (token) {
|
||||
g_key_file_set_string(prefs, PREF_GROUP_AI, provider, token);
|
||||
_save_prefs();
|
||||
return TRUE;
|
||||
} else {
|
||||
return prefs_ai_remove_token(provider);
|
||||
}
|
||||
}
|
||||
|
||||
gboolean
|
||||
prefs_ai_remove_token(const char* const provider)
|
||||
{
|
||||
if (!provider)
|
||||
return FALSE;
|
||||
|
||||
if (!prefs)
|
||||
return FALSE;
|
||||
|
||||
if (!g_key_file_has_key(prefs, PREF_GROUP_AI, provider, NULL)) {
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
g_key_file_remove_key(prefs, PREF_GROUP_AI, provider, NULL);
|
||||
_save_prefs();
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
char*
|
||||
prefs_ai_get_token(const char* const provider)
|
||||
{
|
||||
if (!provider || !prefs)
|
||||
return NULL;
|
||||
|
||||
return g_key_file_get_string(prefs, PREF_GROUP_AI, provider, NULL);
|
||||
}
|
||||
|
||||
GList*
|
||||
prefs_ai_list_tokens(void)
|
||||
{
|
||||
if (!prefs || !g_key_file_has_group(prefs, PREF_GROUP_AI)) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
GList* result = NULL;
|
||||
gsize len;
|
||||
auto_gcharv gchar** keys = g_key_file_get_keys(prefs, PREF_GROUP_AI, &len, NULL);
|
||||
|
||||
for (gsize i = 0; i < len; i++) {
|
||||
char* name = keys[i];
|
||||
auto_gchar gchar* value = g_key_file_get_string(prefs, PREF_GROUP_AI, name, NULL);
|
||||
if (value) {
|
||||
result = g_list_append(result, g_strdup(name));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
void
|
||||
prefs_free_ai_tokens(GList* tokens)
|
||||
{
|
||||
if (tokens) {
|
||||
g_list_free_full(tokens, g_free);
|
||||
}
|
||||
}
|
||||
|
||||
// get the preference group for a specific preference
|
||||
// for example the PREF_BEEP setting ("beep" in .profrc, see _get_key) belongs
|
||||
// to the [ui] section.
|
||||
@@ -1945,9 +1865,6 @@ _get_group(preference_t pref)
|
||||
return PREF_GROUP_OMEMO;
|
||||
case PREF_OX_LOG:
|
||||
return PREF_GROUP_OX;
|
||||
case PREF_AI_PROVIDER:
|
||||
case PREF_AI_API_KEY:
|
||||
return PREF_GROUP_AI;
|
||||
default:
|
||||
return NULL;
|
||||
}
|
||||
@@ -2225,10 +2142,6 @@ _get_key(preference_t pref)
|
||||
return "stamp.incoming";
|
||||
case PREF_OX_LOG:
|
||||
return "log";
|
||||
case PREF_AI_PROVIDER:
|
||||
return "provider";
|
||||
case PREF_AI_API_KEY:
|
||||
return "api_key";
|
||||
case PREF_MOOD:
|
||||
return "mood";
|
||||
case PREF_VCARD_PHOTO_CMD:
|
||||
@@ -2406,10 +2319,6 @@ _get_default_string(preference_t pref)
|
||||
return "on";
|
||||
case PREF_FORCE_ENCRYPTION_MODE:
|
||||
return "resend-to-confirm";
|
||||
case PREF_AI_PROVIDER:
|
||||
return "openai";
|
||||
case PREF_AI_API_KEY:
|
||||
return "";
|
||||
default:
|
||||
return NULL;
|
||||
}
|
||||
|
||||
@@ -183,9 +183,6 @@ typedef enum {
|
||||
PREF_OX_LOG,
|
||||
PREF_MOOD,
|
||||
PREF_STROPHE_VERBOSITY,
|
||||
PREF_AI_PROVIDER,
|
||||
PREF_AI_API_KEY,
|
||||
PREF_AI_DEFAULT_MODEL,
|
||||
PREF_STROPHE_SM_ENABLED,
|
||||
PREF_STROPHE_SM_RESEND,
|
||||
PREF_VCARD_PHOTO_CMD,
|
||||
@@ -359,11 +356,4 @@ gboolean prefs_get_room_notify(const char* const roomjid);
|
||||
gboolean prefs_get_room_notify_mention(const char* const roomjid);
|
||||
gboolean prefs_get_room_notify_trigger(const char* const roomjid);
|
||||
|
||||
/* AI token management */
|
||||
gboolean prefs_ai_set_token(const char* const provider, const char* const token);
|
||||
gboolean prefs_ai_remove_token(const char* const provider);
|
||||
char* prefs_ai_get_token(const char* const provider);
|
||||
GList* prefs_ai_list_tokens(void);
|
||||
void prefs_free_ai_tokens(GList* tokens);
|
||||
|
||||
#endif
|
||||
|
||||
733
src/database.c
733
src/database.c
@@ -35,703 +35,164 @@
|
||||
|
||||
#include "config.h"
|
||||
|
||||
#include <sys/stat.h>
|
||||
#include <sys/statvfs.h>
|
||||
#include <sqlite3.h>
|
||||
#include <glib.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <errno.h>
|
||||
|
||||
#include "log.h"
|
||||
#include "common.h"
|
||||
#include "config/files.h"
|
||||
#include "config.h"
|
||||
#include "database.h"
|
||||
#include "config/preferences.h"
|
||||
#include "config/accounts.h"
|
||||
#include "ui/ui.h"
|
||||
#include "xmpp/xmpp.h"
|
||||
#include "xmpp/message.h"
|
||||
|
||||
static sqlite3* g_chatlog_database;
|
||||
db_backend_t* active_db_backend = NULL;
|
||||
|
||||
static void _add_to_db(ProfMessage* message, const char* type, const Jid* const from_jid, const Jid* const to_jid);
|
||||
static char* _get_db_filename(ProfAccount* account);
|
||||
static prof_msg_type_t _get_message_type_type(const char* const type);
|
||||
static prof_enc_t _get_message_enc_type(const char* const encstr);
|
||||
static int _get_db_version(void);
|
||||
static gboolean _migrate_to_v2(void);
|
||||
static gboolean _check_available_space_for_db_migration(char* path_to_db);
|
||||
|
||||
static const int latest_version = 2;
|
||||
|
||||
// Helper: close DB handle (if any), warn on busy, and shutdown SQLite
|
||||
static void
|
||||
_db_teardown(const char* ctx)
|
||||
void
|
||||
integrity_issue_free(integrity_issue_t* issue)
|
||||
{
|
||||
if (g_chatlog_database) {
|
||||
int rc = sqlite3_close_v2(g_chatlog_database);
|
||||
if (rc != SQLITE_OK) {
|
||||
log_warning("sqlite3_close_v2 in %s returned %d; database may still have active statements.",
|
||||
ctx ? ctx : "db_teardown", rc);
|
||||
}
|
||||
g_chatlog_database = NULL;
|
||||
if (issue) {
|
||||
g_free(issue->file);
|
||||
g_free(issue->message);
|
||||
g_free(issue);
|
||||
}
|
||||
// Safe to call unconditionally; no-op if not initialized.
|
||||
// See: https://www.sqlite.org/c3ref/initialize.html
|
||||
sqlite3_shutdown();
|
||||
}
|
||||
|
||||
// Helper: prepare a statement and log a contextual error on failure
|
||||
static gboolean
|
||||
_db_prepare_ctx(const char* query, sqlite3_stmt** stmt, const char* ctx)
|
||||
{
|
||||
int rc = sqlite3_prepare_v2(g_chatlog_database, query, -1, stmt, NULL);
|
||||
if (rc != SQLITE_OK) {
|
||||
log_error("SQLite error in %s: (error code: %d) %s",
|
||||
ctx ? ctx : "sqlite3_prepare_v2",
|
||||
rc,
|
||||
sqlite3_errmsg(g_chatlog_database));
|
||||
return FALSE;
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
static char*
|
||||
_db_strdup(const char* str)
|
||||
{
|
||||
return str ? strdup(str) : NULL;
|
||||
}
|
||||
|
||||
#define auto_sqlite __attribute__((__cleanup__(auto_free_sqlite)))
|
||||
|
||||
static void
|
||||
auto_free_sqlite(gchar** str)
|
||||
{
|
||||
if (str == NULL)
|
||||
return;
|
||||
sqlite3_free(*str);
|
||||
}
|
||||
|
||||
static char*
|
||||
_get_db_filename(ProfAccount* account)
|
||||
{
|
||||
return files_file_in_account_data_path(DIR_DATABASE, account->jid, "chatlog.db");
|
||||
}
|
||||
|
||||
gboolean
|
||||
log_database_init(ProfAccount* account)
|
||||
{
|
||||
int ret = sqlite3_initialize();
|
||||
if (ret != SQLITE_OK) {
|
||||
log_error("Error initializing SQLite database: %d", ret);
|
||||
return FALSE;
|
||||
}
|
||||
auto_gchar gchar* pref_dblog = prefs_get_string(PREF_DBLOG);
|
||||
|
||||
auto_char char* filename = _get_db_filename(account);
|
||||
if (!filename) {
|
||||
sqlite3_shutdown();
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
ret = sqlite3_open(filename, &g_chatlog_database);
|
||||
if (ret != SQLITE_OK) {
|
||||
const char* err_msg = g_chatlog_database ? sqlite3_errmsg(g_chatlog_database) : "(no handle)";
|
||||
log_error("Error opening SQLite database: %s", err_msg);
|
||||
_db_teardown("log_database_init(open)");
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
char* err_msg = NULL;
|
||||
|
||||
int db_version = _get_db_version();
|
||||
if (db_version == latest_version) {
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
// ChatLogs Table
|
||||
// Contains all chat messages
|
||||
//
|
||||
// id is primary key
|
||||
// from_jid is the sender's jid
|
||||
// to_jid is the receiver's jid
|
||||
// from_resource is the sender's resource
|
||||
// to_resource is the receiver's resource
|
||||
// message is the message's text
|
||||
// timestamp is the timestamp like "2020/03/24 11:12:14"
|
||||
// type is there to distinguish: message (chat), MUC message (muc), muc pm (mucpm)
|
||||
// stanza_id is the ID in <message>
|
||||
// archive_id is the stanza-id from from XEP-0359: Unique and Stable Stanza IDs used for XEP-0313: Message Archive Management
|
||||
// encryption is to distinguish: none, omemo, otr, pgp
|
||||
// marked_read is 0/1 whether a message has been marked as read via XEP-0333: Chat Markers
|
||||
// replace_id is the ID from XEP-0308: Last Message Correction
|
||||
// replaces_db_id is ID (primary key) of the original message that LMC message corrects/replaces
|
||||
// replaced_by_db_id is ID (primary key) of the last correcting (LMC) message for the original message
|
||||
const char* query = "CREATE TABLE IF NOT EXISTS `ChatLogs` ("
|
||||
"`id` INTEGER PRIMARY KEY AUTOINCREMENT, "
|
||||
"`from_jid` TEXT NOT NULL, "
|
||||
"`to_jid` TEXT NOT NULL, "
|
||||
"`from_resource` TEXT, "
|
||||
"`to_resource` TEXT, "
|
||||
"`message` TEXT, "
|
||||
"`timestamp` TEXT, "
|
||||
"`type` TEXT, "
|
||||
"`stanza_id` TEXT, "
|
||||
"`archive_id` TEXT, "
|
||||
"`encryption` TEXT, "
|
||||
"`marked_read` INTEGER, "
|
||||
"`replace_id` TEXT, "
|
||||
"`replaces_db_id` INTEGER, "
|
||||
"`replaced_by_db_id` INTEGER)";
|
||||
if (SQLITE_OK != sqlite3_exec(g_chatlog_database, query, NULL, 0, &err_msg)) {
|
||||
goto out;
|
||||
}
|
||||
|
||||
query = "CREATE TRIGGER IF NOT EXISTS update_corrected_message "
|
||||
"AFTER INSERT ON ChatLogs "
|
||||
"FOR EACH ROW "
|
||||
"WHEN NEW.replaces_db_id IS NOT NULL "
|
||||
"BEGIN "
|
||||
"UPDATE ChatLogs "
|
||||
"SET replaced_by_db_id = NEW.id "
|
||||
"WHERE id = NEW.replaces_db_id; "
|
||||
"END;";
|
||||
if (SQLITE_OK != sqlite3_exec(g_chatlog_database, query, NULL, 0, &err_msg)) {
|
||||
log_error("Unable to add `update_corrected_message` trigger.");
|
||||
goto out;
|
||||
}
|
||||
|
||||
query = "CREATE INDEX IF NOT EXISTS ChatLogs_timestamp_IDX ON `ChatLogs` (`timestamp`)";
|
||||
if (SQLITE_OK != sqlite3_exec(g_chatlog_database, query, NULL, 0, &err_msg)) {
|
||||
log_error("Unable to create index for timestamp.");
|
||||
goto out;
|
||||
}
|
||||
query = "CREATE INDEX IF NOT EXISTS ChatLogs_to_from_jid_IDX ON `ChatLogs` (`to_jid`, `from_jid`)";
|
||||
if (SQLITE_OK != sqlite3_exec(g_chatlog_database, query, NULL, 0, &err_msg)) {
|
||||
log_error("Unable to create index for to_jid.");
|
||||
goto out;
|
||||
}
|
||||
|
||||
query = "CREATE TABLE IF NOT EXISTS `DbVersion` (`dv_id` INTEGER PRIMARY KEY, `version` INTEGER UNIQUE)";
|
||||
if (SQLITE_OK != sqlite3_exec(g_chatlog_database, query, NULL, 0, &err_msg)) {
|
||||
goto out;
|
||||
}
|
||||
|
||||
if (db_version == -1) {
|
||||
query = "INSERT OR IGNORE INTO `DbVersion` (`version`) VALUES ('2')";
|
||||
if (SQLITE_OK != sqlite3_exec(g_chatlog_database, query, NULL, 0, &err_msg)) {
|
||||
goto out;
|
||||
}
|
||||
db_version = _get_db_version();
|
||||
}
|
||||
|
||||
// Unlikely event, but we don't want to migrate if we are just unable to determine the DB version
|
||||
if (db_version == -1) {
|
||||
cons_show_error("DB Initialization Error: Unable to check DB version.");
|
||||
goto out;
|
||||
}
|
||||
|
||||
if (db_version < latest_version) {
|
||||
cons_show("Migrating database schema. This operation may take a while...");
|
||||
if (db_version < 2 && (!_check_available_space_for_db_migration(filename) || !_migrate_to_v2())) {
|
||||
cons_show_error("Database Initialization Error: Unable to migrate database to version 2. Please, check error logs for details.");
|
||||
goto out;
|
||||
}
|
||||
cons_show("Database schema migration was successful.");
|
||||
}
|
||||
|
||||
log_debug("Initialized SQLite database: %s", filename);
|
||||
return TRUE;
|
||||
|
||||
out:
|
||||
if (err_msg) {
|
||||
log_error("SQLite error in log_database_init(): %s", err_msg);
|
||||
sqlite3_free(err_msg);
|
||||
// Select backend based on preference
|
||||
if (g_strcmp0(pref_dblog, "flatfile") == 0) {
|
||||
active_db_backend = db_backend_flatfile();
|
||||
log_info("Using flat-file database backend");
|
||||
} else {
|
||||
log_error("Unknown SQLite error in log_database_init().");
|
||||
#ifdef HAVE_SQLITE
|
||||
active_db_backend = db_backend_sqlite();
|
||||
log_info("Using SQLite database backend");
|
||||
#else
|
||||
log_info("SQLite not compiled in, falling back to flat-file backend");
|
||||
active_db_backend = db_backend_flatfile();
|
||||
#endif
|
||||
}
|
||||
_db_teardown("log_database_init(out)");
|
||||
return FALSE;
|
||||
|
||||
if (!active_db_backend) {
|
||||
log_error("log_database_init: no backend available");
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
return active_db_backend->init(account);
|
||||
}
|
||||
|
||||
void
|
||||
log_database_close(void)
|
||||
{
|
||||
log_debug("log_database_close() called");
|
||||
_db_teardown("log_database_close");
|
||||
if (active_db_backend) {
|
||||
active_db_backend->close();
|
||||
}
|
||||
active_db_backend = NULL;
|
||||
}
|
||||
|
||||
gboolean
|
||||
log_database_switch_backend(const char* new_backend)
|
||||
{
|
||||
// Close current backend
|
||||
log_database_close();
|
||||
|
||||
// Update preference
|
||||
prefs_set_string(PREF_DBLOG, new_backend);
|
||||
prefs_save();
|
||||
|
||||
// Get current account to reinitialize
|
||||
const char* account_name = session_get_account_name();
|
||||
if (!account_name) {
|
||||
log_error("log_database_switch_backend: no active session");
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
ProfAccount* account = accounts_get_account(account_name);
|
||||
if (!account) {
|
||||
log_error("log_database_switch_backend: account '%s' not found", account_name);
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
gboolean result = log_database_init(account);
|
||||
account_free(account);
|
||||
return result;
|
||||
}
|
||||
|
||||
void
|
||||
log_database_add_incoming(ProfMessage* message)
|
||||
{
|
||||
if (message->to_jid) {
|
||||
_add_to_db(message, NULL, message->from_jid, message->to_jid);
|
||||
} else {
|
||||
_add_to_db(message, NULL, message->from_jid, connection_get_jid());
|
||||
if (active_db_backend && active_db_backend->add_incoming) {
|
||||
active_db_backend->add_incoming(message);
|
||||
}
|
||||
}
|
||||
|
||||
static void
|
||||
_log_database_add_outgoing(const char* type, const char* const id, const char* const barejid, const char* const message, const char* const replace_id, prof_enc_t enc)
|
||||
{
|
||||
ProfMessage* msg = message_init();
|
||||
|
||||
msg->id = _db_strdup(id);
|
||||
msg->from_jid = jid_create(barejid);
|
||||
msg->plain = _db_strdup(message);
|
||||
msg->replace_id = _db_strdup(replace_id);
|
||||
msg->timestamp = g_date_time_new_now_local(); // TODO: get from outside. best to have whole ProfMessage from outside
|
||||
msg->enc = enc;
|
||||
|
||||
_add_to_db(msg, type, connection_get_jid(), msg->from_jid); // TODO: myjid now in profmessage
|
||||
|
||||
message_free(msg);
|
||||
}
|
||||
|
||||
void
|
||||
log_database_add_outgoing_chat(const char* const id, const char* const barejid, const char* const message, const char* const replace_id, prof_enc_t enc)
|
||||
{
|
||||
_log_database_add_outgoing("chat", id, barejid, message, replace_id, enc);
|
||||
if (active_db_backend && active_db_backend->add_outgoing_chat) {
|
||||
active_db_backend->add_outgoing_chat(id, barejid, message, replace_id, enc);
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
log_database_add_outgoing_muc(const char* const id, const char* const barejid, const char* const message, const char* const replace_id, prof_enc_t enc)
|
||||
{
|
||||
_log_database_add_outgoing("muc", id, barejid, message, replace_id, enc);
|
||||
if (active_db_backend && active_db_backend->add_outgoing_muc) {
|
||||
active_db_backend->add_outgoing_muc(id, barejid, message, replace_id, enc);
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
log_database_add_outgoing_muc_pm(const char* const id, const char* const barejid, const char* const message, const char* const replace_id, prof_enc_t enc)
|
||||
{
|
||||
_log_database_add_outgoing("mucpm", id, barejid, message, replace_id, enc);
|
||||
if (active_db_backend && active_db_backend->add_outgoing_muc_pm) {
|
||||
active_db_backend->add_outgoing_muc_pm(id, barejid, message, replace_id, enc);
|
||||
}
|
||||
}
|
||||
|
||||
// Get info (timestamp and stanza_id) of the first or last message in db
|
||||
ProfMessage*
|
||||
log_database_get_limits_info(const gchar* const contact_barejid, gboolean is_last)
|
||||
{
|
||||
sqlite3_stmt* stmt = NULL;
|
||||
const Jid* myjid = connection_get_jid();
|
||||
// Always return a valid ProfMessage to avoid NULL dereferences in callers
|
||||
ProfMessage* msg = message_init();
|
||||
if (!myjid || !myjid->str) {
|
||||
// If caller requested the last message and we have no context, fall back to now
|
||||
if (is_last) {
|
||||
msg->timestamp = g_date_time_new_now_utc();
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
const char* order = is_last ? "DESC" : "ASC";
|
||||
auto_sqlite char* query = sqlite3_mprintf("SELECT `archive_id`, `timestamp` FROM `ChatLogs` WHERE "
|
||||
"(`from_jid` = %Q AND `to_jid` = %Q) OR "
|
||||
"(`from_jid` = %Q AND `to_jid` = %Q) "
|
||||
"ORDER BY `timestamp` %s LIMIT 1;",
|
||||
contact_barejid, myjid->barejid, myjid->barejid, contact_barejid, order);
|
||||
|
||||
if (!query) {
|
||||
log_error("Could not allocate memory for SQL query in log_database_get_limits_info()");
|
||||
if (is_last) {
|
||||
msg->timestamp = g_date_time_new_now_utc();
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
if (!_db_prepare_ctx(query, &stmt, "log_database_get_limits_info()")) {
|
||||
if (is_last) {
|
||||
msg->timestamp = g_date_time_new_now_utc();
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
if (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
char* archive_id = (char*)sqlite3_column_text(stmt, 0);
|
||||
char* date = (char*)sqlite3_column_text(stmt, 1);
|
||||
|
||||
msg->stanzaid = _db_strdup(archive_id);
|
||||
msg->timestamp = g_date_time_new_from_iso8601(date, NULL);
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
// If nothing was found and caller expects the last message, provide a sane default
|
||||
if (!msg->timestamp && is_last) {
|
||||
msg->timestamp = g_date_time_new_now_utc();
|
||||
}
|
||||
|
||||
return msg;
|
||||
}
|
||||
|
||||
// Query previous chats, constraints start_time and end_time. If end_time is
|
||||
// null the current time is used. from_start gets first few messages if true
|
||||
// otherwise the last ones. Flip flips the order of the results
|
||||
db_history_result_t
|
||||
log_database_get_previous_chat(const gchar* const contact_barejid, const gchar* start_time, const gchar* end_time, gboolean from_start, gboolean flip, GSList** result)
|
||||
{
|
||||
if (!g_chatlog_database) {
|
||||
log_warning("log_database_get_previous_chat() called but db is not initialized");
|
||||
return DB_RESPONSE_ERROR;
|
||||
if (active_db_backend && active_db_backend->get_previous_chat) {
|
||||
return active_db_backend->get_previous_chat(contact_barejid, start_time, end_time, from_start, flip, result);
|
||||
}
|
||||
sqlite3_stmt* stmt = NULL;
|
||||
const Jid* myjid = connection_get_jid();
|
||||
if (!myjid->str) {
|
||||
log_warning("log_database_get_previous_chat() called but no connection detected.");
|
||||
return DB_RESPONSE_ERROR;
|
||||
}
|
||||
|
||||
// Flip order when querying older pages
|
||||
const gchar* sort1 = from_start ? "ASC" : "DESC";
|
||||
const gchar* sort2 = !flip ? "ASC" : "DESC";
|
||||
GDateTime* now = g_date_time_new_now_local();
|
||||
auto_gchar gchar* end_date_fmt = end_time ? g_strdup(end_time) : g_date_time_format_iso8601(now);
|
||||
auto_sqlite gchar* query = sqlite3_mprintf("SELECT * FROM ("
|
||||
"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` 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)) "
|
||||
"AND A.`timestamp` < %Q "
|
||||
"AND (%Q IS NULL OR A.`timestamp` > %Q) "
|
||||
"ORDER BY A.`timestamp` %s LIMIT %d) "
|
||||
"ORDER BY `timestamp` %s;",
|
||||
contact_barejid, myjid->barejid, myjid->barejid, contact_barejid, end_date_fmt, start_time, start_time, sort1, MESSAGES_TO_RETRIEVE, sort2);
|
||||
|
||||
g_date_time_unref(now);
|
||||
|
||||
if (!query) {
|
||||
log_error("Could not allocate memory.");
|
||||
return DB_RESPONSE_ERROR;
|
||||
}
|
||||
|
||||
if (!_db_prepare_ctx(query, &stmt, "log_database_get_previous_chat()")) {
|
||||
return DB_RESPONSE_ERROR;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
ProfMessage* msg = message_init();
|
||||
msg->id = id ? strdup(id) : NULL;
|
||||
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 = strdup(message ?: "");
|
||||
msg->timestamp = g_date_time_new_from_iso8601(date, NULL);
|
||||
msg->type = _get_message_type_type(type);
|
||||
msg->enc = _get_message_enc_type(encryption);
|
||||
|
||||
*result = g_slist_append(*result, msg);
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
return g_slist_length(*result) != 0 ? DB_RESPONSE_SUCCESS : DB_RESPONSE_EMPTY;
|
||||
return DB_RESPONSE_ERROR;
|
||||
}
|
||||
|
||||
static const char*
|
||||
_get_message_type_str(prof_msg_type_t type)
|
||||
ProfMessage*
|
||||
log_database_get_limits_info(const gchar* const contact_barejid, gboolean is_last)
|
||||
{
|
||||
switch (type) {
|
||||
case PROF_MSG_TYPE_CHAT:
|
||||
return "chat";
|
||||
case PROF_MSG_TYPE_MUC:
|
||||
return "muc";
|
||||
case PROF_MSG_TYPE_MUCPM:
|
||||
return "mucpm";
|
||||
case PROF_MSG_TYPE_UNINITIALIZED:
|
||||
return NULL;
|
||||
if (active_db_backend && active_db_backend->get_limits_info) {
|
||||
return active_db_backend->get_limits_info(contact_barejid, is_last);
|
||||
}
|
||||
return NULL;
|
||||
// Fallback: return an empty message with sane defaults
|
||||
ProfMessage* msg = message_init();
|
||||
if (is_last) {
|
||||
msg->timestamp = g_date_time_new_now_utc();
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
static prof_msg_type_t
|
||||
_get_message_type_type(const char* const type)
|
||||
GSList*
|
||||
log_database_verify_integrity(const gchar* const contact_barejid)
|
||||
{
|
||||
if (g_strcmp0(type, "chat") == 0) {
|
||||
return PROF_MSG_TYPE_CHAT;
|
||||
} else if (g_strcmp0(type, "muc") == 0) {
|
||||
return PROF_MSG_TYPE_MUC;
|
||||
} else if (g_strcmp0(type, "mucpm") == 0) {
|
||||
return PROF_MSG_TYPE_MUCPM;
|
||||
} else {
|
||||
return PROF_MSG_TYPE_UNINITIALIZED;
|
||||
}
|
||||
}
|
||||
|
||||
static const char*
|
||||
_get_message_enc_str(prof_enc_t enc)
|
||||
{
|
||||
switch (enc) {
|
||||
case PROF_MSG_ENC_OX:
|
||||
return "ox";
|
||||
case PROF_MSG_ENC_PGP:
|
||||
return "pgp";
|
||||
case PROF_MSG_ENC_OTR:
|
||||
return "otr";
|
||||
case PROF_MSG_ENC_OMEMO:
|
||||
return "omemo";
|
||||
case PROF_MSG_ENC_NONE:
|
||||
return "none";
|
||||
}
|
||||
|
||||
return "none";
|
||||
}
|
||||
|
||||
static prof_enc_t
|
||||
_get_message_enc_type(const char* const encstr)
|
||||
{
|
||||
if (g_strcmp0(encstr, "ox") == 0) {
|
||||
return PROF_MSG_ENC_OX;
|
||||
} else if (g_strcmp0(encstr, "pgp") == 0) {
|
||||
return PROF_MSG_ENC_PGP;
|
||||
} else if (g_strcmp0(encstr, "otr") == 0) {
|
||||
return PROF_MSG_ENC_OTR;
|
||||
} else if (g_strcmp0(encstr, "omemo") == 0) {
|
||||
return PROF_MSG_ENC_OMEMO;
|
||||
}
|
||||
|
||||
return PROF_MSG_ENC_NONE;
|
||||
}
|
||||
|
||||
static void
|
||||
_add_to_db(ProfMessage* message, const char* type, const Jid* const from_jid, const Jid* const to_jid)
|
||||
{
|
||||
auto_gchar gchar* pref_dblog = prefs_get_string(PREF_DBLOG);
|
||||
sqlite_int64 original_message_id = -1;
|
||||
|
||||
if (g_strcmp0(pref_dblog, "off") == 0) {
|
||||
return;
|
||||
} else if (g_strcmp0(pref_dblog, "redact") == 0) {
|
||||
if (message->plain) {
|
||||
free(message->plain);
|
||||
}
|
||||
message->plain = strdup("[REDACTED]");
|
||||
}
|
||||
|
||||
if (!g_chatlog_database) {
|
||||
log_debug("log_database_add() called but db is not initialized");
|
||||
return;
|
||||
}
|
||||
|
||||
char* err_msg;
|
||||
auto_gchar gchar* date_fmt = NULL;
|
||||
|
||||
if (message->timestamp) {
|
||||
date_fmt = g_date_time_format_iso8601(message->timestamp);
|
||||
} else {
|
||||
GDateTime* dt = g_date_time_new_now_local();
|
||||
date_fmt = g_date_time_format_iso8601(dt);
|
||||
g_date_time_unref(dt);
|
||||
}
|
||||
|
||||
const char* enc = _get_message_enc_str(message->enc);
|
||||
|
||||
if (!type) {
|
||||
type = (char*)_get_message_type_str(message->type);
|
||||
}
|
||||
|
||||
// Apply LMC and check its validity (XEP-0308)
|
||||
if (message->replace_id) {
|
||||
auto_sqlite char* replace_check_query = sqlite3_mprintf("SELECT `id`, `from_jid`, `replaces_db_id` FROM `ChatLogs` WHERE `stanza_id` = %Q ORDER BY `timestamp` DESC LIMIT 1",
|
||||
message->replace_id);
|
||||
|
||||
if (!replace_check_query) {
|
||||
log_error("Could not allocate memory for SQL replace query in log_database_add()");
|
||||
return;
|
||||
}
|
||||
|
||||
sqlite3_stmt* lmc_stmt = NULL;
|
||||
if (!_db_prepare_ctx(replace_check_query, &lmc_stmt, "_add_to_db(replace_check)")) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sqlite3_step(lmc_stmt) == SQLITE_ROW) {
|
||||
original_message_id = sqlite3_column_int64(lmc_stmt, 0);
|
||||
const char* from_jid_orig = (const char*)sqlite3_column_text(lmc_stmt, 1);
|
||||
|
||||
// Handle non-XEP-compliant replacement messages (edit->edit->original)
|
||||
sqlite_int64 tmp = sqlite3_column_int64(lmc_stmt, 2);
|
||||
original_message_id = tmp ? tmp : original_message_id;
|
||||
|
||||
if (g_strcmp0(from_jid_orig, from_jid->barejid) != 0) {
|
||||
log_error("Mismatch in sender JIDs when trying to do LMC. Corrected message sender: %s. Original message sender: %s. Replace-ID: %s. Message: %s", from_jid->barejid, from_jid_orig, message->replace_id, message->plain);
|
||||
cons_show_error("%s sent a message correction with mismatched sender. See log for details.", from_jid->barejid);
|
||||
sqlite3_finalize(lmc_stmt);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
log_warning("Got LMC message that does not have original message counterpart in the database from %s", message->from_jid->fulljid);
|
||||
}
|
||||
sqlite3_finalize(lmc_stmt);
|
||||
}
|
||||
|
||||
// stanza-id (XEP-0359) doesn't have to be present in the message.
|
||||
// But if it's duplicated, it's a serious server-side problem, so we better track it.
|
||||
// Unless it's MAM, in that case it's expected behaviour.
|
||||
if (message->stanzaid && !message->is_mam) {
|
||||
auto_sqlite char* duplicate_check_query = sqlite3_mprintf("SELECT 1 FROM `ChatLogs` WHERE (`archive_id` = %Q)",
|
||||
message->stanzaid);
|
||||
|
||||
if (!duplicate_check_query) {
|
||||
log_error("Could not allocate memory for SQL duplicate query in log_database_add()");
|
||||
return;
|
||||
}
|
||||
|
||||
sqlite3_stmt* stmt;
|
||||
if (_db_prepare_ctx(duplicate_check_query, &stmt, "_add_to_db(duplicate_check)")) {
|
||||
if (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
log_error("Duplicate stanza-id found for the message. stanza_id: %s; archive_id: %s; sender: %s; content: %s", message->id, message->stanzaid, from_jid->barejid, message->plain);
|
||||
cons_show_error("Got a message with duplicate (server-generated) stanza-id from %s.", from_jid->fulljid);
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
}
|
||||
}
|
||||
|
||||
auto_sqlite char* orig_message_id = original_message_id == -1 ? NULL : sqlite3_mprintf("%d", original_message_id);
|
||||
|
||||
auto_sqlite char* query = sqlite3_mprintf("INSERT INTO `ChatLogs` "
|
||||
"(`from_jid`, `from_resource`, `to_jid`, `to_resource`, "
|
||||
"`message`, `timestamp`, `stanza_id`, `archive_id`, "
|
||||
"`replaces_db_id`, `replace_id`, `type`, `encryption`) "
|
||||
"VALUES (%Q, %Q, %Q, %Q, %Q, %Q, %Q, %Q, %Q, %Q, %Q, %Q)",
|
||||
from_jid->barejid,
|
||||
from_jid->resourcepart,
|
||||
to_jid->barejid,
|
||||
to_jid->resourcepart,
|
||||
message->plain,
|
||||
date_fmt,
|
||||
message->id,
|
||||
message->stanzaid,
|
||||
orig_message_id,
|
||||
message->replace_id,
|
||||
type,
|
||||
enc);
|
||||
if (!query) {
|
||||
log_error("Could not allocate memory for SQL insert query in log_database_add()");
|
||||
return;
|
||||
}
|
||||
|
||||
log_debug("Writing to DB. Query: %s", query);
|
||||
|
||||
if (SQLITE_OK != sqlite3_exec(g_chatlog_database, query, NULL, 0, &err_msg)) {
|
||||
if (err_msg) {
|
||||
log_error("SQLite error in _add_to_db(): %s", err_msg);
|
||||
sqlite3_free(err_msg);
|
||||
} else {
|
||||
log_error("Unknown SQLite error in _add_to_db().");
|
||||
}
|
||||
} else {
|
||||
int inserted_rows_count = sqlite3_changes(g_chatlog_database);
|
||||
if (inserted_rows_count < 1) {
|
||||
log_error("SQLite did not insert message (rows: %d, id: %s, content: %s)", inserted_rows_count, message->id, message->plain);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static int
|
||||
_get_db_version(void)
|
||||
{
|
||||
int current_version = -1;
|
||||
const char* query = "SELECT `version` FROM `DbVersion` LIMIT 1";
|
||||
sqlite3_stmt* statement;
|
||||
if (_db_prepare_ctx(query, &statement, "_get_db_version()")) {
|
||||
if (sqlite3_step(statement) == SQLITE_ROW) {
|
||||
current_version = sqlite3_column_int(statement, 0);
|
||||
}
|
||||
sqlite3_finalize(statement);
|
||||
}
|
||||
return current_version;
|
||||
}
|
||||
|
||||
/**
|
||||
* Migration to version 2 introduces new columns. Returns TRUE on success.
|
||||
*
|
||||
* New columns:
|
||||
* `replaces_db_id` database ID for correcting message of the original message
|
||||
* `replaced_by_db_id` database ID for original message of the last correcting message
|
||||
*/
|
||||
static gboolean
|
||||
_migrate_to_v2(void)
|
||||
{
|
||||
char* err_msg = NULL;
|
||||
|
||||
// from_resource, to_resource, message, timestamp, stanza_id, archive_id, replace_id, type, encryption
|
||||
const char* sql_statements[] = {
|
||||
"BEGIN TRANSACTION",
|
||||
"ALTER TABLE `ChatLogs` ADD COLUMN `replaces_db_id` INTEGER;",
|
||||
"ALTER TABLE `ChatLogs` ADD COLUMN `replaced_by_db_id` INTEGER;",
|
||||
"UPDATE `ChatLogs` AS A "
|
||||
"SET `replaces_db_id` = B.`id` "
|
||||
"FROM `ChatLogs` AS B "
|
||||
"WHERE A.`replace_id` IS NOT NULL AND A.`replace_id` != '' "
|
||||
"AND A.`replace_id` = B.`stanza_id` "
|
||||
"AND A.`from_jid` = B.`from_jid` AND A.`to_jid` = B.`to_jid`;",
|
||||
"UPDATE `ChatLogs` AS A "
|
||||
"SET `replaced_by_db_id` = B.`id` "
|
||||
"FROM `ChatLogs` AS B "
|
||||
"WHERE (A.`replace_id` IS NULL OR A.`replace_id` = '') "
|
||||
"AND A.`id` = B.`replaces_db_id` "
|
||||
"AND A.`from_jid` = B.`from_jid`;",
|
||||
"UPDATE ChatLogs SET "
|
||||
"from_resource = COALESCE(NULLIF(from_resource, ''), NULL), "
|
||||
"to_resource = COALESCE(NULLIF(to_resource, ''), NULL), "
|
||||
"message = COALESCE(NULLIF(message, ''), NULL), "
|
||||
"timestamp = COALESCE(NULLIF(timestamp, ''), NULL), "
|
||||
"stanza_id = COALESCE(NULLIF(stanza_id, ''), NULL), "
|
||||
"archive_id = COALESCE(NULLIF(archive_id, ''), NULL), "
|
||||
"replace_id = COALESCE(NULLIF(replace_id, ''), NULL), "
|
||||
"type = COALESCE(NULLIF(type, ''), NULL), "
|
||||
"encryption = COALESCE(NULLIF(encryption, ''), NULL);",
|
||||
"UPDATE `DbVersion` SET `version` = 2;",
|
||||
"END TRANSACTION"
|
||||
};
|
||||
|
||||
int statements_count = sizeof(sql_statements) / sizeof(sql_statements[0]);
|
||||
|
||||
for (int i = 0; i < statements_count; i++) {
|
||||
if (SQLITE_OK != sqlite3_exec(g_chatlog_database, sql_statements[i], NULL, 0, &err_msg)) {
|
||||
log_error("SQLite error in _migrate_to_v2() on statement %d: %s", i, err_msg);
|
||||
if (err_msg) {
|
||||
sqlite3_free(err_msg);
|
||||
err_msg = NULL;
|
||||
}
|
||||
goto cleanup;
|
||||
}
|
||||
}
|
||||
|
||||
return TRUE;
|
||||
|
||||
cleanup:
|
||||
if (SQLITE_OK != sqlite3_exec(g_chatlog_database, "ROLLBACK;", NULL, 0, &err_msg)) {
|
||||
log_error("[DB Migration] Unable to ROLLBACK: %s", err_msg);
|
||||
if (err_msg) {
|
||||
sqlite3_free(err_msg);
|
||||
}
|
||||
}
|
||||
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
// Checks if there is more system storage space available than current database takes + 40% (for indexing and other potential size increases)
|
||||
static gboolean
|
||||
_check_available_space_for_db_migration(char* path_to_db)
|
||||
{
|
||||
struct stat file_stat;
|
||||
struct statvfs fs_stat;
|
||||
|
||||
if (statvfs(path_to_db, &fs_stat) == 0 && stat(path_to_db, &file_stat) == 0) {
|
||||
unsigned long long file_size = file_stat.st_size / 1024;
|
||||
unsigned long long available_space_kb = fs_stat.f_frsize * fs_stat.f_bavail / 1024;
|
||||
log_debug("_check_available_space_for_db_migration(): Available space on disk: %llu KB; DB size: %llu KB", available_space_kb, file_size);
|
||||
|
||||
return (available_space_kb >= (file_size + (file_size * 10 / 4)));
|
||||
} else {
|
||||
log_error("Error checking available space.");
|
||||
return FALSE;
|
||||
if (active_db_backend && active_db_backend->verify_integrity) {
|
||||
return active_db_backend->verify_integrity(contact_barejid);
|
||||
}
|
||||
|
||||
GSList* issues = NULL;
|
||||
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
|
||||
issue->level = INTEGRITY_WARNING;
|
||||
issue->file = g_strdup("N/A");
|
||||
issue->line = 0;
|
||||
issue->message = g_strdup("Active backend does not support integrity verification");
|
||||
issues = g_slist_append(issues, issue);
|
||||
return issues;
|
||||
}
|
||||
|
||||
@@ -48,7 +48,57 @@ typedef enum {
|
||||
DB_RESPONSE_SUCCESS
|
||||
} db_history_result_t;
|
||||
|
||||
// Integrity verification issue levels
|
||||
typedef enum {
|
||||
INTEGRITY_ERROR,
|
||||
INTEGRITY_WARNING,
|
||||
INTEGRITY_INFO
|
||||
} integrity_level_t;
|
||||
|
||||
// A single integrity issue found during verification
|
||||
typedef struct
|
||||
{
|
||||
integrity_level_t level;
|
||||
char* file;
|
||||
int line;
|
||||
char* message;
|
||||
} integrity_issue_t;
|
||||
|
||||
void integrity_issue_free(integrity_issue_t* issue);
|
||||
|
||||
// Backend vtable: pluggable storage backends implement this interface
|
||||
typedef struct db_backend_t
|
||||
{
|
||||
const char* name;
|
||||
gboolean (*init)(ProfAccount* account);
|
||||
void (*close)(void);
|
||||
void (*add_incoming)(ProfMessage* message);
|
||||
void (*add_outgoing_chat)(const char* const id, const char* const barejid, const char* const message, const char* const replace_id, prof_enc_t enc);
|
||||
void (*add_outgoing_muc)(const char* const id, const char* const barejid, const char* const message, const char* const replace_id, prof_enc_t enc);
|
||||
void (*add_outgoing_muc_pm)(const char* const id, const char* const barejid, const char* const message, const char* const replace_id, prof_enc_t enc);
|
||||
db_history_result_t (*get_previous_chat)(const gchar* const contact_barejid, const gchar* start_time, const gchar* end_time, gboolean from_start, gboolean flip, GSList** result);
|
||||
ProfMessage* (*get_limits_info)(const gchar* const contact_barejid, gboolean is_last);
|
||||
GSList* (*verify_integrity)(const gchar* const contact_barejid);
|
||||
} db_backend_t;
|
||||
|
||||
// Active backend (set during init based on PREF_DBLOG)
|
||||
extern db_backend_t* active_db_backend;
|
||||
|
||||
// Backend registry
|
||||
#ifdef HAVE_SQLITE
|
||||
db_backend_t* db_backend_sqlite(void);
|
||||
GSList* db_sqlite_list_contacts(void);
|
||||
GSList* db_sqlite_get_all_chat(const gchar* const contact_barejid);
|
||||
void db_sqlite_begin_transaction(void);
|
||||
void db_sqlite_end_transaction(void);
|
||||
void db_sqlite_rollback_transaction(void);
|
||||
int db_sqlite_last_changes(void);
|
||||
#endif
|
||||
db_backend_t* db_backend_flatfile(void);
|
||||
|
||||
// Public API (dispatches to active_db_backend)
|
||||
gboolean log_database_init(ProfAccount* account);
|
||||
gboolean log_database_switch_backend(const char* new_backend);
|
||||
void log_database_add_incoming(ProfMessage* message);
|
||||
void log_database_add_outgoing_chat(const char* const id, const char* const barejid, const char* const message, const char* const replace_id, prof_enc_t enc);
|
||||
void log_database_add_outgoing_muc(const char* const id, const char* const barejid, const char* const message, const char* const replace_id, prof_enc_t enc);
|
||||
@@ -56,5 +106,12 @@ void log_database_add_outgoing_muc_pm(const char* const id, const char* const ba
|
||||
db_history_result_t log_database_get_previous_chat(const gchar* const contact_barejid, const gchar* start_time, const gchar* end_time, gboolean from_start, gboolean flip, GSList** result);
|
||||
ProfMessage* log_database_get_limits_info(const gchar* const contact_barejid, gboolean is_last);
|
||||
void log_database_close(void);
|
||||
GSList* log_database_verify_integrity(const gchar* const contact_barejid);
|
||||
|
||||
// Cross-backend export/import (requires HAVE_SQLITE)
|
||||
#ifdef HAVE_SQLITE
|
||||
int log_database_export_to_flatfile(const gchar* const contact_jid);
|
||||
int log_database_import_from_flatfile(const gchar* const contact_jid);
|
||||
#endif
|
||||
|
||||
#endif // DATABASE_H
|
||||
|
||||
559
src/database_export.c
Normal file
559
src/database_export.c
Normal file
@@ -0,0 +1,559 @@
|
||||
/*
|
||||
* database_export.c
|
||||
* vim: expandtab:ts=4:sts=4:sw=4
|
||||
*
|
||||
* Copyright (C) 2026 Profanity Contributors
|
||||
*
|
||||
* This file is part of Profanity.
|
||||
*
|
||||
* Profanity is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Profanity is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Profanity. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* Cross-backend export/import for message history.
|
||||
* Reads from one backend, writes to the other, with merge + dedup.
|
||||
*/
|
||||
|
||||
#include "config.h"
|
||||
|
||||
#include <glib.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <errno.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/file.h>
|
||||
|
||||
#ifdef HAVE_SQLITE
|
||||
#include <sqlite3.h>
|
||||
#endif
|
||||
|
||||
#include "log.h"
|
||||
#include "common.h"
|
||||
#include "config/files.h"
|
||||
#include "database.h"
|
||||
#include "database_flatfile.h"
|
||||
#include "config/preferences.h"
|
||||
#include "ui/ui.h"
|
||||
#include "xmpp/xmpp.h"
|
||||
#include "xmpp/message.h"
|
||||
|
||||
// =========================================================================
|
||||
// Everything below is only used when HAVE_SQLITE is defined
|
||||
// =========================================================================
|
||||
|
||||
#ifdef HAVE_SQLITE
|
||||
|
||||
// =========================================================================
|
||||
// Dedup key: stanza_id if present, else hash of timestamp+from+body
|
||||
// =========================================================================
|
||||
|
||||
static char*
|
||||
_make_dedup_key(const char* stanza_id, const char* timestamp, const char* from_jid, const char* body)
|
||||
{
|
||||
if (stanza_id && strlen(stanza_id) > 0)
|
||||
return g_strdup(stanza_id);
|
||||
|
||||
// Fallback: composite key from timestamp + sender + body prefix
|
||||
auto_gchar gchar* raw = g_strdup_printf("%s|%s|%.256s",
|
||||
timestamp ? timestamp : "",
|
||||
from_jid ? from_jid : "",
|
||||
body ? body : "");
|
||||
return g_compute_checksum_for_string(G_CHECKSUM_SHA256, raw, -1);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Helper: reverse ff_jid_to_dir (best-effort)
|
||||
// '_at_' -> '@'
|
||||
// =========================================================================
|
||||
|
||||
static char*
|
||||
_dir_to_jid(const char* dirname)
|
||||
{
|
||||
if (!dirname)
|
||||
return NULL;
|
||||
return str_replace(dirname, "_at_", "@");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Helper: list all contact JIDs from flatfile directory
|
||||
// Returns a GSList of g_strdup'd JID strings. Caller frees with g_slist_free_full(list, g_free).
|
||||
// =========================================================================
|
||||
|
||||
static GSList*
|
||||
_ff_list_contacts(void)
|
||||
{
|
||||
GSList* contacts = NULL;
|
||||
|
||||
if (!g_flatfile_account_jid)
|
||||
return NULL;
|
||||
|
||||
auto_gchar gchar* data_path = files_get_data_path(DIR_FLATLOG);
|
||||
auto_gchar gchar* my_dir = ff_jid_to_dir(g_flatfile_account_jid);
|
||||
auto_gchar gchar* base_dir = g_strdup_printf("%s/%s", data_path, my_dir);
|
||||
|
||||
GError* err = NULL;
|
||||
GDir* dir = g_dir_open(base_dir, 0, &err);
|
||||
if (!dir) {
|
||||
if (err) {
|
||||
log_debug("export: cannot open flatlog dir %s: %s", base_dir, err->message);
|
||||
g_error_free(err);
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
const gchar* entry;
|
||||
while ((entry = g_dir_read_name(dir)) != NULL) {
|
||||
auto_gchar gchar* full_path = g_strdup_printf("%s/%s/history.log", base_dir, entry);
|
||||
if (g_file_test(full_path, G_FILE_TEST_IS_REGULAR)) {
|
||||
char* jid = _dir_to_jid(entry);
|
||||
if (jid) {
|
||||
contacts = g_slist_prepend(contacts, jid);
|
||||
}
|
||||
}
|
||||
}
|
||||
g_dir_close(dir);
|
||||
|
||||
return g_slist_reverse(contacts);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Helper: read ALL parsed lines from a flatfile for a contact
|
||||
// Returns GSList<ff_parsed_line_t*>. Caller frees with g_slist_free_full(list, (GDestroyNotify)ff_parsed_line_free).
|
||||
// =========================================================================
|
||||
|
||||
static GSList*
|
||||
_ff_read_all_lines(const char* contact_barejid)
|
||||
{
|
||||
GSList* lines = NULL;
|
||||
auto_gchar gchar* log_path = ff_get_log_path(contact_barejid);
|
||||
if (!log_path)
|
||||
return NULL;
|
||||
|
||||
FILE* fp = fopen(log_path, "r");
|
||||
if (!fp)
|
||||
return NULL;
|
||||
|
||||
while (1) {
|
||||
gboolean truncated = FALSE;
|
||||
char* buf = ff_readline(fp, &truncated);
|
||||
if (!buf)
|
||||
break;
|
||||
if (truncated) {
|
||||
free(buf);
|
||||
break;
|
||||
}
|
||||
if (buf[0] == '\0' || buf[0] == '#') {
|
||||
free(buf);
|
||||
continue;
|
||||
}
|
||||
|
||||
ff_parsed_line_t* pl = ff_parse_line(buf);
|
||||
free(buf);
|
||||
if (pl) {
|
||||
lines = g_slist_prepend(lines, pl);
|
||||
}
|
||||
}
|
||||
fclose(fp);
|
||||
|
||||
return g_slist_reverse(lines);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Comparator for sorting ff_parsed_line_t by timestamp (ISO8601 is lexicographic)
|
||||
// =========================================================================
|
||||
|
||||
static gint
|
||||
_compare_parsed_lines_by_timestamp(gconstpointer a, gconstpointer b)
|
||||
{
|
||||
const ff_parsed_line_t* la = a;
|
||||
const ff_parsed_line_t* lb = b;
|
||||
int cmp = g_strcmp0(la->timestamp_str, lb->timestamp_str);
|
||||
if (cmp != 0)
|
||||
return cmp;
|
||||
// Secondary key: stanza_id (stabilises sort for same-second messages)
|
||||
cmp = g_strcmp0(la->stanza_id, lb->stanza_id);
|
||||
if (cmp != 0)
|
||||
return cmp;
|
||||
return g_strcmp0(la->from_jid, lb->from_jid);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Export: SQLite -> flatfile (merge)
|
||||
// =========================================================================
|
||||
|
||||
// Callback data for the SQLite query
|
||||
typedef struct
|
||||
{
|
||||
GHashTable* seen_keys; // dedup set
|
||||
FILE* fp; // output file
|
||||
int exported; // counter
|
||||
int skipped; // counter
|
||||
} export_ctx_t;
|
||||
|
||||
int
|
||||
log_database_export_to_flatfile(const gchar* const contact_jid)
|
||||
{
|
||||
const Jid* myjid = connection_get_jid();
|
||||
if (!myjid || !myjid->barejid) {
|
||||
cons_show_error("Export failed: not connected.");
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (!g_flatfile_account_jid) {
|
||||
// Flatfile backend not initialized — init it temporarily
|
||||
// We need g_flatfile_account_jid for path construction
|
||||
g_flatfile_account_jid = g_strdup(myjid->barejid);
|
||||
}
|
||||
|
||||
// Get the SQLite backend
|
||||
db_backend_t* sqlite_be = db_backend_sqlite();
|
||||
if (!sqlite_be) {
|
||||
cons_show_error("Export failed: SQLite backend not available.");
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Determine contact list
|
||||
GSList* contacts = NULL;
|
||||
if (contact_jid) {
|
||||
contacts = g_slist_append(contacts, g_strdup(contact_jid));
|
||||
} else {
|
||||
contacts = db_sqlite_list_contacts();
|
||||
if (!contacts) {
|
||||
cons_show("No contacts found in SQLite database.");
|
||||
return 0;
|
||||
}
|
||||
cons_show("Exporting %d contact(s) to flat-file format...", g_slist_length(contacts));
|
||||
}
|
||||
|
||||
int total_exported = 0;
|
||||
int total_skipped = 0;
|
||||
|
||||
for (GSList* c = contacts; c; c = c->next) {
|
||||
const char* contact = c->data;
|
||||
|
||||
// 1. Read existing flatfile lines for dedup
|
||||
GHashTable* seen_keys = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL);
|
||||
|
||||
GSList* existing = _ff_read_all_lines(contact);
|
||||
for (GSList* l = existing; l; l = l->next) {
|
||||
ff_parsed_line_t* pl = l->data;
|
||||
char* key = _make_dedup_key(pl->stanza_id, pl->timestamp_str, pl->from_jid, pl->message);
|
||||
g_hash_table_add(seen_keys, key);
|
||||
}
|
||||
int existing_count = g_slist_length(existing);
|
||||
|
||||
// 2. Query SQLite for this contact — get ALL messages via direct SQL
|
||||
GSList* sqlite_lines = db_sqlite_get_all_chat(contact);
|
||||
|
||||
if (!sqlite_lines) {
|
||||
log_debug("export: no SQLite messages for %s", contact);
|
||||
g_hash_table_destroy(seen_keys);
|
||||
g_slist_free_full(existing, (GDestroyNotify)ff_parsed_line_free);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3. Merge: write existing flatfile lines + new SQLite lines to temp file
|
||||
auto_gchar gchar* log_path = ff_get_log_path(contact);
|
||||
if (!log_path) {
|
||||
g_hash_table_destroy(seen_keys);
|
||||
g_slist_free_full(existing, (GDestroyNotify)ff_parsed_line_free);
|
||||
g_slist_free_full(sqlite_lines, (GDestroyNotify)message_free);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ensure directory
|
||||
auto_gchar gchar* dir = g_path_get_dirname(log_path);
|
||||
ff_ensure_dir(dir);
|
||||
|
||||
auto_gchar gchar* tmp_path = g_strdup_printf("%s.export.tmp", log_path);
|
||||
FILE* fp = fopen(tmp_path, "w");
|
||||
if (!fp) {
|
||||
log_error("export: cannot create %s: %s", tmp_path, strerror(errno));
|
||||
g_hash_table_destroy(seen_keys);
|
||||
g_slist_free_full(existing, (GDestroyNotify)ff_parsed_line_free);
|
||||
g_slist_free_full(sqlite_lines, (GDestroyNotify)message_free);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Lock the temp file
|
||||
int fd = fileno(fp);
|
||||
if (flock(fd, LOCK_EX) != 0) {
|
||||
log_warning("export: flock(%s) failed: %s", tmp_path, strerror(errno));
|
||||
}
|
||||
|
||||
fprintf(fp, "%s", FLATFILE_HEADER);
|
||||
|
||||
int contact_exported = 0;
|
||||
int contact_skipped = 0;
|
||||
|
||||
// Collect ALL lines into a merged list for sorted output
|
||||
GSList* merged = NULL;
|
||||
|
||||
// Add existing flatfile lines (mark as already seen in dedup)
|
||||
for (GSList* l = existing; l; l = l->next) {
|
||||
ff_parsed_line_t* pl = l->data;
|
||||
// Create a shallow copy for the merged list (originals freed separately)
|
||||
ff_parsed_line_t* copy = g_malloc0(sizeof(ff_parsed_line_t));
|
||||
copy->timestamp_str = g_strdup(pl->timestamp_str);
|
||||
copy->timestamp = pl->timestamp ? g_date_time_ref(pl->timestamp) : NULL;
|
||||
copy->type = g_strdup(pl->type);
|
||||
copy->enc = g_strdup(pl->enc);
|
||||
copy->stanza_id = g_strdup(pl->stanza_id);
|
||||
copy->archive_id = g_strdup(pl->archive_id);
|
||||
copy->replace_id = g_strdup(pl->replace_id);
|
||||
copy->from_jid = g_strdup(pl->from_jid);
|
||||
copy->from_resource = g_strdup(pl->from_resource);
|
||||
copy->to_jid = g_strdup(pl->to_jid);
|
||||
copy->to_resource = g_strdup(pl->to_resource);
|
||||
copy->marked_read = pl->marked_read;
|
||||
copy->message = g_strdup(pl->message);
|
||||
merged = g_slist_prepend(merged, copy);
|
||||
}
|
||||
|
||||
// Add SQLite messages that aren't already in the flatfile
|
||||
for (GSList* l = sqlite_lines; l; l = l->next) {
|
||||
ProfMessage* msg = l->data;
|
||||
if (!msg || !msg->timestamp)
|
||||
continue;
|
||||
|
||||
auto_gchar gchar* ts = g_date_time_format_iso8601(msg->timestamp);
|
||||
const char* from_jid = msg->from_jid ? msg->from_jid->barejid : "unknown";
|
||||
const char* body = msg->plain ? msg->plain : "";
|
||||
const char* sid = msg->id ? msg->id : "";
|
||||
|
||||
char* key = _make_dedup_key(sid, ts, from_jid, body);
|
||||
if (g_hash_table_contains(seen_keys, key)) {
|
||||
g_free(key);
|
||||
contact_skipped++;
|
||||
continue;
|
||||
}
|
||||
g_hash_table_add(seen_keys, key);
|
||||
|
||||
ff_parsed_line_t* pl = g_malloc0(sizeof(ff_parsed_line_t));
|
||||
pl->timestamp_str = g_strdup(ts);
|
||||
pl->timestamp = g_date_time_ref(msg->timestamp);
|
||||
pl->type = g_strdup(ff_get_message_type_str(msg->type));
|
||||
pl->enc = g_strdup(ff_get_message_enc_str(msg->enc));
|
||||
pl->stanza_id = g_strdup(sid);
|
||||
pl->archive_id = msg->stanzaid ? g_strdup(msg->stanzaid) : NULL;
|
||||
pl->replace_id = NULL;
|
||||
pl->from_jid = g_strdup(from_jid);
|
||||
pl->from_resource = msg->from_jid ? g_strdup(msg->from_jid->resourcepart) : NULL;
|
||||
pl->to_jid = msg->to_jid ? g_strdup(msg->to_jid->barejid) : NULL;
|
||||
pl->to_resource = msg->to_jid ? g_strdup(msg->to_jid->resourcepart) : NULL;
|
||||
pl->marked_read = msg->marked_read;
|
||||
pl->message = g_strdup(body);
|
||||
merged = g_slist_prepend(merged, pl);
|
||||
contact_exported++;
|
||||
}
|
||||
|
||||
// Sort merged list by timestamp (ISO8601 is lexicographically sortable)
|
||||
merged = g_slist_sort(merged, _compare_parsed_lines_by_timestamp);
|
||||
|
||||
// Write all lines sorted
|
||||
int written = 0;
|
||||
for (GSList* l = merged; l; l = l->next) {
|
||||
ff_parsed_line_t* pl = l->data;
|
||||
ff_write_line(fp, pl->timestamp_str,
|
||||
pl->type, pl->enc,
|
||||
pl->stanza_id, pl->archive_id, pl->replace_id,
|
||||
pl->from_jid, pl->from_resource,
|
||||
pl->to_jid, pl->to_resource, pl->marked_read,
|
||||
pl->message);
|
||||
written++;
|
||||
if (written % 500 == 0) {
|
||||
cons_show(" ... %s: %d/%d written so far", contact, written, (int)g_slist_length(merged));
|
||||
}
|
||||
}
|
||||
|
||||
g_slist_free_full(merged, (GDestroyNotify)ff_parsed_line_free);
|
||||
|
||||
fflush(fp);
|
||||
fsync(fd); // ensure data is on disk before atomic rename
|
||||
fclose(fp); // also releases flock
|
||||
|
||||
// Atomic rename
|
||||
if (rename(tmp_path, log_path) != 0) {
|
||||
log_error("export: rename %s -> %s failed: %s", tmp_path, log_path, strerror(errno));
|
||||
unlink(tmp_path);
|
||||
}
|
||||
|
||||
cons_show("Exported %s: %d new, %d skipped (already existed: %d)",
|
||||
contact, contact_exported, contact_skipped, existing_count);
|
||||
total_exported += contact_exported;
|
||||
total_skipped += contact_skipped;
|
||||
|
||||
g_hash_table_destroy(seen_keys);
|
||||
g_slist_free_full(existing, (GDestroyNotify)ff_parsed_line_free);
|
||||
g_slist_free_full(sqlite_lines, (GDestroyNotify)message_free);
|
||||
}
|
||||
|
||||
g_slist_free_full(contacts, g_free);
|
||||
return total_exported;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Import: flatfile -> SQLite (merge)
|
||||
// =========================================================================
|
||||
|
||||
int
|
||||
log_database_import_from_flatfile(const gchar* const contact_jid)
|
||||
{
|
||||
const Jid* myjid = connection_get_jid();
|
||||
if (!myjid || !myjid->barejid) {
|
||||
cons_show_error("Import failed: not connected.");
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (!g_flatfile_account_jid) {
|
||||
g_flatfile_account_jid = g_strdup(myjid->barejid);
|
||||
}
|
||||
|
||||
db_backend_t* sqlite_be = db_backend_sqlite();
|
||||
if (!sqlite_be) {
|
||||
cons_show_error("Import failed: SQLite backend not available.");
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Determine contacts
|
||||
GSList* contacts = NULL;
|
||||
if (contact_jid) {
|
||||
contacts = g_slist_append(contacts, g_strdup(contact_jid));
|
||||
} else {
|
||||
contacts = _ff_list_contacts();
|
||||
if (!contacts) {
|
||||
cons_show("No flat-file history found to import.");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
int total_imported = 0;
|
||||
int total_skipped = 0;
|
||||
|
||||
for (GSList* c = contacts; c; c = c->next) {
|
||||
const char* contact = c->data;
|
||||
|
||||
// 1. Read all flatfile lines
|
||||
GSList* ff_lines = _ff_read_all_lines(contact);
|
||||
if (!ff_lines) {
|
||||
log_debug("import: no flatfile messages for %s", contact);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2. Build dedup set from SQLite (existing stanza_ids + fallback keys)
|
||||
GHashTable* seen_keys = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL);
|
||||
|
||||
// Read all SQLite messages for this contact via direct SQL query
|
||||
GSList* existing_msgs = db_sqlite_get_all_chat(contact);
|
||||
for (GSList* l = existing_msgs; l; l = l->next) {
|
||||
ProfMessage* msg = l->data;
|
||||
if (!msg)
|
||||
continue;
|
||||
auto_gchar gchar* ts = msg->timestamp ? g_date_time_format_iso8601(msg->timestamp) : g_strdup("");
|
||||
char* key = _make_dedup_key(msg->id, ts, msg->from_jid ? msg->from_jid->barejid : "", msg->plain);
|
||||
g_hash_table_add(seen_keys, key);
|
||||
}
|
||||
g_slist_free_full(existing_msgs, (GDestroyNotify)message_free);
|
||||
|
||||
// 3. For each flatfile line not in seen_keys, insert via add_incoming
|
||||
// Wrap in a transaction for atomicity and performance
|
||||
int contact_imported = 0;
|
||||
int contact_skipped = 0;
|
||||
int total_lines = g_slist_length(ff_lines);
|
||||
|
||||
db_sqlite_begin_transaction();
|
||||
gboolean import_ok = TRUE;
|
||||
|
||||
for (GSList* l = ff_lines; l; l = l->next) {
|
||||
ff_parsed_line_t* pl = l->data;
|
||||
if (!pl || !pl->timestamp)
|
||||
continue;
|
||||
|
||||
auto_gchar gchar* ts = g_date_time_format_iso8601(pl->timestamp);
|
||||
char* key = _make_dedup_key(pl->stanza_id, ts, pl->from_jid, pl->message);
|
||||
|
||||
if (g_hash_table_contains(seen_keys, key)) {
|
||||
g_free(key);
|
||||
contact_skipped++;
|
||||
continue;
|
||||
}
|
||||
g_hash_table_add(seen_keys, key);
|
||||
|
||||
// Build a ProfMessage and insert via add_incoming
|
||||
// (add_incoming uses _add_to_db which preserves the original timestamp)
|
||||
gboolean is_outgoing = (g_strcmp0(pl->from_jid, myjid->barejid) == 0);
|
||||
|
||||
ProfMessage* msg = message_init();
|
||||
msg->id = pl->stanza_id ? g_strdup(pl->stanza_id) : NULL;
|
||||
msg->stanzaid = pl->archive_id ? g_strdup(pl->archive_id) : NULL;
|
||||
msg->plain = g_strdup(pl->message ? pl->message : "");
|
||||
msg->timestamp = g_date_time_ref(pl->timestamp);
|
||||
msg->type = ff_get_message_type_type(pl->type);
|
||||
msg->enc = ff_get_message_enc_type(pl->enc);
|
||||
msg->replace_id = pl->replace_id ? g_strdup(pl->replace_id) : NULL;
|
||||
msg->is_mam = TRUE; // Suppress dedup check in the backend
|
||||
|
||||
if (is_outgoing) {
|
||||
msg->from_jid = jid_create(myjid->barejid);
|
||||
if (pl->to_jid) {
|
||||
msg->to_jid = jid_create_from_bare_and_resource(pl->to_jid, pl->to_resource);
|
||||
} else {
|
||||
msg->to_jid = jid_create(contact);
|
||||
}
|
||||
} else {
|
||||
msg->from_jid = jid_create_from_bare_and_resource(pl->from_jid, pl->from_resource);
|
||||
if (pl->to_jid) {
|
||||
msg->to_jid = jid_create_from_bare_and_resource(pl->to_jid, pl->to_resource);
|
||||
} else {
|
||||
msg->to_jid = jid_create(myjid->barejid);
|
||||
}
|
||||
}
|
||||
|
||||
sqlite_be->add_incoming(msg);
|
||||
message_free(msg);
|
||||
|
||||
// Verify the row was actually inserted
|
||||
if (db_sqlite_last_changes() < 1) {
|
||||
log_error("import: insert failed for %s at %s", contact, ts);
|
||||
import_ok = FALSE;
|
||||
break;
|
||||
}
|
||||
contact_imported++;
|
||||
|
||||
if (contact_imported % 500 == 0) {
|
||||
cons_show(" ... %s: %d/%d imported so far", contact, contact_imported, total_lines);
|
||||
}
|
||||
}
|
||||
|
||||
if (import_ok) {
|
||||
db_sqlite_end_transaction();
|
||||
} else {
|
||||
db_sqlite_rollback_transaction();
|
||||
cons_show_error("Import of %s failed — transaction rolled back.", contact);
|
||||
}
|
||||
|
||||
cons_show("Imported %s: %d new, %d skipped (already in SQLite)",
|
||||
contact, contact_imported, contact_skipped);
|
||||
total_imported += contact_imported;
|
||||
total_skipped += contact_skipped;
|
||||
|
||||
g_hash_table_destroy(seen_keys);
|
||||
g_slist_free_full(ff_lines, (GDestroyNotify)ff_parsed_line_free);
|
||||
}
|
||||
|
||||
g_slist_free_full(contacts, g_free);
|
||||
return total_imported;
|
||||
}
|
||||
|
||||
#endif /* HAVE_SQLITE */
|
||||
1039
src/database_flatfile.c
Normal file
1039
src/database_flatfile.c
Normal file
File diff suppressed because it is too large
Load Diff
154
src/database_flatfile.h
Normal file
154
src/database_flatfile.h
Normal file
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* database_flatfile.h
|
||||
* vim: expandtab:ts=4:sts=4:sw=4
|
||||
*
|
||||
* Copyright (C) 2026 Profanity Contributors
|
||||
*
|
||||
* This file is part of Profanity.
|
||||
*
|
||||
* Profanity is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Profanity is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Profanity. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* Internal header shared between database_flatfile*.c modules.
|
||||
* Not part of the public API — do not include from outside the flatfile backend.
|
||||
*/
|
||||
|
||||
#ifndef DATABASE_FLATFILE_H
|
||||
#define DATABASE_FLATFILE_H
|
||||
|
||||
#include <glib.h>
|
||||
#include <stdio.h>
|
||||
#include <sys/types.h>
|
||||
|
||||
#include "database.h"
|
||||
#include "xmpp/xmpp.h"
|
||||
#include "xmpp/message.h"
|
||||
|
||||
// --- Constants ---
|
||||
|
||||
#define DIR_FLATLOG "flatlog"
|
||||
#define FLATFILE_HEADER "# profanity chat log — UTF-8, LF line endings\n# vim: set fileencoding=utf-8 fileformat=unix :\n"
|
||||
#define FF_MAX_LINE_LEN (10 * 1024 * 1024) /* 10 MB — reject lines longer than this */
|
||||
#define FF_MAX_LMC_DEPTH 100 /* max correction chain depth */
|
||||
|
||||
// --- Shared global ---
|
||||
|
||||
// Account JID stored during init for path construction.
|
||||
// Defined in database_flatfile.c, used by all flatfile modules.
|
||||
extern char* g_flatfile_account_jid;
|
||||
|
||||
// --- Parsed line structure ---
|
||||
|
||||
typedef struct
|
||||
{
|
||||
char* timestamp_str;
|
||||
GDateTime* timestamp;
|
||||
char* type;
|
||||
char* enc;
|
||||
char* stanza_id;
|
||||
char* archive_id;
|
||||
char* replace_id;
|
||||
char* from_jid;
|
||||
char* from_resource;
|
||||
char* to_jid;
|
||||
char* to_resource;
|
||||
int marked_read; // -1 = unset (NULL in DB), 0 = unread, 1 = read
|
||||
char* message;
|
||||
off_t file_offset;
|
||||
} ff_parsed_line_t;
|
||||
|
||||
// --- Sparse index for single-file lookup ---
|
||||
|
||||
#define FF_INDEX_STEP 500
|
||||
|
||||
typedef struct
|
||||
{
|
||||
off_t byte_offset;
|
||||
gint64 timestamp_epoch;
|
||||
} ff_index_entry_t;
|
||||
|
||||
typedef struct
|
||||
{
|
||||
time_t mtime;
|
||||
off_t size;
|
||||
ino_t inode;
|
||||
} ff_file_stamp_t;
|
||||
|
||||
typedef struct
|
||||
{
|
||||
char* filepath;
|
||||
ff_index_entry_t* entries;
|
||||
size_t n_entries;
|
||||
size_t cap_entries;
|
||||
size_t total_lines;
|
||||
ff_file_stamp_t stamp;
|
||||
off_t cursor_offset;
|
||||
int bom_len;
|
||||
GHashTable* archive_ids; // set: archive_id -> NULL (MAM dedup, O(1))
|
||||
GHashTable* stanza_senders; // map: stanza_id -> from_jid (LMC validation, O(1))
|
||||
} ff_contact_state_t;
|
||||
|
||||
// State management
|
||||
ff_contact_state_t* ff_state_new(const char* filepath);
|
||||
void ff_state_free(ff_contact_state_t* state);
|
||||
gboolean ff_state_ensure_fresh(ff_contact_state_t* state);
|
||||
off_t ff_state_offset_for_time(ff_contact_state_t* state, const char* iso_time);
|
||||
|
||||
// --- Type conversion helpers ---
|
||||
|
||||
const char* ff_get_message_type_str(prof_msg_type_t type);
|
||||
prof_msg_type_t ff_get_message_type_type(const char* const type);
|
||||
const char* ff_get_message_enc_str(prof_enc_t enc);
|
||||
prof_enc_t ff_get_message_enc_type(const char* const encstr);
|
||||
|
||||
// --- Path helpers ---
|
||||
|
||||
char* ff_jid_to_dir(const char* jid);
|
||||
char* ff_get_contact_dir(const char* contact_barejid);
|
||||
char* ff_get_log_path(const char* contact_barejid);
|
||||
gboolean ff_ensure_dir(const char* path);
|
||||
|
||||
// --- Escape / unescape ---
|
||||
|
||||
char* ff_escape_message(const char* text);
|
||||
char* ff_unescape_message(const char* text);
|
||||
char* ff_escape_meta_value(const char* val);
|
||||
char* ff_unescape_meta_value(const char* val);
|
||||
|
||||
// --- I/O ---
|
||||
|
||||
char* ff_readline(FILE* fp, gboolean* truncated);
|
||||
void ff_write_line(FILE* fp, const char* timestamp, const char* type, const char* enc,
|
||||
const char* stanza_id, const char* archive_id, const char* replace_id,
|
||||
const char* from_jid, const char* from_resource,
|
||||
const char* to_jid, const char* to_resource, int marked_read,
|
||||
const char* message_text);
|
||||
|
||||
// --- Parser helpers ---
|
||||
|
||||
const char* ff_find_unescaped_char(const char* str, char ch);
|
||||
char** ff_split_meta(const char* meta);
|
||||
const char* ff_find_unescaped_colonspace(const char* str);
|
||||
char* ff_unescape_sender_resource(const char* res);
|
||||
|
||||
// --- Parser ---
|
||||
|
||||
void ff_parsed_line_free(ff_parsed_line_t* pl);
|
||||
ff_parsed_line_t* ff_parse_line(const char* line);
|
||||
ProfMessage* ff_parsed_to_profmessage(ff_parsed_line_t* pl);
|
||||
|
||||
// --- Integrity verification (database_flatfile_verify.c) ---
|
||||
|
||||
GSList* ff_verify_integrity(const gchar* const contact_barejid);
|
||||
|
||||
#endif
|
||||
780
src/database_flatfile_parser.c
Normal file
780
src/database_flatfile_parser.c
Normal file
@@ -0,0 +1,780 @@
|
||||
/*
|
||||
* database_flatfile_parser.c
|
||||
* vim: expandtab:ts=4:sts=4:sw=4
|
||||
*
|
||||
* Copyright (C) 2026 Profanity Contributors
|
||||
*
|
||||
* This file is part of Profanity.
|
||||
*
|
||||
* Profanity is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Profanity is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Profanity. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* Flat-file backend: type helpers, path helpers, escape/unescape,
|
||||
* line I/O, and the tolerant log-line parser.
|
||||
*/
|
||||
|
||||
#include "config.h"
|
||||
|
||||
#include <sys/stat.h>
|
||||
#include <glib.h>
|
||||
#include <glib/gstdio.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <errno.h>
|
||||
|
||||
#include "log.h"
|
||||
#include "common.h"
|
||||
#include "config/files.h"
|
||||
#include "database_flatfile.h"
|
||||
|
||||
// =========================================================================
|
||||
// Type conversion helpers
|
||||
// =========================================================================
|
||||
|
||||
const char*
|
||||
ff_get_message_type_str(prof_msg_type_t type)
|
||||
{
|
||||
switch (type) {
|
||||
case PROF_MSG_TYPE_CHAT:
|
||||
return "chat";
|
||||
case PROF_MSG_TYPE_MUC:
|
||||
return "muc";
|
||||
case PROF_MSG_TYPE_MUCPM:
|
||||
return "mucpm";
|
||||
case PROF_MSG_TYPE_UNINITIALIZED:
|
||||
return "chat";
|
||||
}
|
||||
return "chat";
|
||||
}
|
||||
|
||||
prof_msg_type_t
|
||||
ff_get_message_type_type(const char* const type)
|
||||
{
|
||||
if (g_strcmp0(type, "chat") == 0) {
|
||||
return PROF_MSG_TYPE_CHAT;
|
||||
} else if (g_strcmp0(type, "muc") == 0) {
|
||||
return PROF_MSG_TYPE_MUC;
|
||||
} else if (g_strcmp0(type, "mucpm") == 0) {
|
||||
return PROF_MSG_TYPE_MUCPM;
|
||||
}
|
||||
return PROF_MSG_TYPE_CHAT;
|
||||
}
|
||||
|
||||
const char*
|
||||
ff_get_message_enc_str(prof_enc_t enc)
|
||||
{
|
||||
switch (enc) {
|
||||
case PROF_MSG_ENC_OX:
|
||||
return "ox";
|
||||
case PROF_MSG_ENC_PGP:
|
||||
return "pgp";
|
||||
case PROF_MSG_ENC_OTR:
|
||||
return "otr";
|
||||
case PROF_MSG_ENC_OMEMO:
|
||||
return "omemo";
|
||||
case PROF_MSG_ENC_NONE:
|
||||
return "none";
|
||||
}
|
||||
return "none";
|
||||
}
|
||||
|
||||
prof_enc_t
|
||||
ff_get_message_enc_type(const char* const encstr)
|
||||
{
|
||||
if (g_strcmp0(encstr, "ox") == 0) {
|
||||
return PROF_MSG_ENC_OX;
|
||||
} else if (g_strcmp0(encstr, "pgp") == 0) {
|
||||
return PROF_MSG_ENC_PGP;
|
||||
} else if (g_strcmp0(encstr, "otr") == 0) {
|
||||
return PROF_MSG_ENC_OTR;
|
||||
} else if (g_strcmp0(encstr, "omemo") == 0) {
|
||||
return PROF_MSG_ENC_OMEMO;
|
||||
}
|
||||
return PROF_MSG_ENC_NONE;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Path helpers
|
||||
// =========================================================================
|
||||
|
||||
// Sanitise a JID for use as a directory name.
|
||||
// 1. Replace '@' with '_at_'
|
||||
// 2. Reject / strip path-separator and traversal characters: '/', '\0', '..'
|
||||
// This prevents a malicious federated JID like "../../../tmp/pwned" from
|
||||
// escaping the log directory tree.
|
||||
char*
|
||||
ff_jid_to_dir(const char* jid)
|
||||
{
|
||||
if (!jid || jid[0] == '\0')
|
||||
return NULL;
|
||||
|
||||
// Replace '@' first
|
||||
char* step1 = str_replace(jid, "@", "_at_");
|
||||
if (!step1)
|
||||
return NULL;
|
||||
|
||||
// Replace '/' and '\\' with '_' to prevent path traversal
|
||||
GString* out = g_string_sized_new(strlen(step1));
|
||||
for (const char* p = step1; *p; p++) {
|
||||
if (*p == '/' || *p == '\\') {
|
||||
g_string_append_c(out, '_');
|
||||
} else {
|
||||
g_string_append_c(out, *p);
|
||||
}
|
||||
}
|
||||
free(step1);
|
||||
|
||||
// Collapse any remaining ".." sequences to "__" (belt-and-suspenders)
|
||||
char* result = g_string_free(out, FALSE);
|
||||
char* dotdot;
|
||||
while ((dotdot = strstr(result, "..")) != NULL) {
|
||||
dotdot[0] = '_';
|
||||
dotdot[1] = '_';
|
||||
}
|
||||
|
||||
// Reject empty result
|
||||
if (result[0] == '\0') {
|
||||
g_free(result);
|
||||
return NULL;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Get the base directory for a contact's logs:
|
||||
// ~/.local/share/profanity/flatlog/{my_jid_dir}/{contact_jid_dir}/
|
||||
char*
|
||||
ff_get_contact_dir(const char* contact_barejid)
|
||||
{
|
||||
if (!g_flatfile_account_jid || !contact_barejid)
|
||||
return NULL;
|
||||
|
||||
auto_gchar gchar* data_path = files_get_data_path(DIR_FLATLOG);
|
||||
auto_gchar gchar* my_dir = ff_jid_to_dir(g_flatfile_account_jid);
|
||||
auto_gchar gchar* contact_dir = ff_jid_to_dir(contact_barejid);
|
||||
|
||||
char* result = g_strdup_printf("%s/%s/%s", data_path, my_dir, contact_dir);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Get the single log file path for a contact: {contact_dir}/history.log
|
||||
char*
|
||||
ff_get_log_path(const char* contact_barejid)
|
||||
{
|
||||
auto_gchar gchar* contact_dir = ff_get_contact_dir(contact_barejid);
|
||||
if (!contact_dir)
|
||||
return NULL;
|
||||
|
||||
char* result = g_strdup_printf("%s/history.log", contact_dir);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Ensure the directory exists, create if needed.
|
||||
// Refuses to follow symlinks at the final component.
|
||||
gboolean
|
||||
ff_ensure_dir(const char* path)
|
||||
{
|
||||
if (g_file_test(path, G_FILE_TEST_IS_DIR)) {
|
||||
// Verify it's not a symlink
|
||||
struct stat st;
|
||||
if (g_lstat(path, &st) == 0 && S_ISLNK(st.st_mode)) {
|
||||
log_error("flatfile: directory path is a symlink, refusing: %s", path);
|
||||
return FALSE;
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
if (g_mkdir_with_parents(path, S_IRWXU) != 0) {
|
||||
log_error("flatfile: Could not create directory: %s (errno=%d)", path, errno);
|
||||
return FALSE;
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Escape / unescape helpers
|
||||
// =========================================================================
|
||||
//
|
||||
// Message text and metadata values from remote peers can contain arbitrary
|
||||
// characters including newlines, pipes and brackets. Without escaping, a
|
||||
// crafted message could inject fake log lines (log injection / format
|
||||
// injection). We escape on write and unescape on read.
|
||||
|
||||
// Escape message body: \ -> \\, \n -> \n literal, \r -> \r literal
|
||||
char*
|
||||
ff_escape_message(const char* text)
|
||||
{
|
||||
if (!text)
|
||||
return g_strdup("");
|
||||
GString* out = g_string_sized_new(strlen(text));
|
||||
for (const char* p = text; *p; p++) {
|
||||
switch (*p) {
|
||||
case '\\':
|
||||
g_string_append(out, "\\\\");
|
||||
break;
|
||||
case '\n':
|
||||
g_string_append(out, "\\n");
|
||||
break;
|
||||
case '\r':
|
||||
g_string_append(out, "\\r");
|
||||
break;
|
||||
default:
|
||||
g_string_append_c(out, *p);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return g_string_free(out, FALSE);
|
||||
}
|
||||
|
||||
// Unescape message body: \\ -> \, \n -> newline, \r -> CR
|
||||
char*
|
||||
ff_unescape_message(const char* text)
|
||||
{
|
||||
if (!text)
|
||||
return g_strdup("");
|
||||
GString* out = g_string_sized_new(strlen(text));
|
||||
for (const char* p = text; *p; p++) {
|
||||
if (*p == '\\' && *(p + 1)) {
|
||||
p++;
|
||||
switch (*p) {
|
||||
case '\\':
|
||||
g_string_append_c(out, '\\');
|
||||
break;
|
||||
case 'n':
|
||||
g_string_append_c(out, '\n');
|
||||
break;
|
||||
case 'r':
|
||||
g_string_append_c(out, '\r');
|
||||
break;
|
||||
default:
|
||||
g_string_append_c(out, '\\');
|
||||
g_string_append_c(out, *p);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
g_string_append_c(out, *p);
|
||||
}
|
||||
}
|
||||
return g_string_free(out, FALSE);
|
||||
}
|
||||
|
||||
// Escape metadata value (stanza_id, archive_id, replace_id):
|
||||
// these come from remote servers and may contain |, ], \, newlines.
|
||||
char*
|
||||
ff_escape_meta_value(const char* val)
|
||||
{
|
||||
if (!val || strlen(val) == 0)
|
||||
return NULL;
|
||||
GString* out = g_string_sized_new(strlen(val));
|
||||
for (const char* p = val; *p; p++) {
|
||||
switch (*p) {
|
||||
case '|':
|
||||
g_string_append(out, "\\|");
|
||||
break;
|
||||
case ']':
|
||||
g_string_append(out, "\\]");
|
||||
break;
|
||||
case '\\':
|
||||
g_string_append(out, "\\\\");
|
||||
break;
|
||||
case '\n':
|
||||
g_string_append(out, "\\n");
|
||||
break;
|
||||
case '\r':
|
||||
g_string_append(out, "\\r");
|
||||
break;
|
||||
default:
|
||||
g_string_append_c(out, *p);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return g_string_free(out, FALSE);
|
||||
}
|
||||
|
||||
// Unescape metadata value
|
||||
char*
|
||||
ff_unescape_meta_value(const char* val)
|
||||
{
|
||||
if (!val)
|
||||
return NULL;
|
||||
GString* out = g_string_sized_new(strlen(val));
|
||||
for (const char* p = val; *p; p++) {
|
||||
if (*p == '\\' && *(p + 1)) {
|
||||
p++;
|
||||
switch (*p) {
|
||||
case '|':
|
||||
g_string_append_c(out, '|');
|
||||
break;
|
||||
case ']':
|
||||
g_string_append_c(out, ']');
|
||||
break;
|
||||
case '\\':
|
||||
g_string_append_c(out, '\\');
|
||||
break;
|
||||
case 'n':
|
||||
g_string_append_c(out, '\n');
|
||||
break;
|
||||
case 'r':
|
||||
g_string_append_c(out, '\r');
|
||||
break;
|
||||
default:
|
||||
g_string_append_c(out, '\\');
|
||||
g_string_append_c(out, *p);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
g_string_append_c(out, *p);
|
||||
}
|
||||
}
|
||||
return g_string_free(out, FALSE);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Readline helper
|
||||
// =========================================================================
|
||||
//
|
||||
// POSIX getline() for dynamic-length lines. Returns line without trailing
|
||||
// newline, or NULL on EOF. Sets *truncated=TRUE if the line had no trailing
|
||||
// newline at EOF (partial write detection).
|
||||
char*
|
||||
ff_readline(FILE* fp, gboolean* truncated)
|
||||
{
|
||||
char* line = NULL;
|
||||
size_t cap = 0;
|
||||
ssize_t nread = getline(&line, &cap, fp);
|
||||
if (nread == -1) {
|
||||
free(line);
|
||||
return NULL;
|
||||
}
|
||||
// Guard against pathological lines that could exhaust memory.
|
||||
// getline() already allocated, so we free and skip if too long.
|
||||
if (nread > FF_MAX_LINE_LEN) {
|
||||
log_error("flatfile: line too long (%zd bytes), skipping", nread);
|
||||
// Check newline termination before freeing
|
||||
gboolean had_newline = (nread > 0 && line[nread - 1] == '\n');
|
||||
free(line);
|
||||
// Skip to next newline if the overlength line wasn't newline-terminated
|
||||
if (!had_newline) {
|
||||
int ch;
|
||||
while ((ch = fgetc(fp)) != EOF && ch != '\n') {}
|
||||
}
|
||||
if (truncated)
|
||||
*truncated = FALSE;
|
||||
// Return empty string so caller's loop continues (parse will reject it).
|
||||
// Use malloc() so callers can always use free() consistently.
|
||||
char* empty = malloc(1);
|
||||
if (empty)
|
||||
empty[0] = '\0';
|
||||
return empty;
|
||||
}
|
||||
if (truncated)
|
||||
*truncated = FALSE;
|
||||
if (nread > 0 && line[nread - 1] == '\n') {
|
||||
line[--nread] = '\0';
|
||||
} else if (feof(fp)) {
|
||||
// Line without trailing newline at EOF — likely a partial write
|
||||
if (truncated)
|
||||
*truncated = TRUE;
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Line writer
|
||||
// =========================================================================
|
||||
//
|
||||
// Format: {ISO8601} [{type}|{enc}|id:{stanza_id}|aid:{archive_id}|corrects:{replace_id}] {from_jid}/{resource}: {message}
|
||||
//
|
||||
// Message text is escaped: \\ -> \\\\, newline -> \\n, CR -> \\r
|
||||
// Metadata values are escaped: |, ], \\, newline, CR
|
||||
// Lines starting with '#' are comments.
|
||||
// Empty lines are skipped.
|
||||
|
||||
void
|
||||
ff_write_line(FILE* fp, const char* timestamp, const char* type, const char* enc,
|
||||
const char* stanza_id, const char* archive_id, const char* replace_id,
|
||||
const char* from_jid, const char* from_resource,
|
||||
const char* to_jid, const char* to_resource, int marked_read,
|
||||
const char* message_text)
|
||||
{
|
||||
// Escape metadata values from remote peers
|
||||
auto_gchar gchar* safe_sid = ff_escape_meta_value(stanza_id);
|
||||
auto_gchar gchar* safe_aid = ff_escape_meta_value(archive_id);
|
||||
auto_gchar gchar* safe_rid = ff_escape_meta_value(replace_id);
|
||||
|
||||
// Build metadata section: [type|enc|id:...|aid:...|corrects:...|to:...|to_res:...|read:...]
|
||||
GString* meta = g_string_new("[");
|
||||
g_string_append(meta, type ? type : "chat");
|
||||
g_string_append_c(meta, '|');
|
||||
g_string_append(meta, enc ? enc : "none");
|
||||
if (safe_sid) {
|
||||
g_string_append_printf(meta, "|id:%s", safe_sid);
|
||||
}
|
||||
if (safe_aid) {
|
||||
g_string_append_printf(meta, "|aid:%s", safe_aid);
|
||||
}
|
||||
if (safe_rid) {
|
||||
g_string_append_printf(meta, "|corrects:%s", safe_rid);
|
||||
}
|
||||
if (to_jid && strlen(to_jid) > 0) {
|
||||
auto_gchar gchar* safe_to = ff_escape_meta_value(to_jid);
|
||||
g_string_append_printf(meta, "|to:%s", safe_to);
|
||||
}
|
||||
if (to_resource && strlen(to_resource) > 0) {
|
||||
auto_gchar gchar* safe_tores = ff_escape_meta_value(to_resource);
|
||||
g_string_append_printf(meta, "|to_res:%s", safe_tores);
|
||||
}
|
||||
if (marked_read >= 0) {
|
||||
g_string_append_printf(meta, "|read:%d", marked_read ? 1 : 0);
|
||||
}
|
||||
g_string_append_c(meta, ']');
|
||||
|
||||
// Build sender — escape ": " in the resource part to prevent
|
||||
// the parser from splitting at the wrong point.
|
||||
GString* sender = g_string_new(from_jid ? from_jid : "unknown");
|
||||
if (from_resource && strlen(from_resource) > 0) {
|
||||
// Escape backslash and colon-space in resource
|
||||
GString* safe_res = g_string_sized_new(strlen(from_resource));
|
||||
for (const char* p = from_resource; *p; p++) {
|
||||
if (*p == '\\') {
|
||||
g_string_append(safe_res, "\\\\");
|
||||
} else if (*p == ':' && *(p + 1) == ' ') {
|
||||
g_string_append(safe_res, "\\: ");
|
||||
p++; // skip the space too
|
||||
} else {
|
||||
g_string_append_c(safe_res, *p);
|
||||
}
|
||||
}
|
||||
g_string_append_printf(sender, "/%s", safe_res->str);
|
||||
g_string_free(safe_res, TRUE);
|
||||
}
|
||||
|
||||
// Escape message body to prevent log injection
|
||||
char* safe_msg = ff_escape_message(message_text);
|
||||
|
||||
// Build complete line and write with a single fwrite()
|
||||
GString* full_line = g_string_new(NULL);
|
||||
g_string_printf(full_line, "%s %s %s: %s\n",
|
||||
timestamp, meta->str, sender->str, safe_msg);
|
||||
|
||||
size_t to_write = full_line->len;
|
||||
size_t written = fwrite(full_line->str, 1, to_write, fp);
|
||||
if (written != to_write) {
|
||||
log_error("flatfile: partial write (%zu/%zu)", written, to_write);
|
||||
}
|
||||
|
||||
g_string_free(full_line, TRUE);
|
||||
g_free(safe_msg);
|
||||
g_string_free(meta, TRUE);
|
||||
g_string_free(sender, TRUE);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Parser helpers
|
||||
// =========================================================================
|
||||
|
||||
// Find the first occurrence of 'ch' that is not preceded by an unescaped backslash.
|
||||
const char*
|
||||
ff_find_unescaped_char(const char* str, char ch)
|
||||
{
|
||||
if (!str)
|
||||
return NULL;
|
||||
for (const char* p = str; *p; p++) {
|
||||
if (*p == '\\' && *(p + 1)) {
|
||||
p++; // skip escaped character
|
||||
continue;
|
||||
}
|
||||
if (*p == ch)
|
||||
return p;
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// Split metadata content on unescaped '|'. Returns a NULL-terminated array.
|
||||
// Caller must g_strfreev() the result.
|
||||
char**
|
||||
ff_split_meta(const char* meta)
|
||||
{
|
||||
GPtrArray* arr = g_ptr_array_new();
|
||||
const char* start = meta;
|
||||
for (const char* p = meta;; p++) {
|
||||
if (*p == '\\' && *(p + 1)) {
|
||||
p++; // skip escaped char
|
||||
continue;
|
||||
}
|
||||
if (*p == '|' || *p == '\0') {
|
||||
g_ptr_array_add(arr, g_strndup(start, p - start));
|
||||
if (*p == '\0')
|
||||
break;
|
||||
start = p + 1;
|
||||
}
|
||||
}
|
||||
g_ptr_array_add(arr, NULL);
|
||||
return (char**)g_ptr_array_free(arr, FALSE);
|
||||
}
|
||||
|
||||
// Find first unescaped ": " (colon-space) in a string.
|
||||
const char*
|
||||
ff_find_unescaped_colonspace(const char* str)
|
||||
{
|
||||
if (!str)
|
||||
return NULL;
|
||||
for (const char* p = str; *p; p++) {
|
||||
if (*p == '\\' && *(p + 1)) {
|
||||
p++; // skip escaped character
|
||||
continue;
|
||||
}
|
||||
if (*p == ':' && *(p + 1) == ' ') {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// Unescape a sender resource: "\\" -> '\', "\: " -> ": "
|
||||
char*
|
||||
ff_unescape_sender_resource(const char* res)
|
||||
{
|
||||
if (!res)
|
||||
return NULL;
|
||||
GString* out = g_string_sized_new(strlen(res));
|
||||
for (const char* p = res; *p; p++) {
|
||||
if (*p == '\\' && *(p + 1)) {
|
||||
p++;
|
||||
if (*p == '\\') {
|
||||
g_string_append_c(out, '\\');
|
||||
} else if (*p == ':' && *(p + 1) == ' ') {
|
||||
g_string_append(out, ": ");
|
||||
p++; // skip the space
|
||||
} else {
|
||||
// Unknown escape — preserve literally
|
||||
g_string_append_c(out, '\\');
|
||||
g_string_append_c(out, *p);
|
||||
}
|
||||
} else {
|
||||
g_string_append_c(out, *p);
|
||||
}
|
||||
}
|
||||
return g_string_free(out, FALSE);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Line parser
|
||||
// =========================================================================
|
||||
|
||||
void
|
||||
ff_parsed_line_free(ff_parsed_line_t* pl)
|
||||
{
|
||||
if (!pl)
|
||||
return;
|
||||
g_free(pl->timestamp_str);
|
||||
if (pl->timestamp)
|
||||
g_date_time_unref(pl->timestamp);
|
||||
g_free(pl->type);
|
||||
g_free(pl->enc);
|
||||
g_free(pl->stanza_id);
|
||||
g_free(pl->archive_id);
|
||||
g_free(pl->replace_id);
|
||||
g_free(pl->from_jid);
|
||||
g_free(pl->from_resource);
|
||||
g_free(pl->to_jid);
|
||||
g_free(pl->to_resource);
|
||||
g_free(pl->message);
|
||||
g_free(pl);
|
||||
}
|
||||
|
||||
// Parse a single line. Returns NULL on parse failure.
|
||||
// Line format: {timestamp} [{metadata}] {sender}: {message}
|
||||
ff_parsed_line_t*
|
||||
ff_parse_line(const char* line)
|
||||
{
|
||||
if (!line || line[0] == '\0' || line[0] == '#') {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// Strip trailing \r if present (CRLF handling)
|
||||
char* work = g_strdup(line);
|
||||
gsize len = strlen(work);
|
||||
if (len > 0 && work[len - 1] == '\r') {
|
||||
work[len - 1] = '\0';
|
||||
len--;
|
||||
}
|
||||
if (len == 0) {
|
||||
g_free(work);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// UTF-8 validation
|
||||
const gchar* end;
|
||||
if (!g_utf8_validate(work, -1, &end)) {
|
||||
log_warning("flatfile: invalid UTF-8 at byte offset %ld", (long)(end - work));
|
||||
// Attempt Latin-1 fallback
|
||||
gsize br, bw;
|
||||
GError* err = NULL;
|
||||
char* converted = g_convert(work, -1, "UTF-8", "ISO-8859-1", &br, &bw, &err);
|
||||
if (converted) {
|
||||
g_free(work);
|
||||
work = converted;
|
||||
} else {
|
||||
if (err)
|
||||
g_error_free(err);
|
||||
g_free(work);
|
||||
return NULL;
|
||||
}
|
||||
}
|
||||
|
||||
ff_parsed_line_t* result = g_malloc0(sizeof(ff_parsed_line_t));
|
||||
result->file_offset = -1;
|
||||
result->marked_read = -1; // unset by default
|
||||
|
||||
// Parse timestamp — everything up to first space followed by '['
|
||||
char* bracket_start = strchr(work, '[');
|
||||
char* first_space = strchr(work, ' ');
|
||||
|
||||
if (bracket_start && first_space && first_space < bracket_start) {
|
||||
// Standard format with metadata: {timestamp} [{meta}] {sender}: {msg}
|
||||
result->timestamp_str = g_strndup(work, first_space - work);
|
||||
|
||||
// Parse metadata section [...]
|
||||
const char* bracket_end = ff_find_unescaped_char(bracket_start + 1, ']');
|
||||
if (bracket_end) {
|
||||
char* meta_content = g_strndup(bracket_start + 1, bracket_end - bracket_start - 1);
|
||||
|
||||
// Split by unescaped '|'
|
||||
char** parts = ff_split_meta(meta_content);
|
||||
if (parts) {
|
||||
int i = 0;
|
||||
for (; parts[i]; i++) {
|
||||
if (i == 0) {
|
||||
result->type = g_strdup(parts[i]);
|
||||
} else if (i == 1) {
|
||||
result->enc = g_strdup(parts[i]);
|
||||
} else if (g_str_has_prefix(parts[i], "id:")) {
|
||||
result->stanza_id = ff_unescape_meta_value(parts[i] + 3);
|
||||
} else if (g_str_has_prefix(parts[i], "aid:")) {
|
||||
result->archive_id = ff_unescape_meta_value(parts[i] + 4);
|
||||
} else if (g_str_has_prefix(parts[i], "corrects:")) {
|
||||
result->replace_id = ff_unescape_meta_value(parts[i] + 9);
|
||||
} else if (g_str_has_prefix(parts[i], "to:")) {
|
||||
result->to_jid = ff_unescape_meta_value(parts[i] + 3);
|
||||
} else if (g_str_has_prefix(parts[i], "to_res:")) {
|
||||
result->to_resource = ff_unescape_meta_value(parts[i] + 7);
|
||||
} else if (g_str_has_prefix(parts[i], "read:")) {
|
||||
result->marked_read = atoi(parts[i] + 5) ? 1 : 0;
|
||||
}
|
||||
}
|
||||
g_strfreev(parts);
|
||||
}
|
||||
g_free(meta_content);
|
||||
|
||||
// Parse sender: message after '] '
|
||||
const char* after_meta = bracket_end + 1;
|
||||
if (*after_meta == ' ')
|
||||
after_meta++;
|
||||
|
||||
// Find first *unescaped* ': ' which separates sender from message.
|
||||
const char* colon = ff_find_unescaped_colonspace(after_meta);
|
||||
if (colon) {
|
||||
char* raw_sender = g_strndup(after_meta, colon - after_meta);
|
||||
|
||||
// Split sender into jid/resource, then unescape resource
|
||||
char* slash = strchr(raw_sender, '/');
|
||||
if (slash) {
|
||||
result->from_jid = g_strndup(raw_sender, slash - raw_sender);
|
||||
result->from_resource = ff_unescape_sender_resource(slash + 1);
|
||||
} else {
|
||||
result->from_jid = g_strdup(raw_sender);
|
||||
}
|
||||
g_free(raw_sender);
|
||||
|
||||
result->message = ff_unescape_message(colon + 2);
|
||||
} else {
|
||||
// No ': ' found, treat entire rest as message with unknown sender
|
||||
result->from_jid = g_strdup("unknown");
|
||||
result->message = ff_unescape_message(after_meta);
|
||||
}
|
||||
} else {
|
||||
// No closing bracket — malformed metadata
|
||||
ff_parsed_line_free(result);
|
||||
g_free(work);
|
||||
return NULL;
|
||||
}
|
||||
} else if (first_space) {
|
||||
// Legacy/simple format without metadata: {timestamp} - {sender}: {msg}
|
||||
result->timestamp_str = g_strndup(work, first_space - work);
|
||||
result->type = g_strdup("chat");
|
||||
result->enc = g_strdup("none");
|
||||
|
||||
char* rest = first_space + 1;
|
||||
// Skip " - " if present (chatlog.c format)
|
||||
if (g_str_has_prefix(rest, "- ")) {
|
||||
rest += 2;
|
||||
}
|
||||
|
||||
char* colon = strstr(rest, ": ");
|
||||
if (colon) {
|
||||
char* sender = g_strndup(rest, colon - rest);
|
||||
char* slash = strchr(sender, '/');
|
||||
if (slash) {
|
||||
result->from_jid = g_strndup(sender, slash - sender);
|
||||
result->from_resource = g_strdup(slash + 1);
|
||||
} else {
|
||||
result->from_jid = g_strdup(sender);
|
||||
}
|
||||
g_free(sender);
|
||||
result->message = ff_unescape_message(colon + 2);
|
||||
} else {
|
||||
result->from_jid = g_strdup("unknown");
|
||||
result->message = ff_unescape_message(rest);
|
||||
}
|
||||
} else {
|
||||
// No space at all — can't parse
|
||||
ff_parsed_line_free(result);
|
||||
g_free(work);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// Parse timestamp
|
||||
result->timestamp = g_date_time_new_from_iso8601(result->timestamp_str, NULL);
|
||||
if (!result->timestamp) {
|
||||
log_warning("flatfile: unparsable timestamp: %s", result->timestamp_str);
|
||||
ff_parsed_line_free(result);
|
||||
g_free(work);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// Default type/enc if missing
|
||||
if (!result->type)
|
||||
result->type = g_strdup("chat");
|
||||
if (!result->enc)
|
||||
result->enc = g_strdup("none");
|
||||
|
||||
g_free(work);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Convert parsed line to ProfMessage
|
||||
ProfMessage*
|
||||
ff_parsed_to_profmessage(ff_parsed_line_t* pl)
|
||||
{
|
||||
ProfMessage* msg = message_init();
|
||||
msg->id = pl->stanza_id ? g_strdup(pl->stanza_id) : NULL;
|
||||
msg->from_jid = jid_create_from_bare_and_resource(pl->from_jid, pl->from_resource);
|
||||
if (pl->to_jid) {
|
||||
msg->to_jid = jid_create_from_bare_and_resource(pl->to_jid, pl->to_resource);
|
||||
}
|
||||
msg->plain = g_strdup(pl->message ? pl->message : "");
|
||||
msg->timestamp = g_date_time_ref(pl->timestamp);
|
||||
msg->type = ff_get_message_type_type(pl->type);
|
||||
msg->enc = ff_get_message_enc_type(pl->enc);
|
||||
return msg;
|
||||
}
|
||||
309
src/database_flatfile_verify.c
Normal file
309
src/database_flatfile_verify.c
Normal file
@@ -0,0 +1,309 @@
|
||||
/*
|
||||
* database_flatfile_verify.c
|
||||
* vim: expandtab:ts=4:sts=4:sw=4
|
||||
*
|
||||
* Copyright (C) 2026 Profanity Contributors
|
||||
*
|
||||
* This file is part of Profanity.
|
||||
*
|
||||
* Profanity is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Profanity is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Profanity. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* Flat-file backend: integrity verification (/history verify).
|
||||
* Checks: parsability, timestamp ordering, duplicate IDs, broken LMC
|
||||
* references, file permissions, BOM, CRLF, UTF-8, control chars.
|
||||
*/
|
||||
|
||||
#include "config.h"
|
||||
|
||||
#include <sys/stat.h>
|
||||
#include <glib.h>
|
||||
#include <glib/gstdio.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "log.h"
|
||||
#include "config/files.h"
|
||||
#include "database_flatfile.h"
|
||||
|
||||
GSList*
|
||||
ff_verify_integrity(const gchar* const contact_barejid)
|
||||
{
|
||||
GSList* issues = NULL;
|
||||
|
||||
if (!g_flatfile_account_jid) {
|
||||
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
|
||||
issue->level = INTEGRITY_ERROR;
|
||||
issue->file = g_strdup("N/A");
|
||||
issue->line = 0;
|
||||
issue->message = g_strdup("Flat-file backend not initialized");
|
||||
issues = g_slist_prepend(issues, issue);
|
||||
return issues;
|
||||
}
|
||||
|
||||
// If contact specified, verify just that contact; otherwise discover all contacts
|
||||
GSList* contact_dirs = NULL;
|
||||
|
||||
if (contact_barejid) {
|
||||
auto_gchar gchar* cdir = ff_get_contact_dir(contact_barejid);
|
||||
if (cdir && g_file_test(cdir, G_FILE_TEST_IS_DIR)) {
|
||||
contact_dirs = g_slist_prepend(contact_dirs, g_strdup(cdir));
|
||||
} else {
|
||||
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
|
||||
issue->level = INTEGRITY_INFO;
|
||||
issue->file = g_strdup(contact_barejid);
|
||||
issue->line = 0;
|
||||
issue->message = g_strdup("No log files found for this contact");
|
||||
issues = g_slist_prepend(issues, issue);
|
||||
return issues;
|
||||
}
|
||||
} else {
|
||||
// Discover all contact directories
|
||||
auto_gchar gchar* data_path = files_get_data_path(DIR_FLATLOG);
|
||||
auto_gchar gchar* my_dir = ff_jid_to_dir(g_flatfile_account_jid);
|
||||
auto_gchar gchar* base_dir = g_strdup_printf("%s/%s", data_path, my_dir);
|
||||
|
||||
GDir* dir = g_dir_open(base_dir, 0, NULL);
|
||||
if (dir) {
|
||||
const gchar* dname;
|
||||
while ((dname = g_dir_read_name(dir)) != NULL) {
|
||||
char* full = g_strdup_printf("%s/%s", base_dir, dname);
|
||||
if (g_file_test(full, G_FILE_TEST_IS_DIR)) {
|
||||
contact_dirs = g_slist_prepend(contact_dirs, full);
|
||||
} else {
|
||||
g_free(full);
|
||||
}
|
||||
}
|
||||
g_dir_close(dir);
|
||||
}
|
||||
}
|
||||
|
||||
// Verify each contact directory (single history.log per contact)
|
||||
for (GSList* cd = contact_dirs; cd; cd = cd->next) {
|
||||
const char* cdir_path = cd->data;
|
||||
|
||||
auto_gchar gchar* filepath = g_strdup_printf("%s/history.log", cdir_path);
|
||||
if (!g_file_test(filepath, G_FILE_TEST_EXISTS))
|
||||
continue;
|
||||
|
||||
const char* basename = "history.log";
|
||||
|
||||
// Check file permissions
|
||||
struct stat st;
|
||||
if (g_stat(filepath, &st) == 0) {
|
||||
if ((st.st_mode & 0777) != (S_IRUSR | S_IWUSR)) {
|
||||
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
|
||||
issue->level = INTEGRITY_WARNING;
|
||||
issue->file = g_strdup(basename);
|
||||
issue->line = 0;
|
||||
issue->message = g_strdup_printf("File permissions are %o, expected 600 (sensitive data)", st.st_mode & 0777);
|
||||
issues = g_slist_prepend(issues, issue);
|
||||
}
|
||||
}
|
||||
|
||||
FILE* fp = fopen(filepath, "r");
|
||||
if (!fp)
|
||||
continue;
|
||||
|
||||
// BOM check
|
||||
int c1 = fgetc(fp);
|
||||
int c2 = fgetc(fp);
|
||||
int c3 = fgetc(fp);
|
||||
if (c1 == 0xEF && c2 == 0xBB && c3 == 0xBF) {
|
||||
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
|
||||
issue->level = INTEGRITY_INFO;
|
||||
issue->file = g_strdup(basename);
|
||||
issue->line = 0;
|
||||
issue->message = g_strdup("File has UTF-8 BOM — harmless but unnecessary");
|
||||
issues = g_slist_prepend(issues, issue);
|
||||
} else {
|
||||
fseek(fp, 0, SEEK_SET);
|
||||
}
|
||||
|
||||
char* buf = NULL;
|
||||
int lineno = 0;
|
||||
GDateTime* prev_ts = NULL;
|
||||
GHashTable* seen_ids = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL);
|
||||
GHashTable* all_stanza_ids = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL);
|
||||
gboolean has_crlf = FALSE;
|
||||
gboolean is_empty = TRUE;
|
||||
|
||||
while ((buf = ff_readline(fp, NULL)) != NULL) {
|
||||
lineno++;
|
||||
gsize len = strlen(buf);
|
||||
|
||||
if (len > 0 && buf[len - 1] == '\r') {
|
||||
has_crlf = TRUE;
|
||||
buf[--len] = '\0';
|
||||
}
|
||||
|
||||
if (len == 0 || buf[0] == '#') {
|
||||
free(buf);
|
||||
continue;
|
||||
}
|
||||
is_empty = FALSE;
|
||||
|
||||
const gchar* end;
|
||||
if (!g_utf8_validate(buf, -1, &end)) {
|
||||
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
|
||||
issue->level = INTEGRITY_ERROR;
|
||||
issue->file = g_strdup(basename);
|
||||
issue->line = lineno;
|
||||
issue->message = g_strdup_printf("Invalid UTF-8 at byte offset %ld", (long)(end - buf));
|
||||
issues = g_slist_prepend(issues, issue);
|
||||
free(buf);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (gsize i = 0; i < len; i++) {
|
||||
unsigned char ch = (unsigned char)buf[i];
|
||||
if (ch < 0x20 && ch != '\t') {
|
||||
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
|
||||
issue->level = INTEGRITY_WARNING;
|
||||
issue->file = g_strdup(basename);
|
||||
issue->line = lineno;
|
||||
issue->message = g_strdup_printf("Contains control character 0x%02x", ch);
|
||||
issues = g_slist_prepend(issues, issue);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
ff_parsed_line_t* pl = ff_parse_line(buf);
|
||||
if (!pl) {
|
||||
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
|
||||
issue->level = INTEGRITY_ERROR;
|
||||
issue->file = g_strdup(basename);
|
||||
issue->line = lineno;
|
||||
issue->message = g_strdup("Unparsable line");
|
||||
issues = g_slist_prepend(issues, issue);
|
||||
free(buf);
|
||||
continue;
|
||||
}
|
||||
|
||||
free(buf);
|
||||
|
||||
// Timestamp ordering
|
||||
if (prev_ts && g_date_time_compare(pl->timestamp, prev_ts) < 0) {
|
||||
auto_gchar gchar* ts_cur = g_date_time_format_iso8601(pl->timestamp);
|
||||
auto_gchar gchar* ts_prev = g_date_time_format_iso8601(prev_ts);
|
||||
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
|
||||
issue->level = INTEGRITY_WARNING;
|
||||
issue->file = g_strdup(basename);
|
||||
issue->line = lineno;
|
||||
issue->message = g_strdup_printf("Timestamp out of order (%s after %s)", ts_cur, ts_prev);
|
||||
issues = g_slist_prepend(issues, issue);
|
||||
}
|
||||
if (prev_ts)
|
||||
g_date_time_unref(prev_ts);
|
||||
prev_ts = g_date_time_ref(pl->timestamp);
|
||||
|
||||
// Duplicate stanza-id / archive-id
|
||||
if (pl->stanza_id && strlen(pl->stanza_id) > 0) {
|
||||
if (g_hash_table_contains(seen_ids, pl->stanza_id)) {
|
||||
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
|
||||
issue->level = INTEGRITY_WARNING;
|
||||
issue->file = g_strdup(basename);
|
||||
issue->line = lineno;
|
||||
issue->message = g_strdup_printf("Duplicate stanza-id \"%s\"", pl->stanza_id);
|
||||
issues = g_slist_prepend(issues, issue);
|
||||
} else {
|
||||
g_hash_table_insert(seen_ids, g_strdup(pl->stanza_id), GINT_TO_POINTER(lineno));
|
||||
}
|
||||
g_hash_table_insert(all_stanza_ids, g_strdup(pl->stanza_id), GINT_TO_POINTER(lineno));
|
||||
}
|
||||
if (pl->archive_id && strlen(pl->archive_id) > 0) {
|
||||
if (g_hash_table_contains(seen_ids, pl->archive_id)) {
|
||||
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
|
||||
issue->level = INTEGRITY_WARNING;
|
||||
issue->file = g_strdup(basename);
|
||||
issue->line = lineno;
|
||||
issue->message = g_strdup_printf("Duplicate archive-id \"%s\"", pl->archive_id);
|
||||
issues = g_slist_prepend(issues, issue);
|
||||
} else {
|
||||
g_hash_table_insert(seen_ids, g_strdup(pl->archive_id), GINT_TO_POINTER(lineno));
|
||||
}
|
||||
}
|
||||
|
||||
ff_parsed_line_free(pl);
|
||||
}
|
||||
|
||||
fclose(fp);
|
||||
|
||||
if (has_crlf) {
|
||||
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
|
||||
issue->level = INTEGRITY_WARNING;
|
||||
issue->file = g_strdup(basename);
|
||||
issue->line = 0;
|
||||
issue->message = g_strdup("File uses Windows line endings (CRLF) — consider converting to LF");
|
||||
issues = g_slist_prepend(issues, issue);
|
||||
}
|
||||
|
||||
if (is_empty) {
|
||||
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
|
||||
issue->level = INTEGRITY_INFO;
|
||||
issue->file = g_strdup(basename);
|
||||
issue->line = 0;
|
||||
issue->message = g_strdup("File is empty (no message lines)");
|
||||
issues = g_slist_prepend(issues, issue);
|
||||
}
|
||||
|
||||
// Second pass: check LMC references
|
||||
fp = fopen(filepath, "r");
|
||||
if (fp) {
|
||||
int b1 = fgetc(fp);
|
||||
int b2 = fgetc(fp);
|
||||
int b3 = fgetc(fp);
|
||||
if (!(b1 == 0xEF && b2 == 0xBB && b3 == 0xBF))
|
||||
fseek(fp, 0, SEEK_SET);
|
||||
|
||||
lineno = 0;
|
||||
while ((buf = ff_readline(fp, NULL)) != NULL) {
|
||||
lineno++;
|
||||
gsize len = strlen(buf);
|
||||
if (len > 0 && buf[len - 1] == '\r')
|
||||
buf[--len] = '\0';
|
||||
if (len == 0 || buf[0] == '#') {
|
||||
free(buf);
|
||||
continue;
|
||||
}
|
||||
|
||||
ff_parsed_line_t* pl = ff_parse_line(buf);
|
||||
free(buf);
|
||||
if (!pl)
|
||||
continue;
|
||||
|
||||
if (pl->replace_id && strlen(pl->replace_id) > 0) {
|
||||
if (!g_hash_table_contains(all_stanza_ids, pl->replace_id)) {
|
||||
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
|
||||
issue->level = INTEGRITY_ERROR;
|
||||
issue->file = g_strdup(basename);
|
||||
issue->line = lineno;
|
||||
issue->message = g_strdup_printf("Broken correction reference: corrects:%s not found", pl->replace_id);
|
||||
issues = g_slist_prepend(issues, issue);
|
||||
}
|
||||
}
|
||||
ff_parsed_line_free(pl);
|
||||
}
|
||||
fclose(fp);
|
||||
}
|
||||
|
||||
if (prev_ts)
|
||||
g_date_time_unref(prev_ts);
|
||||
g_hash_table_destroy(seen_ids);
|
||||
g_hash_table_destroy(all_stanza_ids);
|
||||
}
|
||||
|
||||
g_slist_free_full(contact_dirs, g_free);
|
||||
return g_slist_reverse(issues);
|
||||
}
|
||||
966
src/database_sqlite.c
Normal file
966
src/database_sqlite.c
Normal file
@@ -0,0 +1,966 @@
|
||||
/*
|
||||
* database_sqlite.c
|
||||
* vim: expandtab:ts=4:sts=4:sw=4
|
||||
*
|
||||
* Copyright (C) 2020 - 2025 Michael Vetter <jubalh@iodoru.org>
|
||||
*
|
||||
* This file is part of Profanity.
|
||||
*
|
||||
* Profanity is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Profanity is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Profanity. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* In addition, as a special exception, the copyright holders give permission to
|
||||
* link the code of portions of this program with the OpenSSL library under
|
||||
* certain conditions as described in each individual source file, and
|
||||
* distribute linked combinations including the two.
|
||||
*
|
||||
* You must obey the GNU General Public License in all respects for all of the
|
||||
* code used other than OpenSSL. If you modify file(s) with this exception, you
|
||||
* may extend this exception to your version of the file(s), but you are not
|
||||
* obligated to do so. If you do not wish to do so, delete this exception
|
||||
* statement from your version. If you delete this exception statement from all
|
||||
* source files in the program, then also delete it here.
|
||||
*
|
||||
*/
|
||||
|
||||
#include "config.h"
|
||||
|
||||
#include <sys/stat.h>
|
||||
#include <sys/statvfs.h>
|
||||
#include <sqlite3.h>
|
||||
#include <glib.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <errno.h>
|
||||
|
||||
#include "log.h"
|
||||
#include "common.h"
|
||||
#include "config/files.h"
|
||||
#include "database.h"
|
||||
#include "config/preferences.h"
|
||||
#include "ui/ui.h"
|
||||
#include "xmpp/xmpp.h"
|
||||
#include "xmpp/message.h"
|
||||
|
||||
static sqlite3* g_chatlog_database;
|
||||
|
||||
static void _add_to_db(ProfMessage* message, const char* type, const Jid* const from_jid, const Jid* const to_jid);
|
||||
static char* _get_db_filename(ProfAccount* account);
|
||||
static prof_msg_type_t _get_message_type_type(const char* const type);
|
||||
static prof_enc_t _get_message_enc_type(const char* const encstr);
|
||||
static int _get_db_version(void);
|
||||
static gboolean _migrate_to_v2(void);
|
||||
static gboolean _check_available_space_for_db_migration(char* path_to_db);
|
||||
|
||||
static const int latest_version = 2;
|
||||
|
||||
// Helper: close DB handle (if any), warn on busy, and shutdown SQLite
|
||||
static void
|
||||
_db_teardown(const char* ctx)
|
||||
{
|
||||
if (g_chatlog_database) {
|
||||
int rc = sqlite3_close_v2(g_chatlog_database);
|
||||
if (rc != SQLITE_OK) {
|
||||
log_warning("sqlite3_close_v2 in %s returned %d; database may still have active statements.",
|
||||
ctx ? ctx : "db_teardown", rc);
|
||||
}
|
||||
g_chatlog_database = NULL;
|
||||
}
|
||||
sqlite3_shutdown();
|
||||
}
|
||||
|
||||
// Helper: prepare a statement and log a contextual error on failure
|
||||
static gboolean
|
||||
_db_prepare_ctx(const char* query, sqlite3_stmt** stmt, const char* ctx)
|
||||
{
|
||||
int rc = sqlite3_prepare_v2(g_chatlog_database, query, -1, stmt, NULL);
|
||||
if (rc != SQLITE_OK) {
|
||||
log_error("SQLite error in %s: (error code: %d) %s",
|
||||
ctx ? ctx : "sqlite3_prepare_v2",
|
||||
rc,
|
||||
sqlite3_errmsg(g_chatlog_database));
|
||||
return FALSE;
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
static char*
|
||||
_db_strdup(const char* str)
|
||||
{
|
||||
return str ? strdup(str) : NULL;
|
||||
}
|
||||
|
||||
#define auto_sqlite __attribute__((__cleanup__(auto_free_sqlite)))
|
||||
|
||||
static void
|
||||
auto_free_sqlite(gchar** str)
|
||||
{
|
||||
if (str == NULL)
|
||||
return;
|
||||
sqlite3_free(*str);
|
||||
}
|
||||
|
||||
static char*
|
||||
_get_db_filename(ProfAccount* account)
|
||||
{
|
||||
return files_file_in_account_data_path(DIR_DATABASE, account->jid, "chatlog.db");
|
||||
}
|
||||
|
||||
static gboolean
|
||||
_sqlite_init(ProfAccount* account)
|
||||
{
|
||||
int ret = sqlite3_initialize();
|
||||
if (ret != SQLITE_OK) {
|
||||
log_error("Error initializing SQLite database: %d", ret);
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
auto_char char* filename = _get_db_filename(account);
|
||||
if (!filename) {
|
||||
sqlite3_shutdown();
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
ret = sqlite3_open(filename, &g_chatlog_database);
|
||||
if (ret != SQLITE_OK) {
|
||||
const char* err_msg = g_chatlog_database ? sqlite3_errmsg(g_chatlog_database) : "(no handle)";
|
||||
log_error("Error opening SQLite database: %s", err_msg);
|
||||
_db_teardown("_sqlite_init(open)");
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
char* err_msg = NULL;
|
||||
|
||||
int db_version = _get_db_version();
|
||||
if (db_version == latest_version) {
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
const char* query = "CREATE TABLE IF NOT EXISTS `ChatLogs` ("
|
||||
"`id` INTEGER PRIMARY KEY AUTOINCREMENT, "
|
||||
"`from_jid` TEXT NOT NULL, "
|
||||
"`to_jid` TEXT NOT NULL, "
|
||||
"`from_resource` TEXT, "
|
||||
"`to_resource` TEXT, "
|
||||
"`message` TEXT, "
|
||||
"`timestamp` TEXT, "
|
||||
"`type` TEXT, "
|
||||
"`stanza_id` TEXT, "
|
||||
"`archive_id` TEXT, "
|
||||
"`encryption` TEXT, "
|
||||
"`marked_read` INTEGER, "
|
||||
"`replace_id` TEXT, "
|
||||
"`replaces_db_id` INTEGER, "
|
||||
"`replaced_by_db_id` INTEGER)";
|
||||
if (SQLITE_OK != sqlite3_exec(g_chatlog_database, query, NULL, 0, &err_msg)) {
|
||||
goto out;
|
||||
}
|
||||
|
||||
query = "CREATE TRIGGER IF NOT EXISTS update_corrected_message "
|
||||
"AFTER INSERT ON ChatLogs "
|
||||
"FOR EACH ROW "
|
||||
"WHEN NEW.replaces_db_id IS NOT NULL "
|
||||
"BEGIN "
|
||||
"UPDATE ChatLogs "
|
||||
"SET replaced_by_db_id = NEW.id "
|
||||
"WHERE id = NEW.replaces_db_id; "
|
||||
"END;";
|
||||
if (SQLITE_OK != sqlite3_exec(g_chatlog_database, query, NULL, 0, &err_msg)) {
|
||||
log_error("Unable to add `update_corrected_message` trigger.");
|
||||
goto out;
|
||||
}
|
||||
|
||||
query = "CREATE INDEX IF NOT EXISTS ChatLogs_timestamp_IDX ON `ChatLogs` (`timestamp`)";
|
||||
if (SQLITE_OK != sqlite3_exec(g_chatlog_database, query, NULL, 0, &err_msg)) {
|
||||
log_error("Unable to create index for timestamp.");
|
||||
goto out;
|
||||
}
|
||||
query = "CREATE INDEX IF NOT EXISTS ChatLogs_to_from_jid_IDX ON `ChatLogs` (`to_jid`, `from_jid`)";
|
||||
if (SQLITE_OK != sqlite3_exec(g_chatlog_database, query, NULL, 0, &err_msg)) {
|
||||
log_error("Unable to create index for to_jid.");
|
||||
goto out;
|
||||
}
|
||||
|
||||
query = "CREATE TABLE IF NOT EXISTS `DbVersion` (`dv_id` INTEGER PRIMARY KEY, `version` INTEGER UNIQUE)";
|
||||
if (SQLITE_OK != sqlite3_exec(g_chatlog_database, query, NULL, 0, &err_msg)) {
|
||||
goto out;
|
||||
}
|
||||
|
||||
if (db_version == -1) {
|
||||
query = "INSERT OR IGNORE INTO `DbVersion` (`version`) VALUES ('2')";
|
||||
if (SQLITE_OK != sqlite3_exec(g_chatlog_database, query, NULL, 0, &err_msg)) {
|
||||
goto out;
|
||||
}
|
||||
db_version = _get_db_version();
|
||||
}
|
||||
|
||||
if (db_version == -1) {
|
||||
cons_show_error("DB Initialization Error: Unable to check DB version.");
|
||||
goto out;
|
||||
}
|
||||
|
||||
if (db_version < latest_version) {
|
||||
cons_show("Migrating database schema. This operation may take a while...");
|
||||
if (db_version < 2 && (!_check_available_space_for_db_migration(filename) || !_migrate_to_v2())) {
|
||||
cons_show_error("Database Initialization Error: Unable to migrate database to version 2. Please, check error logs for details.");
|
||||
goto out;
|
||||
}
|
||||
cons_show("Database schema migration was successful.");
|
||||
}
|
||||
|
||||
log_debug("Initialized SQLite database: %s", filename);
|
||||
return TRUE;
|
||||
|
||||
out:
|
||||
if (err_msg) {
|
||||
log_error("SQLite error in _sqlite_init(): %s", err_msg);
|
||||
sqlite3_free(err_msg);
|
||||
} else {
|
||||
log_error("Unknown SQLite error in _sqlite_init().");
|
||||
}
|
||||
_db_teardown("_sqlite_init(out)");
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
static void
|
||||
_sqlite_close(void)
|
||||
{
|
||||
log_debug("_sqlite_close() called");
|
||||
_db_teardown("_sqlite_close");
|
||||
}
|
||||
|
||||
static void
|
||||
_sqlite_add_incoming(ProfMessage* message)
|
||||
{
|
||||
if (message->to_jid) {
|
||||
_add_to_db(message, NULL, message->from_jid, message->to_jid);
|
||||
} else {
|
||||
_add_to_db(message, NULL, message->from_jid, connection_get_jid());
|
||||
}
|
||||
}
|
||||
|
||||
static void
|
||||
_log_database_add_outgoing(const char* type, const char* const id, const char* const barejid, const char* const message, const char* const replace_id, prof_enc_t enc)
|
||||
{
|
||||
ProfMessage* msg = message_init();
|
||||
|
||||
msg->id = _db_strdup(id);
|
||||
msg->from_jid = jid_create(barejid);
|
||||
msg->plain = _db_strdup(message);
|
||||
msg->replace_id = _db_strdup(replace_id);
|
||||
msg->timestamp = g_date_time_new_now_local();
|
||||
msg->enc = enc;
|
||||
|
||||
_add_to_db(msg, type, connection_get_jid(), msg->from_jid);
|
||||
|
||||
message_free(msg);
|
||||
}
|
||||
|
||||
static void
|
||||
_sqlite_add_outgoing_chat(const char* const id, const char* const barejid, const char* const message, const char* const replace_id, prof_enc_t enc)
|
||||
{
|
||||
_log_database_add_outgoing("chat", id, barejid, message, replace_id, enc);
|
||||
}
|
||||
|
||||
static void
|
||||
_sqlite_add_outgoing_muc(const char* const id, const char* const barejid, const char* const message, const char* const replace_id, prof_enc_t enc)
|
||||
{
|
||||
_log_database_add_outgoing("muc", id, barejid, message, replace_id, enc);
|
||||
}
|
||||
|
||||
static void
|
||||
_sqlite_add_outgoing_muc_pm(const char* const id, const char* const barejid, const char* const message, const char* const replace_id, prof_enc_t enc)
|
||||
{
|
||||
_log_database_add_outgoing("mucpm", id, barejid, message, replace_id, enc);
|
||||
}
|
||||
|
||||
static ProfMessage*
|
||||
_sqlite_get_limits_info(const gchar* const contact_barejid, gboolean is_last)
|
||||
{
|
||||
sqlite3_stmt* stmt = NULL;
|
||||
const Jid* myjid = connection_get_jid();
|
||||
ProfMessage* msg = message_init();
|
||||
if (!myjid || !myjid->str) {
|
||||
if (is_last) {
|
||||
msg->timestamp = g_date_time_new_now_utc();
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
const char* order = is_last ? "DESC" : "ASC";
|
||||
auto_sqlite char* query = sqlite3_mprintf("SELECT `archive_id`, `timestamp` FROM `ChatLogs` WHERE "
|
||||
"(`from_jid` = %Q AND `to_jid` = %Q) OR "
|
||||
"(`from_jid` = %Q AND `to_jid` = %Q) "
|
||||
"ORDER BY `timestamp` %s LIMIT 1;",
|
||||
contact_barejid, myjid->barejid, myjid->barejid, contact_barejid, order);
|
||||
|
||||
if (!query) {
|
||||
log_error("Could not allocate memory for SQL query in _sqlite_get_limits_info()");
|
||||
if (is_last) {
|
||||
msg->timestamp = g_date_time_new_now_utc();
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
if (!_db_prepare_ctx(query, &stmt, "_sqlite_get_limits_info()")) {
|
||||
if (is_last) {
|
||||
msg->timestamp = g_date_time_new_now_utc();
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
if (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
char* archive_id = (char*)sqlite3_column_text(stmt, 0);
|
||||
char* date = (char*)sqlite3_column_text(stmt, 1);
|
||||
|
||||
msg->stanzaid = _db_strdup(archive_id);
|
||||
msg->timestamp = g_date_time_new_from_iso8601(date, NULL);
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
if (!msg->timestamp && is_last) {
|
||||
msg->timestamp = g_date_time_new_now_utc();
|
||||
}
|
||||
|
||||
return msg;
|
||||
}
|
||||
|
||||
static db_history_result_t
|
||||
_sqlite_get_previous_chat(const gchar* const contact_barejid, const gchar* start_time, const gchar* end_time, gboolean from_start, gboolean flip, GSList** result)
|
||||
{
|
||||
if (!g_chatlog_database) {
|
||||
log_warning("_sqlite_get_previous_chat() called but db is not initialized");
|
||||
return DB_RESPONSE_ERROR;
|
||||
}
|
||||
sqlite3_stmt* stmt = NULL;
|
||||
const Jid* myjid = connection_get_jid();
|
||||
if (!myjid->str) {
|
||||
log_warning("_sqlite_get_previous_chat() called but no connection detected.");
|
||||
return DB_RESPONSE_ERROR;
|
||||
}
|
||||
|
||||
const gchar* sort1 = from_start ? "ASC" : "DESC";
|
||||
const gchar* sort2 = !flip ? "ASC" : "DESC";
|
||||
GDateTime* now = g_date_time_new_now_local();
|
||||
auto_gchar gchar* end_date_fmt = end_time ? g_strdup(end_time) : g_date_time_format_iso8601(now);
|
||||
auto_sqlite gchar* query = sqlite3_mprintf("SELECT * FROM ("
|
||||
"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` 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)) "
|
||||
"AND A.`timestamp` < %Q "
|
||||
"AND (%Q IS NULL OR A.`timestamp` > %Q) "
|
||||
"ORDER BY A.`timestamp` %s LIMIT %d) "
|
||||
"ORDER BY `timestamp` %s;",
|
||||
contact_barejid, myjid->barejid, myjid->barejid, contact_barejid, end_date_fmt, start_time, start_time, sort1, MESSAGES_TO_RETRIEVE, sort2);
|
||||
|
||||
g_date_time_unref(now);
|
||||
|
||||
if (!query) {
|
||||
log_error("Could not allocate memory.");
|
||||
return DB_RESPONSE_ERROR;
|
||||
}
|
||||
|
||||
if (!_db_prepare_ctx(query, &stmt, "_sqlite_get_previous_chat()")) {
|
||||
return DB_RESPONSE_ERROR;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
ProfMessage* msg = message_init();
|
||||
msg->id = id ? strdup(id) : NULL;
|
||||
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 = strdup(message ?: "");
|
||||
msg->timestamp = g_date_time_new_from_iso8601(date, NULL);
|
||||
msg->type = _get_message_type_type(type);
|
||||
msg->enc = _get_message_enc_type(encryption);
|
||||
|
||||
*result = g_slist_append(*result, msg);
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
return g_slist_length(*result) != 0 ? DB_RESPONSE_SUCCESS : DB_RESPONSE_EMPTY;
|
||||
}
|
||||
|
||||
static GSList*
|
||||
_sqlite_verify_integrity(const gchar* const contact_barejid)
|
||||
{
|
||||
GSList* issues = NULL;
|
||||
|
||||
if (!g_chatlog_database) {
|
||||
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
|
||||
issue->level = INTEGRITY_ERROR;
|
||||
issue->file = g_strdup("chatlog.db");
|
||||
issue->line = 0;
|
||||
issue->message = g_strdup("Database not initialized");
|
||||
issues = g_slist_append(issues, issue);
|
||||
return issues;
|
||||
}
|
||||
|
||||
// PRAGMA integrity_check
|
||||
sqlite3_stmt* stmt = NULL;
|
||||
if (_db_prepare_ctx("PRAGMA integrity_check", &stmt, "_sqlite_verify_integrity()")) {
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
const char* result = (const char*)sqlite3_column_text(stmt, 0);
|
||||
if (g_strcmp0(result, "ok") != 0) {
|
||||
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
|
||||
issue->level = INTEGRITY_ERROR;
|
||||
issue->file = g_strdup("chatlog.db");
|
||||
issue->line = 0;
|
||||
issue->message = g_strdup_printf("SQLite integrity check: %s", result);
|
||||
issues = g_slist_append(issues, issue);
|
||||
}
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
}
|
||||
|
||||
// Check timestamp ordering for a specific contact or all
|
||||
const Jid* myjid = connection_get_jid();
|
||||
if (myjid && myjid->barejid) {
|
||||
auto_sqlite char* query = NULL;
|
||||
if (contact_barejid) {
|
||||
query = sqlite3_mprintf(
|
||||
"SELECT A.`id`, A.`timestamp`, B.`id`, B.`timestamp` FROM `ChatLogs` A "
|
||||
"JOIN `ChatLogs` B ON B.`id` = A.`id` + 1 "
|
||||
"WHERE A.`timestamp` > B.`timestamp` "
|
||||
"AND ((A.`from_jid` = %Q AND A.`to_jid` = %Q) OR (A.`from_jid` = %Q AND A.`to_jid` = %Q)) "
|
||||
"LIMIT 50",
|
||||
contact_barejid, myjid->barejid, myjid->barejid, contact_barejid);
|
||||
} else {
|
||||
query = sqlite3_mprintf(
|
||||
"SELECT A.`id`, A.`timestamp`, B.`id`, B.`timestamp` FROM `ChatLogs` A "
|
||||
"JOIN `ChatLogs` B ON B.`id` = A.`id` + 1 "
|
||||
"WHERE A.`timestamp` > B.`timestamp` "
|
||||
"LIMIT 50");
|
||||
}
|
||||
|
||||
if (query && _db_prepare_ctx(query, &stmt, "_sqlite_verify_integrity(timestamp_order)")) {
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
int id_a = sqlite3_column_int(stmt, 0);
|
||||
const char* ts_a = (const char*)sqlite3_column_text(stmt, 1);
|
||||
int id_b = sqlite3_column_int(stmt, 2);
|
||||
const char* ts_b = (const char*)sqlite3_column_text(stmt, 3);
|
||||
|
||||
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
|
||||
issue->level = INTEGRITY_WARNING;
|
||||
issue->file = g_strdup("chatlog.db");
|
||||
issue->line = id_a;
|
||||
issue->message = g_strdup_printf("Timestamp out of order: row %d (%s) > row %d (%s)",
|
||||
id_a, ts_a ? ts_a : "NULL", id_b, ts_b ? ts_b : "NULL");
|
||||
issues = g_slist_append(issues, issue);
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
}
|
||||
|
||||
// Check broken LMC references
|
||||
auto_sqlite char* lmc_query = sqlite3_mprintf(
|
||||
"SELECT A.`id`, A.`replaces_db_id` FROM `ChatLogs` A "
|
||||
"WHERE A.`replaces_db_id` IS NOT NULL "
|
||||
"AND NOT EXISTS (SELECT 1 FROM `ChatLogs` B WHERE B.`id` = A.`replaces_db_id`) "
|
||||
"LIMIT 50");
|
||||
|
||||
if (lmc_query && _db_prepare_ctx(lmc_query, &stmt, "_sqlite_verify_integrity(lmc_check)")) {
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
int id = sqlite3_column_int(stmt, 0);
|
||||
int replaces_id = sqlite3_column_int(stmt, 1);
|
||||
|
||||
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
|
||||
issue->level = INTEGRITY_ERROR;
|
||||
issue->file = g_strdup("chatlog.db");
|
||||
issue->line = id;
|
||||
issue->message = g_strdup_printf("Broken LMC reference: row %d references non-existent row %d",
|
||||
id, replaces_id);
|
||||
issues = g_slist_append(issues, issue);
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
// --- Type conversion helpers ---
|
||||
|
||||
static const char*
|
||||
_get_message_type_str(prof_msg_type_t type)
|
||||
{
|
||||
switch (type) {
|
||||
case PROF_MSG_TYPE_CHAT:
|
||||
return "chat";
|
||||
case PROF_MSG_TYPE_MUC:
|
||||
return "muc";
|
||||
case PROF_MSG_TYPE_MUCPM:
|
||||
return "mucpm";
|
||||
case PROF_MSG_TYPE_UNINITIALIZED:
|
||||
return NULL;
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static prof_msg_type_t
|
||||
_get_message_type_type(const char* const type)
|
||||
{
|
||||
if (g_strcmp0(type, "chat") == 0) {
|
||||
return PROF_MSG_TYPE_CHAT;
|
||||
} else if (g_strcmp0(type, "muc") == 0) {
|
||||
return PROF_MSG_TYPE_MUC;
|
||||
} else if (g_strcmp0(type, "mucpm") == 0) {
|
||||
return PROF_MSG_TYPE_MUCPM;
|
||||
} else {
|
||||
return PROF_MSG_TYPE_UNINITIALIZED;
|
||||
}
|
||||
}
|
||||
|
||||
static const char*
|
||||
_get_message_enc_str(prof_enc_t enc)
|
||||
{
|
||||
switch (enc) {
|
||||
case PROF_MSG_ENC_OX:
|
||||
return "ox";
|
||||
case PROF_MSG_ENC_PGP:
|
||||
return "pgp";
|
||||
case PROF_MSG_ENC_OTR:
|
||||
return "otr";
|
||||
case PROF_MSG_ENC_OMEMO:
|
||||
return "omemo";
|
||||
case PROF_MSG_ENC_NONE:
|
||||
return "none";
|
||||
}
|
||||
|
||||
return "none";
|
||||
}
|
||||
|
||||
static prof_enc_t
|
||||
_get_message_enc_type(const char* const encstr)
|
||||
{
|
||||
if (g_strcmp0(encstr, "ox") == 0) {
|
||||
return PROF_MSG_ENC_OX;
|
||||
} else if (g_strcmp0(encstr, "pgp") == 0) {
|
||||
return PROF_MSG_ENC_PGP;
|
||||
} else if (g_strcmp0(encstr, "otr") == 0) {
|
||||
return PROF_MSG_ENC_OTR;
|
||||
} else if (g_strcmp0(encstr, "omemo") == 0) {
|
||||
return PROF_MSG_ENC_OMEMO;
|
||||
}
|
||||
|
||||
return PROF_MSG_ENC_NONE;
|
||||
}
|
||||
|
||||
// --- Core write logic ---
|
||||
|
||||
static void
|
||||
_add_to_db(ProfMessage* message, const char* type, const Jid* const from_jid, const Jid* const to_jid)
|
||||
{
|
||||
auto_gchar gchar* pref_dblog = prefs_get_string(PREF_DBLOG);
|
||||
sqlite_int64 original_message_id = -1;
|
||||
|
||||
if (g_strcmp0(pref_dblog, "off") == 0) {
|
||||
return;
|
||||
} else if (g_strcmp0(pref_dblog, "redact") == 0) {
|
||||
if (message->plain) {
|
||||
free(message->plain);
|
||||
}
|
||||
message->plain = strdup("[REDACTED]");
|
||||
}
|
||||
|
||||
if (!g_chatlog_database) {
|
||||
log_debug("_add_to_db() called but db is not initialized");
|
||||
return;
|
||||
}
|
||||
|
||||
char* err_msg;
|
||||
auto_gchar gchar* date_fmt = NULL;
|
||||
|
||||
if (message->timestamp) {
|
||||
date_fmt = g_date_time_format_iso8601(message->timestamp);
|
||||
} else {
|
||||
GDateTime* dt = g_date_time_new_now_local();
|
||||
date_fmt = g_date_time_format_iso8601(dt);
|
||||
g_date_time_unref(dt);
|
||||
}
|
||||
|
||||
const char* enc = _get_message_enc_str(message->enc);
|
||||
|
||||
if (!type) {
|
||||
type = (char*)_get_message_type_str(message->type);
|
||||
}
|
||||
|
||||
// Apply LMC and check its validity (XEP-0308)
|
||||
if (message->replace_id) {
|
||||
auto_sqlite char* replace_check_query = sqlite3_mprintf("SELECT `id`, `from_jid`, `replaces_db_id` FROM `ChatLogs` WHERE `stanza_id` = %Q ORDER BY `timestamp` DESC LIMIT 1",
|
||||
message->replace_id);
|
||||
|
||||
if (!replace_check_query) {
|
||||
log_error("Could not allocate memory for SQL replace query in _add_to_db()");
|
||||
return;
|
||||
}
|
||||
|
||||
sqlite3_stmt* lmc_stmt = NULL;
|
||||
if (!_db_prepare_ctx(replace_check_query, &lmc_stmt, "_add_to_db(replace_check)")) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sqlite3_step(lmc_stmt) == SQLITE_ROW) {
|
||||
original_message_id = sqlite3_column_int64(lmc_stmt, 0);
|
||||
const char* from_jid_orig = (const char*)sqlite3_column_text(lmc_stmt, 1);
|
||||
|
||||
sqlite_int64 tmp = sqlite3_column_int64(lmc_stmt, 2);
|
||||
original_message_id = tmp ? tmp : original_message_id;
|
||||
|
||||
if (g_strcmp0(from_jid_orig, from_jid->barejid) != 0) {
|
||||
log_error("Mismatch in sender JIDs when trying to do LMC. Corrected message sender: %s. Original message sender: %s. Replace-ID: %s. Message: %s", from_jid->barejid, from_jid_orig, message->replace_id, message->plain);
|
||||
cons_show_error("%s sent a message correction with mismatched sender. See log for details.", from_jid->barejid);
|
||||
sqlite3_finalize(lmc_stmt);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
log_warning("Got LMC message that does not have original message counterpart in the database from %s", message->from_jid->fulljid);
|
||||
}
|
||||
sqlite3_finalize(lmc_stmt);
|
||||
}
|
||||
|
||||
if (message->stanzaid && !message->is_mam) {
|
||||
auto_sqlite char* duplicate_check_query = sqlite3_mprintf("SELECT 1 FROM `ChatLogs` WHERE (`archive_id` = %Q)",
|
||||
message->stanzaid);
|
||||
|
||||
if (!duplicate_check_query) {
|
||||
log_error("Could not allocate memory for SQL duplicate query in _add_to_db()");
|
||||
return;
|
||||
}
|
||||
|
||||
sqlite3_stmt* stmt;
|
||||
if (_db_prepare_ctx(duplicate_check_query, &stmt, "_add_to_db(duplicate_check)")) {
|
||||
if (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
log_error("Duplicate stanza-id found for the message. stanza_id: %s; archive_id: %s; sender: %s; content: %s", message->id, message->stanzaid, from_jid->barejid, message->plain);
|
||||
cons_show_error("Got a message with duplicate (server-generated) stanza-id from %s.", from_jid->fulljid);
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
}
|
||||
}
|
||||
|
||||
auto_sqlite char* orig_message_id = original_message_id == -1 ? NULL : sqlite3_mprintf("%d", original_message_id);
|
||||
|
||||
auto_sqlite char* query = sqlite3_mprintf("INSERT INTO `ChatLogs` "
|
||||
"(`from_jid`, `from_resource`, `to_jid`, `to_resource`, "
|
||||
"`message`, `timestamp`, `stanza_id`, `archive_id`, "
|
||||
"`replaces_db_id`, `replace_id`, `type`, `encryption`) "
|
||||
"VALUES (%Q, %Q, %Q, %Q, %Q, %Q, %Q, %Q, %Q, %Q, %Q, %Q)",
|
||||
from_jid->barejid,
|
||||
from_jid->resourcepart,
|
||||
to_jid->barejid,
|
||||
to_jid->resourcepart,
|
||||
message->plain,
|
||||
date_fmt,
|
||||
message->id,
|
||||
message->stanzaid,
|
||||
orig_message_id,
|
||||
message->replace_id,
|
||||
type,
|
||||
enc);
|
||||
if (!query) {
|
||||
log_error("Could not allocate memory for SQL insert query in _add_to_db()");
|
||||
return;
|
||||
}
|
||||
|
||||
log_debug("Writing to DB. Query: %s", query);
|
||||
|
||||
if (SQLITE_OK != sqlite3_exec(g_chatlog_database, query, NULL, 0, &err_msg)) {
|
||||
if (err_msg) {
|
||||
log_error("SQLite error in _add_to_db(): %s", err_msg);
|
||||
sqlite3_free(err_msg);
|
||||
} else {
|
||||
log_error("Unknown SQLite error in _add_to_db().");
|
||||
}
|
||||
} else {
|
||||
int inserted_rows_count = sqlite3_changes(g_chatlog_database);
|
||||
if (inserted_rows_count < 1) {
|
||||
log_error("SQLite did not insert message (rows: %d, id: %s, content: %s)", inserted_rows_count, message->id, message->plain);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- DB version and migration ---
|
||||
|
||||
static int
|
||||
_get_db_version(void)
|
||||
{
|
||||
int current_version = -1;
|
||||
const char* query = "SELECT `version` FROM `DbVersion` LIMIT 1";
|
||||
sqlite3_stmt* statement;
|
||||
if (_db_prepare_ctx(query, &statement, "_get_db_version()")) {
|
||||
if (sqlite3_step(statement) == SQLITE_ROW) {
|
||||
current_version = sqlite3_column_int(statement, 0);
|
||||
}
|
||||
sqlite3_finalize(statement);
|
||||
}
|
||||
return current_version;
|
||||
}
|
||||
|
||||
static gboolean
|
||||
_migrate_to_v2(void)
|
||||
{
|
||||
char* err_msg = NULL;
|
||||
|
||||
const char* sql_statements[] = {
|
||||
"BEGIN TRANSACTION",
|
||||
"ALTER TABLE `ChatLogs` ADD COLUMN `replaces_db_id` INTEGER;",
|
||||
"ALTER TABLE `ChatLogs` ADD COLUMN `replaced_by_db_id` INTEGER;",
|
||||
"UPDATE `ChatLogs` AS A "
|
||||
"SET `replaces_db_id` = B.`id` "
|
||||
"FROM `ChatLogs` AS B "
|
||||
"WHERE A.`replace_id` IS NOT NULL AND A.`replace_id` != '' "
|
||||
"AND A.`replace_id` = B.`stanza_id` "
|
||||
"AND A.`from_jid` = B.`from_jid` AND A.`to_jid` = B.`to_jid`;",
|
||||
"UPDATE `ChatLogs` AS A "
|
||||
"SET `replaced_by_db_id` = B.`id` "
|
||||
"FROM `ChatLogs` AS B "
|
||||
"WHERE (A.`replace_id` IS NULL OR A.`replace_id` = '') "
|
||||
"AND A.`id` = B.`replaces_db_id` "
|
||||
"AND A.`from_jid` = B.`from_jid`;",
|
||||
"UPDATE ChatLogs SET "
|
||||
"from_resource = COALESCE(NULLIF(from_resource, ''), NULL), "
|
||||
"to_resource = COALESCE(NULLIF(to_resource, ''), NULL), "
|
||||
"message = COALESCE(NULLIF(message, ''), NULL), "
|
||||
"timestamp = COALESCE(NULLIF(timestamp, ''), NULL), "
|
||||
"stanza_id = COALESCE(NULLIF(stanza_id, ''), NULL), "
|
||||
"archive_id = COALESCE(NULLIF(archive_id, ''), NULL), "
|
||||
"replace_id = COALESCE(NULLIF(replace_id, ''), NULL), "
|
||||
"type = COALESCE(NULLIF(type, ''), NULL), "
|
||||
"encryption = COALESCE(NULLIF(encryption, ''), NULL);",
|
||||
"UPDATE `DbVersion` SET `version` = 2;",
|
||||
"END TRANSACTION"
|
||||
};
|
||||
|
||||
int statements_count = sizeof(sql_statements) / sizeof(sql_statements[0]);
|
||||
|
||||
for (int i = 0; i < statements_count; i++) {
|
||||
if (SQLITE_OK != sqlite3_exec(g_chatlog_database, sql_statements[i], NULL, 0, &err_msg)) {
|
||||
log_error("SQLite error in _migrate_to_v2() on statement %d: %s", i, err_msg);
|
||||
if (err_msg) {
|
||||
sqlite3_free(err_msg);
|
||||
err_msg = NULL;
|
||||
}
|
||||
goto cleanup;
|
||||
}
|
||||
}
|
||||
|
||||
return TRUE;
|
||||
|
||||
cleanup:
|
||||
if (SQLITE_OK != sqlite3_exec(g_chatlog_database, "ROLLBACK;", NULL, 0, &err_msg)) {
|
||||
log_error("[DB Migration] Unable to ROLLBACK: %s", err_msg);
|
||||
if (err_msg) {
|
||||
sqlite3_free(err_msg);
|
||||
}
|
||||
}
|
||||
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
static gboolean
|
||||
_check_available_space_for_db_migration(char* path_to_db)
|
||||
{
|
||||
struct stat file_stat;
|
||||
struct statvfs fs_stat;
|
||||
|
||||
if (statvfs(path_to_db, &fs_stat) == 0 && stat(path_to_db, &file_stat) == 0) {
|
||||
unsigned long long file_size = file_stat.st_size / 1024;
|
||||
unsigned long long available_space_kb = fs_stat.f_frsize * fs_stat.f_bavail / 1024;
|
||||
log_debug("_check_available_space_for_db_migration(): Available space on disk: %llu KB; DB size: %llu KB", available_space_kb, file_size);
|
||||
|
||||
return (available_space_kb >= (file_size + (file_size * 10 / 4)));
|
||||
} else {
|
||||
log_error("Error checking available space.");
|
||||
return FALSE;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Backend vtable ---
|
||||
|
||||
static db_backend_t sqlite_backend = {
|
||||
.name = "sqlite",
|
||||
.init = _sqlite_init,
|
||||
.close = _sqlite_close,
|
||||
.add_incoming = _sqlite_add_incoming,
|
||||
.add_outgoing_chat = _sqlite_add_outgoing_chat,
|
||||
.add_outgoing_muc = _sqlite_add_outgoing_muc,
|
||||
.add_outgoing_muc_pm = _sqlite_add_outgoing_muc_pm,
|
||||
.get_previous_chat = _sqlite_get_previous_chat,
|
||||
.get_limits_info = _sqlite_get_limits_info,
|
||||
.verify_integrity = _sqlite_verify_integrity,
|
||||
};
|
||||
|
||||
db_backend_t*
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int
|
||||
db_sqlite_last_changes(void)
|
||||
{
|
||||
if (!g_chatlog_database)
|
||||
return 0;
|
||||
return sqlite3_changes(g_chatlog_database);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
GSList* contacts = NULL;
|
||||
if (!g_chatlog_database)
|
||||
return NULL;
|
||||
|
||||
const Jid* myjid = connection_get_jid();
|
||||
if (!myjid || !myjid->barejid)
|
||||
return NULL;
|
||||
|
||||
const char* q = "SELECT DISTINCT `from_jid` FROM `ChatLogs` "
|
||||
"WHERE `from_jid` != ? "
|
||||
"UNION "
|
||||
"SELECT DISTINCT `to_jid` FROM `ChatLogs` "
|
||||
"WHERE `to_jid` != ?";
|
||||
sqlite3_stmt* stmt = NULL;
|
||||
if (!_db_prepare_ctx(q, &stmt, "db_sqlite_list_contacts"))
|
||||
return NULL;
|
||||
|
||||
sqlite3_bind_text(stmt, 1, myjid->barejid, -1, SQLITE_STATIC);
|
||||
sqlite3_bind_text(stmt, 2, myjid->barejid, -1, SQLITE_STATIC);
|
||||
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
const char* jid = (const char*)sqlite3_column_text(stmt, 0);
|
||||
if (jid && strlen(jid) > 0) {
|
||||
contacts = g_slist_prepend(contacts, g_strdup(jid));
|
||||
}
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
return g_slist_reverse(contacts);
|
||||
}
|
||||
@@ -37,10 +37,8 @@
|
||||
#include "config.h"
|
||||
|
||||
#include <stdlib.h>
|
||||
#include <assert.h>
|
||||
#include <glib.h>
|
||||
|
||||
#include "ai/ai_client.h"
|
||||
#include "log.h"
|
||||
#include "chatlog.h"
|
||||
#include "database.h"
|
||||
@@ -49,7 +47,7 @@
|
||||
#include "event/common.h"
|
||||
#include "plugins/plugins.h"
|
||||
#include "ui/inputwin.h"
|
||||
#include "ui/window.h"
|
||||
#include "ui/window_list.h"
|
||||
#include "xmpp/chat_session.h"
|
||||
#include "xmpp/session.h"
|
||||
#include "xmpp/xmpp.h"
|
||||
@@ -286,24 +284,3 @@ allow_unencrypted_message(ProfChatWin* chatwin, const char* const msg)
|
||||
win_println((ProfWin*)chatwin, THEME_ERROR, "-", "Message not sent: invalid encryption mode (%s). Use '/force-encryption policy resend-to-confirm'.", force_enc_mode);
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
void
|
||||
cl_ev_send_ai_msg(ProfAiWin* aiwin, const char* const message, const char* const id)
|
||||
{
|
||||
if (!aiwin || !message) {
|
||||
return;
|
||||
}
|
||||
|
||||
assert(aiwin->memcheck == PROFAIWIN_MEMCHECK);
|
||||
|
||||
if (!aiwin->session) {
|
||||
log_error("[AI] AI session not initialized");
|
||||
return;
|
||||
}
|
||||
|
||||
// Display user message in AI window.
|
||||
win_print_outgoing(&aiwin->window, ">>", id, NULL, message);
|
||||
|
||||
// Send to AI provider.
|
||||
ai_send_prompt(aiwin->session, message, aiwin);
|
||||
}
|
||||
|
||||
@@ -51,7 +51,6 @@ void cl_ev_send_msg(ProfChatWin* chatwin, const char* const msg, const char* con
|
||||
void cl_ev_send_muc_msg_corrected(ProfMucWin* mucwin, const char* const msg, const char* const oob_url, gboolean correct_last_msg);
|
||||
void cl_ev_send_muc_msg(ProfMucWin* mucwin, const char* const msg, const char* const oob_url);
|
||||
void cl_ev_send_priv_msg(ProfPrivateWin* privwin, const char* const msg, const char* const oob_url);
|
||||
void cl_ev_send_ai_msg(ProfAiWin* aiwin, const char* const message, const char* const id);
|
||||
|
||||
// Checks if an unencrypted message can be sent based on encryption preferences.
|
||||
// Returns TRUE if allowed, FALSE if blocked.
|
||||
|
||||
@@ -69,7 +69,6 @@
|
||||
#include "xmpp/xmpp.h"
|
||||
#include "xmpp/muc.h"
|
||||
#include "xmpp/chat_session.h"
|
||||
#include "ai/ai_client.h"
|
||||
#include "xmpp/chat_state.h"
|
||||
#include "xmpp/contact.h"
|
||||
#include "xmpp/roster_list.h"
|
||||
@@ -250,7 +249,6 @@ _init(char* log_level, char* config_file, char* log_file, char* theme_name)
|
||||
}
|
||||
session_init();
|
||||
cmd_init();
|
||||
ai_client_init();
|
||||
log_info("Initialising contact list");
|
||||
muc_init();
|
||||
tlscerts_init();
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
/*
|
||||
* aiwin.c - AI Chat Window Management
|
||||
*
|
||||
* Provides functions for managing the AI chat window (ProfAiWin) in the
|
||||
* profanity client. This includes retrieving window strings for identification,
|
||||
* displaying AI/LLM responses in the chat view, and handling error messages
|
||||
* from AI interactions.
|
||||
*
|
||||
* Key functions:
|
||||
* aiwin_get_string() - Returns a descriptive string for the window
|
||||
* aiwin_display_response() - Displays an AI/LLM response and appends it
|
||||
* to the conversation history
|
||||
* aiwin_display_error() - Displays an error message from AI operations
|
||||
*
|
||||
* vim: expandtab:ts=4:sts=4:sw=4
|
||||
*/
|
||||
|
||||
#include "config.h"
|
||||
|
||||
#include <assert.h>
|
||||
|
||||
#include "ai/ai_client.h"
|
||||
#include "log.h"
|
||||
#include "ui/ui.h"
|
||||
#include "ui/win_types.h"
|
||||
|
||||
char*
|
||||
aiwin_get_string(ProfAiWin* win)
|
||||
{
|
||||
assert(win->memcheck == PROFAIWIN_MEMCHECK);
|
||||
return g_strdup_printf("AI Chat (%s: %s)", win->session->provider_name, win->session->model);
|
||||
}
|
||||
|
||||
void
|
||||
aiwin_display_response(ProfAiWin* win, const char* response)
|
||||
{
|
||||
log_debug("[AI-WIN] aiwin_display_response ENTER: win=%p, response='%s'", (void*)win, response);
|
||||
assert(win->memcheck == PROFAIWIN_MEMCHECK);
|
||||
|
||||
if (!response) {
|
||||
log_error("[AI-WIN] FAIL: null response");
|
||||
return;
|
||||
}
|
||||
|
||||
// Display AI response
|
||||
win_println(&win->window, THEME_DEFAULT, "<< LLM:", "%s", response);
|
||||
log_debug("[AI-WIN] Displayed AI response");
|
||||
}
|
||||
|
||||
void
|
||||
aiwin_display_error(ProfAiWin* win, const char* error_msg)
|
||||
{
|
||||
log_debug("[AI-WIN] aiwin_display_error ENTER: win=%p, error='%s'", (void*)win, error_msg);
|
||||
assert(win->memcheck == PROFAIWIN_MEMCHECK);
|
||||
|
||||
const char* msg = error_msg ? error_msg : "Unknown error";
|
||||
win_println(&win->window, THEME_ERROR, "[ERROR]", "%s", msg);
|
||||
|
||||
// Also show error in console so user sees it regardless of active window
|
||||
cons_show_error("AI Error: %s", msg);
|
||||
}
|
||||
@@ -386,8 +386,6 @@ ProfWin* win_create_config(const char* const title, DataForm* form, ProfConfWinC
|
||||
ProfWin* win_create_private(const char* const fulljid);
|
||||
ProfWin* win_create_plugin(const char* const plugin_name, const char* const tag);
|
||||
ProfWin* win_create_vcard(vCard* vcard);
|
||||
ProfWin* win_create_ai(AISession* session);
|
||||
ProfWin* wins_new_ai(AISession* session);
|
||||
void win_update_virtual(ProfWin* window);
|
||||
void win_free(ProfWin* window);
|
||||
gboolean win_notify_remind(ProfWin* window);
|
||||
@@ -447,9 +445,4 @@ void notify_invite(const char* const from, const char* const room, const char* c
|
||||
void notify(const char* const message, int timeout, const char* const category);
|
||||
void notify_subscription(const char* const from);
|
||||
|
||||
/* AI window */
|
||||
char* aiwin_get_string(ProfAiWin* win);
|
||||
void aiwin_display_response(ProfAiWin* win, const char* response);
|
||||
void aiwin_display_error(ProfAiWin* win, const char* error_msg);
|
||||
|
||||
#endif
|
||||
|
||||
@@ -62,7 +62,6 @@
|
||||
#define PROFXMLWIN_MEMCHECK 87333463
|
||||
#define PROFPLUGINWIN_MEMCHECK 43434777
|
||||
#define PROFVCARDWIN_MEMCHECK 68947523
|
||||
#define PROFAIWIN_MEMCHECK 91827364
|
||||
|
||||
typedef enum {
|
||||
FIELD_HIDDEN,
|
||||
@@ -145,8 +144,7 @@ typedef enum {
|
||||
WIN_PRIVATE,
|
||||
WIN_XML,
|
||||
WIN_PLUGIN,
|
||||
WIN_VCARD,
|
||||
WIN_AI
|
||||
WIN_VCARD
|
||||
} win_type_t;
|
||||
|
||||
typedef enum {
|
||||
@@ -260,16 +258,4 @@ typedef struct prof_vcard_win_t
|
||||
unsigned long memcheck;
|
||||
} ProfVcardWin;
|
||||
|
||||
/* Forward declaration */
|
||||
typedef struct ai_session_t AISession;
|
||||
|
||||
typedef struct prof_ai_win_t
|
||||
{
|
||||
ProfWin window;
|
||||
ProfBuff message_buffer;
|
||||
GString* conversation_history;
|
||||
AISession* session;
|
||||
unsigned long memcheck;
|
||||
} ProfAiWin;
|
||||
|
||||
#endif
|
||||
|
||||
@@ -36,7 +36,6 @@
|
||||
|
||||
#include "config.h"
|
||||
#include "database.h"
|
||||
#include "ui/win_types.h"
|
||||
#include "ui/window_list.h"
|
||||
|
||||
#include <stdlib.h>
|
||||
@@ -63,7 +62,6 @@
|
||||
#include "ui/screen.h"
|
||||
#include "xmpp/xmpp.h"
|
||||
#include "xmpp/roster_list.h"
|
||||
#include "ai/ai_client.h"
|
||||
|
||||
static const char* LOADING_MESSAGE = "Loading older messages…";
|
||||
static const char* CONS_WIN_TITLE = "CProof. Type /help for help information.";
|
||||
@@ -331,22 +329,6 @@ win_create_vcard(vCard* vcard)
|
||||
return &new_win->window;
|
||||
}
|
||||
|
||||
ProfWin*
|
||||
win_create_ai(AISession* session)
|
||||
{
|
||||
ProfAiWin* new_win = g_new0(ProfAiWin, 1);
|
||||
new_win->window.type = WIN_AI;
|
||||
new_win->window.scroll_state = WIN_SCROLL_INNER;
|
||||
new_win->window.layout = _win_create_simple_layout();
|
||||
|
||||
new_win->message_buffer = buffer_create();
|
||||
new_win->conversation_history = g_string_new("");
|
||||
new_win->session = session ? ai_session_ref(session) : NULL;
|
||||
new_win->memcheck = PROFAIWIN_MEMCHECK;
|
||||
|
||||
return &new_win->window;
|
||||
}
|
||||
|
||||
gchar*
|
||||
win_get_title(ProfWin* window)
|
||||
{
|
||||
@@ -425,13 +407,6 @@ win_get_title(ProfWin* window)
|
||||
|
||||
return g_string_free(title, FALSE);
|
||||
}
|
||||
case WIN_AI:
|
||||
{
|
||||
const ProfAiWin* aiwin = (ProfAiWin*)window;
|
||||
assert(aiwin->memcheck == PROFAIWIN_MEMCHECK);
|
||||
|
||||
return g_strdup_printf("AI Assistant (%s: %s)", aiwin->session->provider_name, aiwin->session->model);
|
||||
}
|
||||
}
|
||||
assert(FALSE);
|
||||
}
|
||||
@@ -479,10 +454,6 @@ win_get_tab_identifier(ProfWin* window)
|
||||
{
|
||||
return strdup("vcard");
|
||||
}
|
||||
case WIN_AI:
|
||||
{
|
||||
return strdup("ai");
|
||||
}
|
||||
}
|
||||
assert(FALSE);
|
||||
}
|
||||
@@ -565,12 +536,6 @@ win_to_string(ProfWin* window)
|
||||
ProfVcardWin* vcardwin = (ProfVcardWin*)window;
|
||||
return vcardwin_get_string(vcardwin);
|
||||
}
|
||||
case WIN_AI:
|
||||
{
|
||||
ProfAiWin* aiwin = (ProfAiWin*)window;
|
||||
assert(aiwin->memcheck == PROFAIWIN_MEMCHECK);
|
||||
return aiwin_get_string(aiwin);
|
||||
}
|
||||
}
|
||||
assert(FALSE);
|
||||
}
|
||||
@@ -697,17 +662,6 @@ win_free(ProfWin* window)
|
||||
free(pluginwin->plugin_name);
|
||||
break;
|
||||
}
|
||||
case WIN_AI:
|
||||
{
|
||||
ProfAiWin* aiwin = (ProfAiWin*)window;
|
||||
assert(aiwin->memcheck == PROFAIWIN_MEMCHECK);
|
||||
buffer_free(aiwin->message_buffer);
|
||||
g_string_free(aiwin->conversation_history, TRUE);
|
||||
if (aiwin->session) {
|
||||
ai_session_unref(aiwin->session);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -44,11 +44,9 @@
|
||||
|
||||
#include "common.h"
|
||||
#include "config/preferences.h"
|
||||
#include "log.h"
|
||||
#include "config/theme.h"
|
||||
#include "plugins/plugins.h"
|
||||
#include "ui/ui.h"
|
||||
#include "ui/window.h"
|
||||
#include "ui/window_list.h"
|
||||
#include "xmpp/xmpp.h"
|
||||
#include "xmpp/roster_list.h"
|
||||
@@ -376,8 +374,6 @@ wins_set_current_by_num(int i)
|
||||
status_bar_active(1, conswin->type, "console");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log_error("Window not found for index %d", i);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -718,20 +714,6 @@ wins_new_vcard(vCard* vcard)
|
||||
return newwin;
|
||||
}
|
||||
|
||||
ProfWin*
|
||||
wins_new_ai(AISession* session)
|
||||
{
|
||||
int result = _wins_get_next_available_num(keys);
|
||||
ProfWin* newwin = win_create_ai(session);
|
||||
if (!newwin) {
|
||||
return NULL;
|
||||
}
|
||||
_wins_htable_insert(windows, GINT_TO_POINTER(result), newwin);
|
||||
autocomplete_add(wins_ac, "ai");
|
||||
autocomplete_add(wins_close_ac, "ai");
|
||||
return newwin;
|
||||
}
|
||||
|
||||
gboolean
|
||||
wins_do_notify_remind(void)
|
||||
{
|
||||
@@ -859,7 +841,7 @@ wins_get_prune_wins(void)
|
||||
|
||||
while (curr) {
|
||||
ProfWin* window = curr->data;
|
||||
if (win_unread(window) == 0 && window->type != WIN_MUC && window->type != WIN_CONFIG && window->type != WIN_XML && window->type != WIN_CONSOLE && window->type != WIN_AI) {
|
||||
if (win_unread(window) == 0 && window->type != WIN_MUC && window->type != WIN_CONFIG && window->type != WIN_XML && window->type != WIN_CONSOLE) {
|
||||
result = g_slist_append(result, window);
|
||||
}
|
||||
curr = g_list_next(curr);
|
||||
@@ -867,45 +849,6 @@ wins_get_prune_wins(void)
|
||||
return result;
|
||||
}
|
||||
|
||||
ProfAiWin*
|
||||
wins_get_ai(void)
|
||||
{
|
||||
GList* curr = values;
|
||||
|
||||
while (curr) {
|
||||
ProfWin* window = curr->data;
|
||||
if (window->type == WIN_AI) {
|
||||
ProfAiWin* aiwin = (ProfAiWin*)window;
|
||||
assert(aiwin->memcheck == PROFAIWIN_MEMCHECK);
|
||||
return aiwin;
|
||||
}
|
||||
curr = g_list_next(curr);
|
||||
}
|
||||
|
||||
return NULL;
|
||||
}
|
||||
|
||||
gboolean
|
||||
wins_ai_exists(ProfAiWin* const aiwin)
|
||||
{
|
||||
if (!aiwin) {
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
GList* curr = values;
|
||||
while (curr) {
|
||||
ProfWin* window = curr->data;
|
||||
if (window->type == WIN_AI) {
|
||||
if ((ProfAiWin*)window == aiwin) {
|
||||
return TRUE;
|
||||
}
|
||||
}
|
||||
curr = g_list_next(curr);
|
||||
}
|
||||
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
void
|
||||
wins_lost_connection(void)
|
||||
{
|
||||
|
||||
@@ -63,8 +63,6 @@ ProfPrivateWin* wins_get_private(const char* const fulljid);
|
||||
ProfPluginWin* wins_get_plugin(const char* const tag);
|
||||
ProfXMLWin* wins_get_xmlconsole(void);
|
||||
ProfVcardWin* wins_get_vcard(void);
|
||||
ProfAiWin* wins_get_ai(void);
|
||||
gboolean wins_ai_exists(ProfAiWin* const aiwin);
|
||||
|
||||
void wins_close_plugin(char* tag);
|
||||
|
||||
|
||||
@@ -358,6 +358,7 @@ message_init(void)
|
||||
message->enc = PROF_MSG_ENC_NONE;
|
||||
message->trusted = true;
|
||||
message->type = PROF_MSG_TYPE_UNINITIALIZED;
|
||||
message->marked_read = -1;
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
@@ -175,6 +175,7 @@ typedef struct prof_message_t
|
||||
gboolean trusted;
|
||||
gboolean is_mam;
|
||||
prof_msg_type_t type;
|
||||
int marked_read; // -1 = unset, 0 = unread, 1 = read (used by export/import)
|
||||
} ProfMessage;
|
||||
|
||||
void session_init(void);
|
||||
|
||||
@@ -15,10 +15,10 @@
|
||||
* functional tests run less frequently than unit tests.
|
||||
*
|
||||
* Tests are organized into groups for better maintainability and parallel execution:
|
||||
* Group 1: Connect, Ping, Autoping (fast), Rooms, Software, Last Activity
|
||||
* Group 2: Message, Receipts, Roster, Chat Session
|
||||
* Group 3: Presence, Disconnect, Autoping (slow)
|
||||
* Group 4: MUC, Carbons
|
||||
* Group 1: Connect, Ping, Rooms, Software, Last Activity, Autoping
|
||||
* Group 2: Message, Receipts, Roster, DB History (+export/import with SQLite)
|
||||
* Group 3: Presence, Disconnect, Disco
|
||||
* Group 4: Chat Session, MUC, Carbons
|
||||
*
|
||||
* Parallel execution:
|
||||
* ./functionaltests - run all tests sequentially
|
||||
@@ -55,6 +55,10 @@
|
||||
#include "test_lastactivity.h"
|
||||
#include "test_autoping.h"
|
||||
#include "test_disco.h"
|
||||
#include "test_history.h"
|
||||
#ifdef HAVE_SQLITE
|
||||
#include "test_export_import.h"
|
||||
#endif
|
||||
|
||||
/* Macro to wrap each test with setup/teardown functions */
|
||||
#define PROF_FUNC_TEST(test) cmocka_unit_test_setup_teardown(test, init_prof_test, close_prof_test)
|
||||
@@ -62,7 +66,7 @@
|
||||
int
|
||||
main(int argc, char* argv[])
|
||||
{
|
||||
int group = 0; /* 0 = all groups */
|
||||
int group = 0; /* 0 = all groups */
|
||||
if (argc > 1) {
|
||||
group = atoi(argv[1]);
|
||||
if (group < 1 || group > 4) {
|
||||
@@ -75,11 +79,11 @@ main(int argc, char* argv[])
|
||||
char group_env[16];
|
||||
snprintf(group_env, sizeof(group_env), "%d", group);
|
||||
setenv("PROF_TEST_GROUP", group_env, 1);
|
||||
|
||||
|
||||
fprintf(stderr, "[PROF_TEST] Starting functional tests, group=%d\n", group);
|
||||
|
||||
/* ============================================================
|
||||
* GROUP 1: Connect, Ping, Rooms, Software
|
||||
* GROUP 1: Connect, Ping, Rooms, Software, Last Activity
|
||||
* Basic XMPP session establishment and server queries
|
||||
* ============================================================ */
|
||||
const struct CMUnitTest group1_tests[] = {
|
||||
@@ -124,10 +128,24 @@ main(int argc, char* argv[])
|
||||
};
|
||||
|
||||
/* ============================================================
|
||||
* GROUP 2: Message, Receipts, Roster, Chat Session
|
||||
* GROUP 2: Message, Receipts, Roster, DB History
|
||||
* Core messaging and contact management
|
||||
* ============================================================ */
|
||||
const struct CMUnitTest group2_tests[] = {
|
||||
#ifdef HAVE_SQLITE
|
||||
/* Export/Import — cross-backend migration (SQLite ↔ flat-file) */
|
||||
PROF_FUNC_TEST(export_sqlite_to_flatfile),
|
||||
PROF_FUNC_TEST(import_flatfile_to_sqlite),
|
||||
PROF_FUNC_TEST(export_idempotent_no_duplicates),
|
||||
PROF_FUNC_TEST(export_lmc_correction_survives),
|
||||
PROF_FUNC_TEST(switch_preserves_old_backend_data),
|
||||
PROF_FUNC_TEST(export_all_contacts),
|
||||
PROF_FUNC_TEST(import_double_dedup),
|
||||
PROF_FUNC_TEST(verify_after_export),
|
||||
PROF_FUNC_TEST(switch_backends_independent_messages),
|
||||
PROF_FUNC_TEST(export_empty_contact),
|
||||
#endif
|
||||
|
||||
/* Basic message send/receive */
|
||||
PROF_FUNC_TEST(message_send),
|
||||
PROF_FUNC_TEST(message_receive_console),
|
||||
@@ -145,20 +163,27 @@ main(int argc, char* argv[])
|
||||
PROF_FUNC_TEST(sends_remove_item_nick),
|
||||
PROF_FUNC_TEST(sends_nick_change),
|
||||
|
||||
/* Chat session management - bare/full JID routing */
|
||||
PROF_FUNC_TEST(sends_message_to_barejid_when_contact_offline),
|
||||
PROF_FUNC_TEST(sends_message_to_barejid_when_contact_online),
|
||||
PROF_FUNC_TEST(sends_message_to_fulljid_when_received_from_fulljid),
|
||||
PROF_FUNC_TEST(sends_subsequent_messages_to_fulljid),
|
||||
PROF_FUNC_TEST(resets_to_barejid_after_presence_received),
|
||||
PROF_FUNC_TEST(new_session_when_message_received_from_different_fulljid),
|
||||
/* Database persistence - message write+read round-trip */
|
||||
PROF_FUNC_TEST(message_db_history_on_reopen),
|
||||
PROF_FUNC_TEST(message_db_history_multiple),
|
||||
PROF_FUNC_TEST(message_db_history_contact_isolation),
|
||||
PROF_FUNC_TEST(message_db_history_special_chars),
|
||||
PROF_FUNC_TEST(message_db_history_outgoing),
|
||||
PROF_FUNC_TEST(message_db_history_dialog),
|
||||
PROF_FUNC_TEST(message_db_history_empty),
|
||||
PROF_FUNC_TEST(message_db_history_long_message),
|
||||
PROF_FUNC_TEST(message_db_history_newline),
|
||||
PROF_FUNC_TEST(message_db_history_service_chars),
|
||||
PROF_FUNC_TEST(message_db_history_verify),
|
||||
PROF_FUNC_TEST(message_db_history_lmc),
|
||||
};
|
||||
|
||||
/* ============================================================
|
||||
* GROUP 3: Presence, Disconnect, Disco
|
||||
* Online/away/xa/dnd/chat status management
|
||||
* Status management, clean teardown, service discovery
|
||||
* ============================================================ */
|
||||
const struct CMUnitTest group3_tests[] = {
|
||||
/* Presence - online/away/xa/dnd/chat status management */
|
||||
PROF_FUNC_TEST(presence_online),
|
||||
PROF_FUNC_TEST(presence_online_with_message),
|
||||
PROF_FUNC_TEST(presence_away),
|
||||
@@ -196,10 +221,18 @@ main(int argc, char* argv[])
|
||||
};
|
||||
|
||||
/* ============================================================
|
||||
* GROUP 4: MUC, Carbons
|
||||
* Multi-user chat and message synchronization
|
||||
* GROUP 4: Chat Session, MUC, Carbons
|
||||
* Session routing, multi-user chat, message synchronization
|
||||
* ============================================================ */
|
||||
const struct CMUnitTest group4_tests[] = {
|
||||
/* Chat session management - bare/full JID routing */
|
||||
PROF_FUNC_TEST(sends_message_to_barejid_when_contact_offline),
|
||||
PROF_FUNC_TEST(sends_message_to_barejid_when_contact_online),
|
||||
PROF_FUNC_TEST(sends_message_to_fulljid_when_received_from_fulljid),
|
||||
PROF_FUNC_TEST(sends_subsequent_messages_to_fulljid),
|
||||
PROF_FUNC_TEST(resets_to_barejid_after_presence_received),
|
||||
PROF_FUNC_TEST(new_session_when_message_received_from_different_fulljid),
|
||||
|
||||
/* MUC room join with various options - XEP-0045 */
|
||||
PROF_FUNC_TEST(sends_room_join),
|
||||
PROF_FUNC_TEST(sends_room_join_with_nick),
|
||||
@@ -235,15 +268,16 @@ main(int argc, char* argv[])
|
||||
};
|
||||
|
||||
/* Test group registry for easy extension */
|
||||
struct {
|
||||
struct
|
||||
{
|
||||
const char* name;
|
||||
const struct CMUnitTest* tests;
|
||||
size_t count;
|
||||
} groups[] = {
|
||||
{ "Group 1: Connect/Ping/Rooms/Software/Autoping", group1_tests, ARRAY_SIZE(group1_tests) },
|
||||
{ "Group 2: Message/Receipts/Roster/Session", group2_tests, ARRAY_SIZE(group2_tests) },
|
||||
{ "Group 1: Connect/Ping/Rooms/Software/LastActivity/Autoping", group1_tests, ARRAY_SIZE(group1_tests) },
|
||||
{ "Group 2: Message/Receipts/Roster/DBHistory", group2_tests, ARRAY_SIZE(group2_tests) },
|
||||
{ "Group 3: Presence/Disconnect/Disco", group3_tests, ARRAY_SIZE(group3_tests) },
|
||||
{ "Group 4: MUC/Carbons", group4_tests, ARRAY_SIZE(group4_tests) },
|
||||
{ "Group 4: Session/MUC/Carbons", group4_tests, ARRAY_SIZE(group4_tests) },
|
||||
};
|
||||
const int num_groups = ARRAY_SIZE(groups);
|
||||
|
||||
|
||||
@@ -11,16 +11,16 @@ extern char xdg_data_home[256];
|
||||
|
||||
extern int stub_port;
|
||||
|
||||
int init_prof_test(void **state);
|
||||
int close_prof_test(void **state);
|
||||
int init_prof_test(void** state);
|
||||
int close_prof_test(void** state);
|
||||
|
||||
void prof_start(void);
|
||||
void prof_connect(void);
|
||||
void prof_connect_with_roster(const char *roster);
|
||||
void prof_input(const char *input);
|
||||
void prof_connect_with_roster(const char* roster);
|
||||
void prof_input(const char* input);
|
||||
|
||||
int prof_output_exact(const char *text);
|
||||
int prof_output_regex(const char *text);
|
||||
int prof_output_exact(const char* text);
|
||||
int prof_output_regex(const char* text);
|
||||
|
||||
void prof_timeout(int timeout);
|
||||
void prof_timeout_reset(void);
|
||||
|
||||
501
tests/functionaltests/test_export_import.c
Normal file
501
tests/functionaltests/test_export_import.c
Normal file
@@ -0,0 +1,501 @@
|
||||
#include <glib.h>
|
||||
#include "prof_cmocka.h"
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <stabber.h>
|
||||
|
||||
#include "proftest.h"
|
||||
|
||||
/*
|
||||
* Test: SQLite -> flat-file export preserves messages.
|
||||
*
|
||||
* Uses <delay> stamped messages to verify round-trip through export.
|
||||
* Timestamp preservation is verified by unit tests (test_database_export.c);
|
||||
* the history-load path returns NULL GDateTime for timestamps, so
|
||||
* functional tests verify content survival only.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Connect (SQLite backend)
|
||||
* 2. Receive message from buddy1 with delay stamp
|
||||
* 3. Send outgoing message
|
||||
* 4. /close, /history export, /history switch flatfile
|
||||
* 5. Re-open chat -- history loaded from flat-file
|
||||
* 6. Verify message content survives cross-backend migration
|
||||
*/
|
||||
void
|
||||
export_sqlite_to_flatfile(void** state)
|
||||
{
|
||||
/* Phase 1: create message history in SQLite */
|
||||
prof_connect();
|
||||
|
||||
/* Send outgoing message (timestamp = now) */
|
||||
prof_input("/msg buddy1@localhost Hello from SQLite export test");
|
||||
assert_true(prof_output_regex("me: .+Hello from SQLite export test"));
|
||||
|
||||
/* Receive incoming message with a fixed delay timestamp (10:30 UTC) */
|
||||
stbbr_send(
|
||||
"<message id='exp1' to='stabber@localhost' from='buddy1@localhost/res' type='chat'>"
|
||||
"<body>SQLite reply at 1030</body>"
|
||||
"<delay xmlns='urn:xmpp:delay' stamp='2025-06-15T10:30:00Z'/>"
|
||||
"</message>");
|
||||
assert_true(prof_output_regex("Buddy1/res: .+SQLite reply at 1030"));
|
||||
|
||||
/* Close chat window, return to console */
|
||||
prof_input("/close");
|
||||
|
||||
/* Export SQLite history to flat-file format */
|
||||
prof_input("/history export buddy1@localhost");
|
||||
assert_true(prof_output_regex("Export complete: [0-9]+ message\\(s\\) exported\\."));
|
||||
|
||||
/* Phase 2: switch to flat-file backend and verify content survived */
|
||||
prof_input("/history switch flatfile");
|
||||
assert_true(prof_output_regex("Database backend switched to 'flatfile'\\."));
|
||||
|
||||
/* Open chat window -- history loads from flat-file backend */
|
||||
prof_input("/msg buddy1@localhost");
|
||||
|
||||
/* Verify messages survived cross-backend migration */
|
||||
assert_true(prof_output_regex("SQLite reply at 1030"));
|
||||
assert_true(prof_output_regex("Hello from SQLite export test"));
|
||||
}
|
||||
|
||||
/*
|
||||
* Test: flat-file -> SQLite import preserves messages.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Connect, switch to flat-file backend
|
||||
* 2. Receive message with delay stamp -- stored in flat-file
|
||||
* 3. Send outgoing message
|
||||
* 4. /close, switch to SQLite, /history import
|
||||
* 5. Open chat -- history loaded from SQLite
|
||||
* 6. Verify message content survives cross-backend migration
|
||||
*/
|
||||
void
|
||||
import_flatfile_to_sqlite(void** state)
|
||||
{
|
||||
/* Phase 1: connect and switch to flat-file backend */
|
||||
prof_connect();
|
||||
|
||||
prof_input("/history switch flatfile");
|
||||
assert_true(prof_output_regex("Database backend switched to 'flatfile'\\."));
|
||||
|
||||
/* Send outgoing message */
|
||||
prof_input("/msg buddy1@localhost Hello from flatfile import test");
|
||||
assert_true(prof_output_regex("me: .+Hello from flatfile import test"));
|
||||
|
||||
/* Receive incoming message with a fixed delay timestamp (14:45 UTC) */
|
||||
stbbr_send(
|
||||
"<message id='imp1' to='stabber@localhost' from='buddy1@localhost/res' type='chat'>"
|
||||
"<body>Flatfile reply at 1445</body>"
|
||||
"<delay xmlns='urn:xmpp:delay' stamp='2025-06-15T14:45:00Z'/>"
|
||||
"</message>");
|
||||
assert_true(prof_output_regex("Buddy1/res: .+Flatfile reply at 1445"));
|
||||
|
||||
/* Close chat window */
|
||||
prof_input("/close");
|
||||
|
||||
/* Phase 2: switch back to SQLite and import */
|
||||
prof_input("/history switch sqlite");
|
||||
assert_true(prof_output_regex("Database backend switched to 'sqlite'\\."));
|
||||
|
||||
/* Import flat-file history into SQLite */
|
||||
prof_input("/history import buddy1@localhost");
|
||||
assert_true(prof_output_regex("Import complete: [0-9]+ message\\(s\\) imported\\."));
|
||||
|
||||
/* Open chat window -- history loads from SQLite with imported data */
|
||||
prof_input("/msg buddy1@localhost");
|
||||
|
||||
/* Verify messages survived cross-backend migration */
|
||||
assert_true(prof_output_regex("Flatfile reply at 1445"));
|
||||
assert_true(prof_output_regex("Hello from flatfile import test"));
|
||||
}
|
||||
|
||||
/*
|
||||
* Test: re-export idempotency — export + import + re-export does not
|
||||
* duplicate messages.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Connect (SQLite), send 2 messages, export to flatfile
|
||||
* 2. Switch to flatfile, verify 2 messages
|
||||
* 3. Switch back to SQLite, export again
|
||||
* 4. Switch to flatfile, reopen — still exactly 2 messages
|
||||
* (the dedup logic in export must prevent duplication)
|
||||
*/
|
||||
void
|
||||
export_idempotent_no_duplicates(void** state)
|
||||
{
|
||||
prof_connect();
|
||||
|
||||
/* Create 2 messages in SQLite */
|
||||
prof_input("/msg buddy1@localhost idemp-msg-alpha");
|
||||
assert_true(prof_output_regex("me: .+idemp-msg-alpha"));
|
||||
|
||||
stbbr_send(
|
||||
"<message id='idemp1' to='stabber@localhost' from='buddy1@localhost/res' type='chat'>"
|
||||
"<body>idemp-msg-beta</body>"
|
||||
"</message>");
|
||||
assert_true(prof_output_regex("Buddy1/res: .+idemp-msg-beta"));
|
||||
|
||||
prof_input("/close");
|
||||
|
||||
/* Export #1 */
|
||||
prof_input("/history export buddy1@localhost");
|
||||
assert_true(prof_output_regex("Export complete: 2 message\\(s\\) exported\\."));
|
||||
|
||||
/* Verify flatfile has the messages */
|
||||
prof_input("/history switch flatfile");
|
||||
assert_true(prof_output_regex("Database backend switched to 'flatfile'\\."));
|
||||
prof_input("/msg buddy1@localhost");
|
||||
assert_true(prof_output_exact("idemp-msg-alpha"));
|
||||
assert_true(prof_output_exact("idemp-msg-beta"));
|
||||
prof_input("/close");
|
||||
|
||||
/* Switch back to SQLite and export again (re-export) */
|
||||
prof_input("/history switch sqlite");
|
||||
assert_true(prof_output_regex("Database backend switched to 'sqlite'\\."));
|
||||
|
||||
prof_input("/history export buddy1@localhost");
|
||||
/* Dedup: 0 new messages exported (all already present in flatfile) */
|
||||
assert_true(prof_output_regex("Export complete: 0 message\\(s\\) exported\\."));
|
||||
|
||||
/* Verify flatfile still has exactly 2, not 4 */
|
||||
prof_input("/history switch flatfile");
|
||||
assert_true(prof_output_regex("Database backend switched to 'flatfile'\\."));
|
||||
prof_input("/msg buddy1@localhost");
|
||||
assert_true(prof_output_exact("idemp-msg-alpha"));
|
||||
assert_true(prof_output_exact("idemp-msg-beta"));
|
||||
}
|
||||
|
||||
/*
|
||||
* Test: LMC (Last Message Correction, XEP-0308) survives export.
|
||||
*
|
||||
* A corrected message in SQLite should appear as corrected in the
|
||||
* flat-file after export — the original body must be absent and the
|
||||
* corrected body must be present.
|
||||
*/
|
||||
void
|
||||
export_lmc_correction_survives(void** state)
|
||||
{
|
||||
prof_connect();
|
||||
|
||||
prof_input("/history on");
|
||||
assert_true(prof_output_regex("Chat history enabled\\."));
|
||||
|
||||
/* Original message */
|
||||
stbbr_send(
|
||||
"<message id='lmc-exp-orig' to='stabber@localhost' "
|
||||
"from='buddy1@localhost/phone' type='chat'>"
|
||||
"<body>lmc-export-before-fix</body>"
|
||||
"</message>");
|
||||
assert_true(prof_output_exact("<< chat message: Buddy1/phone (win 2)"));
|
||||
|
||||
/* Correction replacing the original */
|
||||
stbbr_send(
|
||||
"<message id='lmc-exp-corr' to='stabber@localhost' "
|
||||
"from='buddy1@localhost/phone' type='chat'>"
|
||||
"<body>lmc-export-after-fix</body>"
|
||||
"<replace id='lmc-exp-orig' xmlns='urn:xmpp:message-correct:0'/>"
|
||||
"</message>");
|
||||
|
||||
/* Sync barrier */
|
||||
stbbr_send(
|
||||
"<message id='lmc-exp-sync' to='stabber@localhost' "
|
||||
"from='buddy2@localhost/phone' type='chat'>"
|
||||
"<body>lmc-export-sync</body>"
|
||||
"</message>");
|
||||
assert_true(prof_output_exact("<< chat message: Buddy2/phone (win 3)"));
|
||||
|
||||
prof_input("/close all");
|
||||
assert_true(prof_output_exact("Closed 2 windows."));
|
||||
|
||||
/* Export to flatfile */
|
||||
prof_input("/history export buddy1@localhost");
|
||||
assert_true(prof_output_regex("Export complete: [0-9]+ message\\(s\\) exported\\."));
|
||||
|
||||
/* Switch to flatfile and verify correction survived */
|
||||
prof_input("/history switch flatfile");
|
||||
assert_true(prof_output_regex("Database backend switched to 'flatfile'\\."));
|
||||
|
||||
prof_input("/msg buddy1@localhost");
|
||||
assert_true(prof_output_exact("lmc-export-after-fix"));
|
||||
|
||||
/* Original should be gone (correction was applied in SQLite) */
|
||||
prof_timeout(3);
|
||||
assert_false(prof_output_exact("lmc-export-before-fix"));
|
||||
prof_timeout_reset();
|
||||
}
|
||||
|
||||
/*
|
||||
* Test: /history switch preserves old backend data.
|
||||
*
|
||||
* Switching from SQLite to flatfile should not destroy SQLite data.
|
||||
* Switching back should recover the original messages.
|
||||
*
|
||||
* Uses incoming messages received on the console so the body is never
|
||||
* rendered to the terminal. The negative check (flatfile is empty)
|
||||
* is performed BEFORE the positive check (SQLite has the message)
|
||||
* so that the body text has not yet entered the cumulative buffer.
|
||||
*/
|
||||
void
|
||||
switch_preserves_old_backend_data(void** state)
|
||||
{
|
||||
prof_connect();
|
||||
|
||||
prof_input("/history on");
|
||||
assert_true(prof_output_regex("Chat history enabled\\."));
|
||||
|
||||
/* Receive incoming on console — body NOT rendered to terminal */
|
||||
stbbr_send(
|
||||
"<message id='sw-pres1' to='stabber@localhost' "
|
||||
"from='buddy1@localhost/phone' type='chat'>"
|
||||
"<body>switch-preserve-sqlite-42</body>"
|
||||
"</message>");
|
||||
assert_true(prof_output_exact("<< chat message: Buddy1/phone (win 2)"));
|
||||
|
||||
/* Close window (destroy in-memory buffer) */
|
||||
prof_input("/close 2");
|
||||
assert_true(prof_output_exact("Closed window 2"));
|
||||
|
||||
/* Switch to flatfile BEFORE verifying SQLite — body is still
|
||||
* absent from the cumulative terminal buffer at this point */
|
||||
prof_input("/history switch flatfile");
|
||||
assert_true(prof_output_regex("Database backend switched to 'flatfile'\\."));
|
||||
|
||||
/* Flatfile is empty — body must NOT appear (and it has never
|
||||
* been rendered yet, so the cumulative buffer is clean) */
|
||||
prof_input("/msg buddy1@localhost");
|
||||
prof_timeout(3);
|
||||
assert_false(prof_output_exact("switch-preserve-sqlite-42"));
|
||||
prof_timeout_reset();
|
||||
prof_input("/close");
|
||||
|
||||
/* Switch back to SQLite — original data should be intact */
|
||||
prof_input("/history switch sqlite");
|
||||
assert_true(prof_output_regex("Database backend switched to 'sqlite'\\."));
|
||||
|
||||
/* Now the body is rendered for the first time from DB history */
|
||||
prof_input("/msg buddy1@localhost");
|
||||
assert_true(prof_output_exact("switch-preserve-sqlite-42"));
|
||||
}
|
||||
|
||||
/*
|
||||
* Test: global export (no JID argument) exports all contacts.
|
||||
*
|
||||
* Creates messages for buddy1 and buddy2 in SQLite, then exports all.
|
||||
* Both contacts' messages must appear in flatfile after switch.
|
||||
*/
|
||||
void
|
||||
export_all_contacts(void** state)
|
||||
{
|
||||
prof_connect();
|
||||
|
||||
/* Messages to buddy1 */
|
||||
prof_input("/msg buddy1@localhost global-export-buddy1-aaa");
|
||||
assert_true(prof_output_regex("me: .+global-export-buddy1-aaa"));
|
||||
prof_input("/close");
|
||||
|
||||
/* Messages to buddy2 */
|
||||
prof_input("/msg buddy2@localhost global-export-buddy2-bbb");
|
||||
assert_true(prof_output_regex("me: .+global-export-buddy2-bbb"));
|
||||
prof_input("/close");
|
||||
|
||||
/* Export all (no JID) */
|
||||
prof_input("/history export");
|
||||
assert_true(prof_output_regex("Exporting all SQLite history to flat-file\\.\\.\\."));
|
||||
assert_true(prof_output_regex("Export complete: [0-9]+ message\\(s\\) exported\\."));
|
||||
|
||||
/* Switch to flatfile */
|
||||
prof_input("/history switch flatfile");
|
||||
assert_true(prof_output_regex("Database backend switched to 'flatfile'\\."));
|
||||
|
||||
/* Verify buddy1 */
|
||||
prof_input("/msg buddy1@localhost");
|
||||
assert_true(prof_output_exact("global-export-buddy1-aaa"));
|
||||
prof_input("/close");
|
||||
|
||||
/* Verify buddy2 */
|
||||
prof_input("/msg buddy2@localhost");
|
||||
assert_true(prof_output_exact("global-export-buddy2-bbb"));
|
||||
}
|
||||
|
||||
/*
|
||||
* Test: double import deduplication.
|
||||
*
|
||||
* Importing the same flatfile twice should not create duplicate messages
|
||||
* in SQLite.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Create messages in flatfile
|
||||
* 2. Switch to SQLite, import, verify count
|
||||
* 3. Import again — count should be 0 (all deduplicated)
|
||||
* 4. Verify only one copy of each message in history
|
||||
*/
|
||||
void
|
||||
import_double_dedup(void** state)
|
||||
{
|
||||
prof_connect();
|
||||
|
||||
/* Create messages in flatfile */
|
||||
prof_input("/history switch flatfile");
|
||||
assert_true(prof_output_regex("Database backend switched to 'flatfile'\\."));
|
||||
|
||||
prof_input("/msg buddy1@localhost dedup-import-msg-one");
|
||||
assert_true(prof_output_regex("me: .+dedup-import-msg-one"));
|
||||
|
||||
stbbr_send(
|
||||
"<message id='dedup1' to='stabber@localhost' from='buddy1@localhost/res' type='chat'>"
|
||||
"<body>dedup-import-msg-two</body>"
|
||||
"</message>");
|
||||
assert_true(prof_output_regex("Buddy1/res: .+dedup-import-msg-two"));
|
||||
prof_input("/close");
|
||||
|
||||
/* Switch to SQLite and import #1 */
|
||||
prof_input("/history switch sqlite");
|
||||
assert_true(prof_output_regex("Database backend switched to 'sqlite'\\."));
|
||||
|
||||
prof_input("/history import buddy1@localhost");
|
||||
assert_true(prof_output_regex("Import complete: 2 message\\(s\\) imported\\."));
|
||||
|
||||
/* Import #2 — should be all duplicates */
|
||||
prof_input("/history import buddy1@localhost");
|
||||
assert_true(prof_output_regex("Import complete: 0 message\\(s\\) imported\\."));
|
||||
|
||||
/* Verify messages exist and are not doubled */
|
||||
prof_input("/msg buddy1@localhost");
|
||||
assert_true(prof_output_exact("dedup-import-msg-one"));
|
||||
assert_true(prof_output_exact("dedup-import-msg-two"));
|
||||
}
|
||||
|
||||
/*
|
||||
* Test: /history verify on data that went through export/import.
|
||||
*
|
||||
* After migrating SQLite → flatfile via export, /history verify should
|
||||
* complete without errors on the resulting flat-files.
|
||||
*/
|
||||
void
|
||||
verify_after_export(void** state)
|
||||
{
|
||||
prof_connect();
|
||||
|
||||
/* Create some messages in SQLite */
|
||||
prof_input("/msg buddy1@localhost verify-after-export-msg");
|
||||
assert_true(prof_output_regex("me: .+verify-after-export-msg"));
|
||||
|
||||
stbbr_send(
|
||||
"<message id='ver-exp1' to='stabber@localhost' from='buddy1@localhost/res' type='chat'>"
|
||||
"<body>verify-export-reply</body>"
|
||||
"</message>");
|
||||
assert_true(prof_output_regex("Buddy1/res: .+verify-export-reply"));
|
||||
prof_input("/close");
|
||||
|
||||
/* Export to flatfile */
|
||||
prof_input("/history export buddy1@localhost");
|
||||
assert_true(prof_output_regex("Export complete: [0-9]+ message\\(s\\) exported\\."));
|
||||
|
||||
/* Switch to flatfile and verify integrity */
|
||||
prof_input("/history switch flatfile");
|
||||
assert_true(prof_output_regex("Database backend switched to 'flatfile'\\."));
|
||||
|
||||
prof_input("/history verify buddy1@localhost");
|
||||
/* Accept "no issues found" or "0 error(s)" — minor info notes are OK */
|
||||
assert_true(prof_output_regex("Verification complete"));
|
||||
}
|
||||
|
||||
/*
|
||||
* Test: each backend maintains its own independent messages.
|
||||
*
|
||||
* After switching backends, new messages go to the new backend only.
|
||||
* Switching back reveals the original backend's messages unchanged.
|
||||
*
|
||||
* Uses incoming messages on the console (body never rendered) and two
|
||||
* different contacts (buddy1 for SQLite, buddy2 for flatfile).
|
||||
*
|
||||
* All negative (assert_false) checks are done BEFORE any positive
|
||||
* check renders a body, because prof_output_exact searches the
|
||||
* cumulative terminal buffer.
|
||||
*/
|
||||
void
|
||||
switch_backends_independent_messages(void** state)
|
||||
{
|
||||
prof_connect();
|
||||
|
||||
prof_input("/history on");
|
||||
assert_true(prof_output_regex("Chat history enabled\\."));
|
||||
|
||||
/* Phase 1: receive buddy1 msg while on SQLite — body NOT rendered */
|
||||
stbbr_send(
|
||||
"<message id='indep-sq1' to='stabber@localhost' "
|
||||
"from='buddy1@localhost/phone' type='chat'>"
|
||||
"<body>indep-sqlite-msg-7k</body>"
|
||||
"</message>");
|
||||
assert_true(prof_output_exact("<< chat message: Buddy1/phone (win 2)"));
|
||||
prof_input("/close 2");
|
||||
assert_true(prof_output_exact("Closed window 2"));
|
||||
|
||||
/* Phase 2: switch to flatfile, receive buddy2 msg */
|
||||
prof_input("/history switch flatfile");
|
||||
assert_true(prof_output_regex("Database backend switched to 'flatfile'\\."));
|
||||
|
||||
stbbr_send(
|
||||
"<message id='indep-ff1' to='stabber@localhost' "
|
||||
"from='buddy2@localhost/laptop' type='chat'>"
|
||||
"<body>indep-flatfile-msg-9m</body>"
|
||||
"</message>");
|
||||
assert_true(prof_output_exact("<< chat message: Buddy2/laptop (win 2)"));
|
||||
prof_input("/close 2");
|
||||
assert_true(prof_output_exact("Closed window 2"));
|
||||
|
||||
/* --- Negative checks first (neither body has been rendered yet) --- */
|
||||
|
||||
/* buddy1 on flatfile — should be empty (received on SQLite) */
|
||||
prof_input("/msg buddy1@localhost");
|
||||
prof_timeout(3);
|
||||
assert_false(prof_output_exact("indep-sqlite-msg-7k"));
|
||||
prof_timeout_reset();
|
||||
prof_input("/close");
|
||||
|
||||
/* Switch to SQLite for buddy2 negative check */
|
||||
prof_input("/history switch sqlite");
|
||||
assert_true(prof_output_regex("Database backend switched to 'sqlite'\\."));
|
||||
|
||||
/* buddy2 on SQLite — should be empty (received on flatfile) */
|
||||
prof_input("/msg buddy2@localhost");
|
||||
prof_timeout(3);
|
||||
assert_false(prof_output_exact("indep-flatfile-msg-9m"));
|
||||
prof_timeout_reset();
|
||||
prof_input("/close");
|
||||
|
||||
/* --- Positive checks (bodies rendered for the first time) --- */
|
||||
|
||||
/* buddy1 on SQLite — should have the sqlite msg */
|
||||
prof_input("/msg buddy1@localhost");
|
||||
assert_true(prof_output_exact("indep-sqlite-msg-7k"));
|
||||
prof_input("/close");
|
||||
|
||||
/* Switch to flatfile for buddy2 positive check */
|
||||
prof_input("/history switch flatfile");
|
||||
assert_true(prof_output_regex("Database backend switched to 'flatfile'\\."));
|
||||
|
||||
/* buddy2 on flatfile — should have the flatfile msg */
|
||||
prof_input("/msg buddy2@localhost");
|
||||
assert_true(prof_output_exact("indep-flatfile-msg-9m"));
|
||||
}
|
||||
|
||||
/*
|
||||
* Test: export of a contact with no messages doesn't crash and reports 0.
|
||||
*
|
||||
* buddy2 has no message history — export should succeed gracefully
|
||||
* with 0 messages exported.
|
||||
*/
|
||||
void
|
||||
export_empty_contact(void** state)
|
||||
{
|
||||
prof_connect();
|
||||
|
||||
/* buddy2 has never exchanged messages */
|
||||
prof_input("/history export buddy2@localhost");
|
||||
assert_true(prof_output_regex("Export complete: 0 message\\(s\\) exported\\."));
|
||||
}
|
||||
10
tests/functionaltests/test_export_import.h
Normal file
10
tests/functionaltests/test_export_import.h
Normal file
@@ -0,0 +1,10 @@
|
||||
void export_sqlite_to_flatfile(void** state);
|
||||
void import_flatfile_to_sqlite(void** state);
|
||||
void export_idempotent_no_duplicates(void** state);
|
||||
void export_lmc_correction_survives(void** state);
|
||||
void switch_preserves_old_backend_data(void** state);
|
||||
void export_all_contacts(void** state);
|
||||
void import_double_dedup(void** state);
|
||||
void verify_after_export(void** state);
|
||||
void switch_backends_independent_messages(void** state);
|
||||
void export_empty_contact(void** state);
|
||||
474
tests/functionaltests/test_history.c
Normal file
474
tests/functionaltests/test_history.c
Normal file
@@ -0,0 +1,474 @@
|
||||
/*
|
||||
* test_history.c
|
||||
*
|
||||
* Functional test for database message persistence.
|
||||
* Verifies that a received chat message is written to the database
|
||||
* and loaded back as history when the chat window is reopened.
|
||||
*
|
||||
* These tests only exercise the public log_database API through normal
|
||||
* profanity UI operations, so they work with any storage backend.
|
||||
*/
|
||||
|
||||
#include <glib.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include "prof_cmocka.h"
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include <stabber.h>
|
||||
|
||||
#include "proftest.h"
|
||||
|
||||
/*
|
||||
* Test: message written to DB is loaded as history on chat window reopen.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Connect and enable /history on (enables PREF_CHLOG + PREF_HISTORY)
|
||||
* 2. Receive a chat message while on the console window — the message
|
||||
* body is NOT rendered to the terminal (only a console notification
|
||||
* "<< chat message: ... (win N)" appears), but the message IS
|
||||
* written to the database via chat_log_msg_in()
|
||||
* 3. Close the auto-created chat window (destroying its in-memory buffer)
|
||||
* 4. Reopen the chat window with /msg — chatwin_new() calls
|
||||
* _chatwin_history() which reads from log_database_get_previous_chat()
|
||||
* 5. Assert the message body appears in the terminal output, proving
|
||||
* it was persisted to and read back from the database
|
||||
*/
|
||||
void
|
||||
message_db_history_on_reopen(void** state)
|
||||
{
|
||||
prof_connect();
|
||||
|
||||
/* Enable chat logging and history display */
|
||||
prof_input("/history on");
|
||||
assert_true(prof_output_regex("Chat history enabled\\."));
|
||||
|
||||
/* Receive a message while on the console window.
|
||||
* The body text is NOT printed to the visible terminal — only the
|
||||
* console notification appears. The message is logged to the DB. */
|
||||
stbbr_send(
|
||||
"<message id='hist-db-test-1' to='stabber@localhost' "
|
||||
"from='buddy1@localhost/phone' type='chat'>"
|
||||
"<body>persistence-roundtrip-check-42</body>"
|
||||
"</message>");
|
||||
|
||||
/* Wait for the console notification (proves message was received).
|
||||
* buddy1@localhost is in the roster as "Buddy1", and PREF_RESOURCE_MESSAGE
|
||||
* is on by default, so the display name is "Buddy1/phone". */
|
||||
assert_true(prof_output_exact("<< chat message: Buddy1/phone (win 2)"));
|
||||
|
||||
/* Close the chat window that was auto-created for the incoming message.
|
||||
* This destroys its in-memory buffer so the text is gone from UI. */
|
||||
prof_input("/close 2");
|
||||
assert_true(prof_output_exact("Closed window 2"));
|
||||
|
||||
/* Reopen the chat window — chatwin_new() will load history from DB.
|
||||
* The message body should now appear on screen for the first time. */
|
||||
prof_input("/msg buddy1@localhost");
|
||||
|
||||
/* The history-loaded message must contain the body we sent earlier.
|
||||
* Since the body was never rendered to the terminal before (it was
|
||||
* only in the hidden window's ncurses buffer which was destroyed),
|
||||
* finding it now proves the database write+read round-trip works. */
|
||||
assert_true(prof_output_exact("persistence-roundtrip-check-42"));
|
||||
}
|
||||
|
||||
/*
|
||||
* Test: multiple messages from the same contact are all preserved.
|
||||
*
|
||||
* Uses different resources so each console notification is unique and
|
||||
* we can reliably synchronise on each delivery before proceeding.
|
||||
*/
|
||||
void
|
||||
message_db_history_multiple(void** state)
|
||||
{
|
||||
prof_connect();
|
||||
|
||||
prof_input("/history on");
|
||||
assert_true(prof_output_regex("Chat history enabled\\."));
|
||||
|
||||
/* Three messages from different resources → distinct notifications */
|
||||
stbbr_send(
|
||||
"<message id='multi1' to='stabber@localhost' "
|
||||
"from='buddy1@localhost/phone' type='chat'>"
|
||||
"<body>db-multi-first-aaa</body>"
|
||||
"</message>");
|
||||
assert_true(prof_output_exact("<< chat message: Buddy1/phone (win 2)"));
|
||||
|
||||
stbbr_send(
|
||||
"<message id='multi2' to='stabber@localhost' "
|
||||
"from='buddy1@localhost/laptop' type='chat'>"
|
||||
"<body>db-multi-second-bbb</body>"
|
||||
"</message>");
|
||||
assert_true(prof_output_exact("<< chat message: Buddy1/laptop (win 2)"));
|
||||
|
||||
stbbr_send(
|
||||
"<message id='multi3' to='stabber@localhost' "
|
||||
"from='buddy1@localhost/tablet' type='chat'>"
|
||||
"<body>db-multi-third-ccc</body>"
|
||||
"</message>");
|
||||
assert_true(prof_output_exact("<< chat message: Buddy1/tablet (win 2)"));
|
||||
|
||||
/* Close and reopen */
|
||||
prof_input("/close 2");
|
||||
assert_true(prof_output_exact("Closed window 2"));
|
||||
|
||||
prof_input("/msg buddy1@localhost");
|
||||
|
||||
/* All three must be present in history */
|
||||
assert_true(prof_output_exact("db-multi-first-aaa"));
|
||||
assert_true(prof_output_exact("db-multi-second-bbb"));
|
||||
assert_true(prof_output_exact("db-multi-third-ccc"));
|
||||
}
|
||||
|
||||
/*
|
||||
* Test: messages are stored per-contact — buddy1's history does not
|
||||
* leak into buddy2's window.
|
||||
*
|
||||
* 1. Receive from buddy1 and buddy2 while on console (bodies hidden)
|
||||
* 2. Close all chat windows
|
||||
* 3. Open buddy1 → verify buddy1's body present, buddy2's body absent
|
||||
*/
|
||||
void
|
||||
message_db_history_contact_isolation(void** state)
|
||||
{
|
||||
prof_connect();
|
||||
|
||||
prof_input("/history on");
|
||||
assert_true(prof_output_regex("Chat history enabled\\."));
|
||||
|
||||
/* Receive from buddy1 */
|
||||
stbbr_send(
|
||||
"<message id='iso1' to='stabber@localhost' "
|
||||
"from='buddy1@localhost/phone' type='chat'>"
|
||||
"<body>isolation-buddy1-only-xyzzy</body>"
|
||||
"</message>");
|
||||
assert_true(prof_output_exact("<< chat message: Buddy1/phone (win 2)"));
|
||||
|
||||
/* Receive from buddy2 */
|
||||
stbbr_send(
|
||||
"<message id='iso2' to='stabber@localhost' "
|
||||
"from='buddy2@localhost/phone' type='chat'>"
|
||||
"<body>isolation-buddy2-only-plugh</body>"
|
||||
"</message>");
|
||||
assert_true(prof_output_exact("<< chat message: Buddy2/phone (win 3)"));
|
||||
|
||||
/* Close all chat windows at once */
|
||||
prof_input("/close all");
|
||||
assert_true(prof_output_exact("Closed 2 windows."));
|
||||
|
||||
/* Open buddy1 — history should contain ONLY buddy1's message */
|
||||
prof_input("/msg buddy1@localhost");
|
||||
assert_true(prof_output_exact("isolation-buddy1-only-xyzzy"));
|
||||
|
||||
/* buddy2's body was never printed to the terminal (received on
|
||||
* console), so finding it now would mean cross-contact leakage */
|
||||
prof_timeout(3);
|
||||
assert_false(prof_output_exact("isolation-buddy2-only-plugh"));
|
||||
prof_timeout_reset();
|
||||
}
|
||||
|
||||
/*
|
||||
* Test: special characters survive the DB write+read round-trip.
|
||||
*
|
||||
* The XMPP body uses XML entities (& < > ") which the
|
||||
* XML parser decodes before storage. The DB must preserve the decoded
|
||||
* characters and return them unchanged.
|
||||
*/
|
||||
void
|
||||
message_db_history_special_chars(void** state)
|
||||
{
|
||||
prof_connect();
|
||||
|
||||
prof_input("/history on");
|
||||
assert_true(prof_output_regex("Chat history enabled\\."));
|
||||
|
||||
stbbr_send(
|
||||
"<message id='spec1' to='stabber@localhost' "
|
||||
"from='buddy1@localhost/phone' type='chat'>"
|
||||
"<body>chars: & <tag> "quoted"</body>"
|
||||
"</message>");
|
||||
assert_true(prof_output_exact("<< chat message: Buddy1/phone (win 2)"));
|
||||
|
||||
prof_input("/close 2");
|
||||
assert_true(prof_output_exact("Closed window 2"));
|
||||
|
||||
prof_input("/msg buddy1@localhost");
|
||||
|
||||
/* The decoded text must appear exactly as-is */
|
||||
assert_true(prof_output_exact("chars: & <tag> \"quoted\""));
|
||||
}
|
||||
|
||||
/*
|
||||
* Test: outgoing message (sent via /msg) persists in DB.
|
||||
*
|
||||
* NOTE: The outgoing body IS rendered to the terminal when sent. Since
|
||||
* prof_output_exact searches the cumulative buffer, the assertion alone
|
||||
* does not conclusively prove DB persistence. However, combined with
|
||||
* the incoming tests, this verifies the end-to-end outgoing write path
|
||||
* and catches crashes in the outgoing DB code.
|
||||
*/
|
||||
void
|
||||
message_db_history_outgoing(void** state)
|
||||
{
|
||||
prof_connect();
|
||||
|
||||
prof_input("/history on");
|
||||
assert_true(prof_output_regex("Chat history enabled\\."));
|
||||
|
||||
/* Send outgoing — opens and focuses chat window */
|
||||
prof_input("/msg buddy1@localhost db-outgoing-sent-42");
|
||||
assert_true(prof_output_regex("me: .+db-outgoing-sent-42"));
|
||||
|
||||
prof_input("/close 2");
|
||||
assert_true(prof_output_exact("Closed window 2"));
|
||||
|
||||
/* Reopen — history should load the outgoing message */
|
||||
prof_input("/msg buddy1@localhost");
|
||||
assert_true(prof_output_exact("db-outgoing-sent-42"));
|
||||
}
|
||||
|
||||
/*
|
||||
* Test: a dialog (outgoing + incoming) is fully preserved in history.
|
||||
*
|
||||
* Sends an outgoing message, then closes the window. A reply arrives
|
||||
* while on the console (body never rendered). After reopen, both sides
|
||||
* of the conversation appear — proving the incoming read from DB works
|
||||
* and the outgoing write path completed without error.
|
||||
*/
|
||||
void
|
||||
message_db_history_dialog(void** state)
|
||||
{
|
||||
prof_connect();
|
||||
|
||||
prof_input("/history on");
|
||||
assert_true(prof_output_regex("Chat history enabled\\."));
|
||||
|
||||
/* Phase 1: send outgoing (renders to terminal) */
|
||||
prof_input("/msg buddy1@localhost dialog-out-msg-42");
|
||||
assert_true(prof_output_regex("me: .+dialog-out-msg-42"));
|
||||
|
||||
/* Return to console */
|
||||
prof_input("/close 2");
|
||||
assert_true(prof_output_exact("Closed window 2"));
|
||||
|
||||
/* Phase 2: receive reply on console (body NOT in terminal buffer) */
|
||||
stbbr_send(
|
||||
"<message id='dlg-in' to='stabber@localhost' "
|
||||
"from='buddy1@localhost/phone' type='chat'>"
|
||||
"<body>dialog-in-reply-77</body>"
|
||||
"</message>");
|
||||
assert_true(prof_output_exact("<< chat message: Buddy1/phone (win 2)"));
|
||||
|
||||
/* Close the auto-created window */
|
||||
prof_input("/close 2");
|
||||
assert_true(prof_output_exact("Closed window 2"));
|
||||
|
||||
/* Phase 3: reopen — history should have both directions */
|
||||
prof_input("/msg buddy1@localhost");
|
||||
|
||||
/* Incoming body conclusively proves DB round-trip */
|
||||
assert_true(prof_output_exact("dialog-in-reply-77"));
|
||||
/* Outgoing body is consistent with DB persistence */
|
||||
assert_true(prof_output_exact("dialog-out-msg-42"));
|
||||
}
|
||||
|
||||
/*
|
||||
* Test: opening a chat window for a contact with no prior messages
|
||||
* does not crash and the window is usable.
|
||||
*/
|
||||
void
|
||||
message_db_history_empty(void** state)
|
||||
{
|
||||
prof_connect();
|
||||
|
||||
prof_input("/history on");
|
||||
assert_true(prof_output_regex("Chat history enabled\\."));
|
||||
|
||||
/* buddy2 has never exchanged messages — history is empty */
|
||||
prof_input("/msg buddy2@localhost");
|
||||
assert_true(prof_output_exact("buddy2@localhost"));
|
||||
|
||||
/* Verify the window is functional by sending a message */
|
||||
prof_input("empty-history-smoke-test");
|
||||
assert_true(prof_output_regex("me: .+empty-history-smoke-test"));
|
||||
}
|
||||
|
||||
/*
|
||||
* Test: a long message (1000+ characters) is not truncated by the DB.
|
||||
*/
|
||||
void
|
||||
message_db_history_long_message(void** state)
|
||||
{
|
||||
prof_connect();
|
||||
|
||||
prof_input("/history on");
|
||||
assert_true(prof_output_regex("Chat history enabled\\."));
|
||||
|
||||
/* Build a 1020-character body: 1000 x 'A' + unique suffix */
|
||||
char body[1100];
|
||||
memset(body, 'A', 1000);
|
||||
strcpy(body + 1000, "-long-db-end-MARKER");
|
||||
|
||||
char xml[1500];
|
||||
snprintf(xml, sizeof(xml),
|
||||
"<message id='long1' to='stabber@localhost' "
|
||||
"from='buddy1@localhost/phone' type='chat'>"
|
||||
"<body>%s</body>"
|
||||
"</message>",
|
||||
body);
|
||||
stbbr_send(xml);
|
||||
|
||||
assert_true(prof_output_exact("<< chat message: Buddy1/phone (win 2)"));
|
||||
|
||||
prof_input("/close 2");
|
||||
assert_true(prof_output_exact("Closed window 2"));
|
||||
|
||||
prof_input("/msg buddy1@localhost");
|
||||
assert_true(prof_output_exact("-long-db-end-MARKER"));
|
||||
}
|
||||
|
||||
/*
|
||||
* Test: message body containing embedded newlines (LF) is stored and
|
||||
* loaded correctly.
|
||||
*
|
||||
* Uses XML character reference for literal newline in body.
|
||||
*/
|
||||
void
|
||||
message_db_history_newline(void** state)
|
||||
{
|
||||
prof_connect();
|
||||
|
||||
prof_input("/history on");
|
||||
assert_true(prof_output_regex("Chat history enabled\\."));
|
||||
|
||||
stbbr_send(
|
||||
"<message id='nl1' to='stabber@localhost' "
|
||||
"from='buddy1@localhost/phone' type='chat'>"
|
||||
"<body>nl-first-line-7k nl-second-line-9m nl-third-line-2p</body>"
|
||||
"</message>");
|
||||
assert_true(prof_output_exact("<< chat message: Buddy1/phone (win 2)"));
|
||||
|
||||
prof_input("/close 2");
|
||||
assert_true(prof_output_exact("Closed window 2"));
|
||||
|
||||
prof_input("/msg buddy1@localhost");
|
||||
/* Each fragment must survive the round-trip */
|
||||
assert_true(prof_output_exact("nl-first-line-7k"));
|
||||
assert_true(prof_output_exact("nl-second-line-9m"));
|
||||
assert_true(prof_output_exact("nl-third-line-2p"));
|
||||
}
|
||||
|
||||
/*
|
||||
* Test: characters that could break storage format (backslash, pipe,
|
||||
* percent, braces, brackets, equals) survive DB round-trip.
|
||||
*/
|
||||
void
|
||||
message_db_history_service_chars(void** state)
|
||||
{
|
||||
prof_connect();
|
||||
|
||||
prof_input("/history on");
|
||||
assert_true(prof_output_regex("Chat history enabled\\."));
|
||||
|
||||
stbbr_send(
|
||||
"<message id='svc1' to='stabber@localhost' "
|
||||
"from='buddy1@localhost/phone' type='chat'>"
|
||||
"<body>svc: a\\b c|d e%f {g} [h] i=j</body>"
|
||||
"</message>");
|
||||
assert_true(prof_output_exact("<< chat message: Buddy1/phone (win 2)"));
|
||||
|
||||
prof_input("/close 2");
|
||||
assert_true(prof_output_exact("Closed window 2"));
|
||||
|
||||
prof_input("/msg buddy1@localhost");
|
||||
assert_true(prof_output_exact("svc: a\\b c|d e%f {g} [h] i=j"));
|
||||
}
|
||||
|
||||
/*
|
||||
* Test: /history verify reports no issues after normal message writes.
|
||||
*/
|
||||
void
|
||||
message_db_history_verify(void** state)
|
||||
{
|
||||
prof_connect();
|
||||
|
||||
prof_input("/history on");
|
||||
assert_true(prof_output_regex("Chat history enabled\\."));
|
||||
|
||||
/* Write a few messages to create non-trivial DB state */
|
||||
stbbr_send(
|
||||
"<message id='ver1' to='stabber@localhost' "
|
||||
"from='buddy1@localhost/phone' type='chat'>"
|
||||
"<body>verify-msg-one</body>"
|
||||
"</message>");
|
||||
assert_true(prof_output_exact("<< chat message: Buddy1/phone (win 2)"));
|
||||
|
||||
stbbr_send(
|
||||
"<message id='ver2' to='stabber@localhost' "
|
||||
"from='buddy2@localhost/phone' type='chat'>"
|
||||
"<body>verify-msg-two</body>"
|
||||
"</message>");
|
||||
assert_true(prof_output_exact("<< chat message: Buddy2/phone (win 3)"));
|
||||
|
||||
/* Run verify — should find no issues */
|
||||
prof_input("/history verify");
|
||||
assert_true(prof_output_exact("Verification complete: no issues found."));
|
||||
}
|
||||
|
||||
/*
|
||||
* Test: LMC (Last Message Correction, XEP-0308) — incoming correction
|
||||
* replaces the original in DB history.
|
||||
*
|
||||
* Both messages arrive while focused on the console so their bodies
|
||||
* are never rendered to the terminal. After reopen, only the corrected
|
||||
* text should appear — the original should be absent.
|
||||
*/
|
||||
void
|
||||
message_db_history_lmc(void** state)
|
||||
{
|
||||
prof_connect();
|
||||
|
||||
prof_input("/history on");
|
||||
assert_true(prof_output_regex("Chat history enabled\\."));
|
||||
|
||||
/* Original message (body never displayed — received on console) */
|
||||
stbbr_send(
|
||||
"<message id='lmc-orig-1' to='stabber@localhost' "
|
||||
"from='buddy1@localhost/phone' type='chat'>"
|
||||
"<body>lmc-before-correction-aaa</body>"
|
||||
"</message>");
|
||||
assert_true(prof_output_exact("<< chat message: Buddy1/phone (win 2)"));
|
||||
|
||||
/* Correction replacing the original (XEP-0308).
|
||||
* Use buddy2 message as sync barrier to ensure correction is processed. */
|
||||
stbbr_send(
|
||||
"<message id='lmc-corr-1' to='stabber@localhost' "
|
||||
"from='buddy1@localhost/phone' type='chat'>"
|
||||
"<body>lmc-after-correction-zzz</body>"
|
||||
"<replace id='lmc-orig-1' xmlns='urn:xmpp:message-correct:0'/>"
|
||||
"</message>");
|
||||
|
||||
/* Sync barrier: send from buddy2, wait for its notification */
|
||||
stbbr_send(
|
||||
"<message id='lmc-sync' to='stabber@localhost' "
|
||||
"from='buddy2@localhost/phone' type='chat'>"
|
||||
"<body>lmc-sync-barrier</body>"
|
||||
"</message>");
|
||||
assert_true(prof_output_exact("<< chat message: Buddy2/phone (win 3)"));
|
||||
|
||||
/* Close buddy1 and buddy2 windows */
|
||||
prof_input("/close all");
|
||||
assert_true(prof_output_exact("Closed 2 windows."));
|
||||
|
||||
/* Reopen buddy1 — should have corrected text only */
|
||||
prof_input("/msg buddy1@localhost");
|
||||
assert_true(prof_output_exact("lmc-after-correction-zzz"));
|
||||
|
||||
/* Original body was never in the terminal buffer (received on console).
|
||||
* Its absence in history proves the correction was applied in the DB. */
|
||||
prof_timeout(3);
|
||||
assert_false(prof_output_exact("lmc-before-correction-aaa"));
|
||||
prof_timeout_reset();
|
||||
}
|
||||
12
tests/functionaltests/test_history.h
Normal file
12
tests/functionaltests/test_history.h
Normal file
@@ -0,0 +1,12 @@
|
||||
void message_db_history_on_reopen(void** state);
|
||||
void message_db_history_multiple(void** state);
|
||||
void message_db_history_contact_isolation(void** state);
|
||||
void message_db_history_special_chars(void** state);
|
||||
void message_db_history_outgoing(void** state);
|
||||
void message_db_history_dialog(void** state);
|
||||
void message_db_history_empty(void** state);
|
||||
void message_db_history_long_message(void** state);
|
||||
void message_db_history_newline(void** state);
|
||||
void message_db_history_service_chars(void** state);
|
||||
void message_db_history_verify(void** state);
|
||||
void message_db_history_lmc(void** state);
|
||||
@@ -23,7 +23,15 @@
|
||||
#include <glib.h>
|
||||
#include "prof_cmocka.h"
|
||||
|
||||
#include "config.h"
|
||||
#include "database.h"
|
||||
#include "database_flatfile.h"
|
||||
|
||||
db_backend_t* active_db_backend = NULL;
|
||||
|
||||
// The flatfile parser needs this global for path construction.
|
||||
// In tests we keep it NULL since no real file I/O happens via the backend.
|
||||
char* g_flatfile_account_jid = NULL;
|
||||
|
||||
gboolean
|
||||
log_database_init(ProfAccount* account)
|
||||
@@ -50,3 +58,66 @@ void
|
||||
log_database_close(void)
|
||||
{
|
||||
}
|
||||
gboolean
|
||||
log_database_switch_backend(const char* new_backend)
|
||||
{
|
||||
return TRUE;
|
||||
}
|
||||
GSList*
|
||||
log_database_verify_integrity(const gchar* const contact_barejid)
|
||||
{
|
||||
return NULL;
|
||||
}
|
||||
void
|
||||
integrity_issue_free(integrity_issue_t* issue)
|
||||
{
|
||||
if (issue) {
|
||||
g_free(issue->file);
|
||||
g_free(issue->message);
|
||||
g_free(issue);
|
||||
}
|
||||
}
|
||||
#ifdef HAVE_SQLITE
|
||||
db_backend_t*
|
||||
db_backend_sqlite(void)
|
||||
{
|
||||
return NULL;
|
||||
}
|
||||
GSList*
|
||||
db_sqlite_list_contacts(void)
|
||||
{
|
||||
return NULL;
|
||||
}
|
||||
GSList*
|
||||
db_sqlite_get_all_chat(const gchar* const contact_barejid)
|
||||
{
|
||||
return NULL;
|
||||
}
|
||||
void
|
||||
db_sqlite_begin_transaction(void)
|
||||
{
|
||||
}
|
||||
void
|
||||
db_sqlite_end_transaction(void)
|
||||
{
|
||||
}
|
||||
void
|
||||
db_sqlite_rollback_transaction(void)
|
||||
{
|
||||
}
|
||||
int
|
||||
log_database_export_to_flatfile(const gchar* const contact_jid)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
int
|
||||
log_database_import_from_flatfile(const gchar* const contact_jid)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
#endif
|
||||
db_backend_t*
|
||||
db_backend_flatfile(void)
|
||||
{
|
||||
return NULL;
|
||||
}
|
||||
|
||||
@@ -1,463 +0,0 @@
|
||||
#include "prof_cmocka.h"
|
||||
#include "common.h"
|
||||
#include "ai/ai_client.h"
|
||||
#include <stdlib.h>
|
||||
|
||||
/* ========================================================================
|
||||
* Setup/Teardown
|
||||
* ======================================================================== */
|
||||
|
||||
int
|
||||
ai_client_setup(void** state)
|
||||
{
|
||||
ai_client_init();
|
||||
return 0;
|
||||
}
|
||||
|
||||
int
|
||||
ai_client_teardown(void** state)
|
||||
{
|
||||
ai_client_shutdown();
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ========================================================================
|
||||
* Provider Management Tests
|
||||
* ======================================================================== */
|
||||
|
||||
void
|
||||
test_ai_client_init(void** state)
|
||||
{
|
||||
/* After init, default providers should exist */
|
||||
AIProvider* openai = ai_get_provider("openai");
|
||||
assert_non_null(openai);
|
||||
assert_string_equal("openai", openai->name);
|
||||
assert_string_equal("https://api.openai.com/", openai->api_url);
|
||||
|
||||
AIProvider* perplexity = ai_get_provider("perplexity");
|
||||
assert_non_null(perplexity);
|
||||
assert_string_equal("perplexity", perplexity->name);
|
||||
assert_string_equal("https://api.perplexity.ai/", perplexity->api_url);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_add_provider(void** state)
|
||||
{
|
||||
/* Add a custom provider (hash table owns ref; caller gets non-owning pointer) */
|
||||
AIProvider* provider = ai_add_provider("custom", "https://custom.api.com/v1", "my-org");
|
||||
assert_non_null(provider);
|
||||
assert_string_equal("custom", provider->name);
|
||||
assert_string_equal("https://custom.api.com/v1", provider->api_url);
|
||||
assert_string_equal("my-org", provider->org_id);
|
||||
|
||||
/* Update existing provider (returns ref; caller owns it) */
|
||||
AIProvider* updated = ai_add_provider("custom", "https://new.api.com/v1", NULL);
|
||||
assert_non_null(updated);
|
||||
assert_string_equal("https://new.api.com/v1", updated->api_url);
|
||||
assert_null(updated->org_id);
|
||||
|
||||
ai_provider_unref(updated);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_remove_provider(void** state)
|
||||
{
|
||||
/* Remove default provider should fail */
|
||||
assert_false(ai_remove_provider("nonexistent"));
|
||||
|
||||
/* Add and remove custom provider */
|
||||
ai_add_provider("temp", "https://temp.api.com/v1", NULL);
|
||||
assert_true(ai_remove_provider("temp"));
|
||||
assert_null(ai_get_provider("temp"));
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_list_providers(void** state)
|
||||
{
|
||||
GList* providers = ai_list_providers();
|
||||
assert_non_null(providers);
|
||||
assert_int_equal(2, g_list_length(providers)); /* openai and perplexity */
|
||||
|
||||
/* Free list (ai_list_providers returns non-ref'd providers; caller must not unref) */
|
||||
g_list_free(providers);
|
||||
|
||||
/* Add another provider */
|
||||
ai_add_provider("test", "https://test.api.com/v1", NULL);
|
||||
providers = ai_list_providers();
|
||||
assert_int_equal(3, g_list_length(providers));
|
||||
|
||||
/* Free list */
|
||||
g_list_free(providers);
|
||||
}
|
||||
|
||||
/* ========================================================================
|
||||
* API Key Tests
|
||||
* ======================================================================== */
|
||||
|
||||
void
|
||||
test_ai_set_provider_key(void** state)
|
||||
{
|
||||
ai_set_provider_key("openai", "sk-test-key-123");
|
||||
{
|
||||
auto_gchar gchar* key = ai_get_provider_key("openai");
|
||||
assert_non_null(key);
|
||||
assert_string_equal("sk-test-key-123", key);
|
||||
}
|
||||
|
||||
/* Update key */
|
||||
ai_set_provider_key("openai", "sk-new-key-456");
|
||||
{
|
||||
auto_gchar gchar* key = ai_get_provider_key("openai");
|
||||
assert_string_equal("sk-new-key-456", key);
|
||||
}
|
||||
|
||||
/* Remove key */
|
||||
ai_set_provider_key("openai", NULL);
|
||||
{
|
||||
auto_gchar gchar* key = ai_get_provider_key("openai");
|
||||
assert_null(key);
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_get_provider_key(void** state)
|
||||
{
|
||||
/* No key set initially */
|
||||
{
|
||||
auto_gchar gchar* key = ai_get_provider_key("openai");
|
||||
assert_null(key);
|
||||
}
|
||||
|
||||
/* Set and get key */
|
||||
ai_set_provider_key("perplexity", "pplx-abc123");
|
||||
{
|
||||
auto_gchar gchar* key = ai_get_provider_key("perplexity");
|
||||
assert_non_null(key);
|
||||
assert_string_equal("pplx-abc123", key);
|
||||
}
|
||||
|
||||
/* Wrong provider returns null */
|
||||
{
|
||||
auto_gchar gchar* key = ai_get_provider_key("openai");
|
||||
assert_null(key);
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================================================
|
||||
* Session Tests
|
||||
* ======================================================================== */
|
||||
|
||||
void
|
||||
test_ai_session_create(void** state)
|
||||
{
|
||||
AISession* session = ai_session_create("openai", "gpt-4");
|
||||
assert_non_null(session);
|
||||
assert_string_equal("openai", session->provider_name);
|
||||
assert_string_equal("gpt-4", session->model);
|
||||
assert_null(session->api_key); /* No key set */
|
||||
|
||||
ai_session_unref(session);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_session_ref_unref(void** state)
|
||||
{
|
||||
AISession* session = ai_session_create("openai", "gpt-4");
|
||||
assert_non_null(session);
|
||||
|
||||
/* Reference */
|
||||
AISession* ref = ai_session_ref(session);
|
||||
assert_true(ref == session);
|
||||
|
||||
/* Unreference twice */
|
||||
ai_session_unref(session);
|
||||
ai_session_unref(ref); /* Should free here */
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_session_add_message(void** state)
|
||||
{
|
||||
AISession* session = ai_session_create("openai", "gpt-4");
|
||||
assert_non_null(session);
|
||||
|
||||
ai_session_add_message(session, "user", "Hello");
|
||||
ai_session_add_message(session, "assistant", "Hi there!");
|
||||
|
||||
assert_int_equal(2, g_list_length(session->history));
|
||||
|
||||
AIMessage* first = session->history->data;
|
||||
assert_string_equal("user", first->role);
|
||||
assert_string_equal("Hello", first->content);
|
||||
|
||||
AIMessage* second = g_list_next(session->history)->data;
|
||||
assert_string_equal("assistant", second->role);
|
||||
assert_string_equal("Hi there!", second->content);
|
||||
|
||||
ai_session_unref(session);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_session_clear_history(void** state)
|
||||
{
|
||||
AISession* session = ai_session_create("openai", "gpt-4");
|
||||
|
||||
ai_session_add_message(session, "user", "Message 1");
|
||||
ai_session_add_message(session, "user", "Message 2");
|
||||
ai_session_add_message(session, "assistant", "Response");
|
||||
|
||||
assert_int_equal(3, g_list_length(session->history));
|
||||
|
||||
ai_session_clear_history(session);
|
||||
assert_int_equal(0, g_list_length(session->history));
|
||||
|
||||
ai_session_unref(session);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_session_set_model(void** state)
|
||||
{
|
||||
AISession* session = ai_session_create("openai", "gpt-4");
|
||||
assert_string_equal("gpt-4", session->model);
|
||||
|
||||
ai_session_set_model(session, "gpt-3.5-turbo");
|
||||
assert_string_equal("gpt-3.5-turbo", session->model);
|
||||
|
||||
ai_session_unref(session);
|
||||
}
|
||||
|
||||
/* ========================================================================
|
||||
* JSON Escape Tests
|
||||
* ======================================================================== */
|
||||
|
||||
void
|
||||
test_ai_json_escape(void** state)
|
||||
{
|
||||
gchar* escaped = ai_json_escape("hello \"world\"");
|
||||
assert_string_equal("hello \\\"world\\\"", escaped);
|
||||
g_free(escaped);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_json_escape_null(void** state)
|
||||
{
|
||||
gchar* escaped = ai_json_escape(NULL);
|
||||
assert_string_equal("", escaped);
|
||||
g_free(escaped);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_json_escape_empty(void** state)
|
||||
{
|
||||
gchar* escaped = ai_json_escape("");
|
||||
assert_string_equal("", escaped);
|
||||
g_free(escaped);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_json_escape_special_chars(void** state)
|
||||
{
|
||||
gchar* escaped = ai_json_escape("line1\nline2\ttab\\backslash\"quote");
|
||||
assert_string_equal("line1\\nline2\\ttab\\\\backslash\\\"quote", escaped);
|
||||
g_free(escaped);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_json_escape_percent_signs(void** state)
|
||||
{
|
||||
/* Critical: % characters in content must be escaped for JSON, not treated as format specifiers */
|
||||
gchar* escaped = ai_json_escape("100% complete with %s and %d format strings");
|
||||
assert_string_equal("100% complete with %s and %d format strings", escaped);
|
||||
g_free(escaped);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_json_escape_backslash_quote(void** state)
|
||||
{
|
||||
/* Test escaped quote handling */
|
||||
gchar* escaped = ai_json_escape("He said \"hello\" and \\ goodbye");
|
||||
assert_string_equal("He said \\\"hello\\\" and \\\\ goodbye", escaped);
|
||||
g_free(escaped);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_session_api_key_is_copied(void** state)
|
||||
{
|
||||
/* Verify that session owns its own copy of the API key */
|
||||
ai_set_provider_key("openai", "sk-test-key-123");
|
||||
|
||||
AISession* session = ai_session_create("openai", "gpt-4");
|
||||
assert_non_null(session);
|
||||
assert_string_equal("sk-test-key-123", session->api_key);
|
||||
|
||||
/* Remove the provider key - session should still have its copy */
|
||||
ai_set_provider_key("openai", NULL);
|
||||
assert_non_null(session->api_key);
|
||||
assert_string_equal("sk-test-key-123", session->api_key);
|
||||
|
||||
ai_session_unref(session);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_add_provider_update_existing(void** state)
|
||||
{
|
||||
/* Add a provider (hash table owns ref) */
|
||||
AIProvider* provider = ai_add_provider("custom", "https://first.api.com/v1", "org1");
|
||||
assert_non_null(provider);
|
||||
assert_string_equal("https://first.api.com/v1", provider->api_url);
|
||||
assert_string_equal("org1", provider->org_id);
|
||||
|
||||
/* Update the same provider (returns ref) */
|
||||
provider = ai_add_provider("custom", "https://second.api.com/v1", "org2");
|
||||
assert_non_null(provider);
|
||||
assert_string_equal("https://second.api.com/v1", provider->api_url);
|
||||
assert_string_equal("org2", provider->org_id);
|
||||
ai_provider_unref(provider);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_add_provider_null_name_returns_null(void** state)
|
||||
{
|
||||
assert_null(ai_add_provider(NULL, "https://api.com/v1", NULL));
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_add_provider_null_url_returns_null(void** state)
|
||||
{
|
||||
assert_null(ai_add_provider("test", NULL, NULL));
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_session_create_null_provider_returns_null(void** state)
|
||||
{
|
||||
assert_null(ai_session_create("nonexistent", "gpt-4"));
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_session_create_null_model_returns_null(void** state)
|
||||
{
|
||||
assert_null(ai_session_create("openai", NULL));
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_session_api_key_null_when_no_key_set(void** state)
|
||||
{
|
||||
/* openai has no key set by default */
|
||||
AISession* session = ai_session_create("openai", "gpt-4");
|
||||
assert_non_null(session);
|
||||
assert_null(session->api_key);
|
||||
ai_session_unref(session);
|
||||
}
|
||||
|
||||
/* ========================================================================
|
||||
* Provider Autocomplete Tests
|
||||
* ======================================================================== */
|
||||
|
||||
void
|
||||
test_ai_providers_find_forward(void** state)
|
||||
{
|
||||
/* Test forward iteration - should return first match */
|
||||
auto_gchar gchar* result = ai_providers_find("o", FALSE, NULL);
|
||||
assert_non_null(result);
|
||||
assert_string_equal("openai", result);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_providers_find_forward_perplexity(void** state)
|
||||
{
|
||||
/* Test forward iteration for perplexity */
|
||||
auto_gchar gchar* result = ai_providers_find("p", FALSE, NULL);
|
||||
assert_non_null(result);
|
||||
assert_string_equal("perplexity", result);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_providers_find_forward_custom(void** state)
|
||||
{
|
||||
/* Add a custom provider and test */
|
||||
ai_add_provider("custom", "https://custom.api.com/v1", NULL);
|
||||
|
||||
auto_gchar gchar* result = ai_providers_find("c", FALSE, NULL);
|
||||
assert_non_null(result);
|
||||
assert_string_equal("custom", result);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_providers_find_forward_no_match(void** state)
|
||||
{
|
||||
/* Test no match */
|
||||
auto_gchar gchar* result = ai_providers_find("z", FALSE, NULL);
|
||||
assert_null(result);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_providers_find_forward_partial_match(void** state)
|
||||
{
|
||||
/* Test partial match - should return providers starting with "ope" */
|
||||
auto_gchar gchar* result = ai_providers_find("ope", FALSE, NULL);
|
||||
assert_non_null(result);
|
||||
assert_string_equal("openai", result);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_providers_find_next(void** state)
|
||||
{
|
||||
/* Test that stateless implementation returns same result each call */
|
||||
auto_gchar gchar* result1 = ai_providers_find("o", FALSE, NULL);
|
||||
assert_non_null(result1);
|
||||
assert_string_equal("openai", result1);
|
||||
|
||||
/* Second call with same params returns same result (stateless) */
|
||||
auto_gchar gchar* result2 = ai_providers_find("o", FALSE, NULL);
|
||||
assert_non_null(result2);
|
||||
assert_string_equal("openai", result2);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_providers_find_previous(void** state)
|
||||
{
|
||||
/* Test that previous=TRUE returns last match in list */
|
||||
/* With only "openai" starting with "o", both FALSE and TRUE return same result */
|
||||
auto_gchar gchar* result1 = ai_providers_find("o", FALSE, NULL);
|
||||
assert_non_null(result1);
|
||||
assert_string_equal("openai", result1);
|
||||
|
||||
/* previous=TRUE also returns "openai" (only one match, so first==last) */
|
||||
auto_gchar gchar* result2 = ai_providers_find("o", TRUE, NULL);
|
||||
assert_non_null(result2);
|
||||
assert_string_equal("openai", result2);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_providers_find_null_search_str(void** state)
|
||||
{
|
||||
/* NULL search_str triggers cycling: returns first provider in list */
|
||||
auto_gchar gchar* result = ai_providers_find(NULL, FALSE, NULL);
|
||||
assert_non_null(result);
|
||||
assert_string_equal("openai", result);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_providers_find_empty_search_str(void** state)
|
||||
{
|
||||
/* Empty search_str triggers cycling: returns first provider in list */
|
||||
auto_gchar gchar* result = ai_providers_find("", FALSE, NULL);
|
||||
assert_non_null(result);
|
||||
assert_string_equal("openai", result);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_providers_find_case_insensitive(void** state)
|
||||
{
|
||||
/* Test that matching is case-insensitive (via g_ascii_strdown) */
|
||||
auto_gchar gchar* result = ai_providers_find("OPENAI", FALSE, NULL);
|
||||
assert_non_null(result);
|
||||
assert_string_equal("openai", result);
|
||||
|
||||
result = ai_providers_find("OpenAI", FALSE, NULL);
|
||||
assert_non_null(result);
|
||||
assert_string_equal("openai", result);
|
||||
|
||||
result = ai_providers_find("openai", FALSE, NULL);
|
||||
assert_non_null(result);
|
||||
assert_string_equal("openai", result);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
/*
|
||||
* test_ai_client.h - AI client unit test declarations
|
||||
*/
|
||||
|
||||
int ai_client_setup(void** state);
|
||||
int ai_client_teardown(void** state);
|
||||
|
||||
void test_ai_client_init(void** state);
|
||||
void test_ai_add_provider(void** state);
|
||||
void test_ai_remove_provider(void** state);
|
||||
void test_ai_list_providers(void** state);
|
||||
void test_ai_set_provider_key(void** state);
|
||||
void test_ai_get_provider_key(void** state);
|
||||
void test_ai_session_create(void** state);
|
||||
void test_ai_session_ref_unref(void** state);
|
||||
void test_ai_session_add_message(void** state);
|
||||
void test_ai_session_clear_history(void** state);
|
||||
void test_ai_session_set_model(void** state);
|
||||
void test_ai_json_escape(void** state);
|
||||
void test_ai_json_escape_null(void** state);
|
||||
void test_ai_json_escape_empty(void** state);
|
||||
void test_ai_json_escape_special_chars(void** state);
|
||||
void test_ai_json_escape_percent_signs(void** state);
|
||||
void test_ai_json_escape_backslash_quote(void** state);
|
||||
void test_ai_session_api_key_is_copied(void** state);
|
||||
void test_ai_add_provider_update_existing(void** state);
|
||||
void test_ai_add_provider_null_name_returns_null(void** state);
|
||||
void test_ai_add_provider_null_url_returns_null(void** state);
|
||||
void test_ai_session_create_null_provider_returns_null(void** state);
|
||||
void test_ai_session_create_null_model_returns_null(void** state);
|
||||
void test_ai_session_api_key_null_when_no_key_set(void** state);
|
||||
/* Provider autocomplete tests */
|
||||
void test_ai_providers_find_forward(void** state);
|
||||
void test_ai_providers_find_forward_perplexity(void** state);
|
||||
void test_ai_providers_find_forward_custom(void** state);
|
||||
void test_ai_providers_find_forward_no_match(void** state);
|
||||
void test_ai_providers_find_forward_partial_match(void** state);
|
||||
void test_ai_providers_find_next(void** state);
|
||||
void test_ai_providers_find_previous(void** state);
|
||||
void test_ai_providers_find_null_search_str(void** state);
|
||||
void test_ai_providers_find_empty_search_str(void** state);
|
||||
void test_ai_providers_find_case_insensitive(void** state);
|
||||
721
tests/unittests/test_database_export.c
Normal file
721
tests/unittests/test_database_export.c
Normal file
@@ -0,0 +1,721 @@
|
||||
/*
|
||||
* test_database_export.c
|
||||
*
|
||||
* Unit tests for the flatfile write→parse round-trip, escape/unescape helpers,
|
||||
* jid_to_dir path construction, and parser edge cases — these are the core
|
||||
* functions exercised by database_export.c during export and import.
|
||||
*
|
||||
* Copyright (C) 2026 Profanity Contributors
|
||||
* License: GPL-3.0-or-later (same as Profanity)
|
||||
*/
|
||||
|
||||
#include "prof_cmocka.h"
|
||||
|
||||
#include <glib.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include "config.h"
|
||||
#include "database_flatfile.h"
|
||||
|
||||
/* ================================================================
|
||||
* Helper: write a single line to a temporary file, then read and
|
||||
* parse it back. Returns the parsed result; caller must free with
|
||||
* ff_parsed_line_free(). The temp file is removed automatically.
|
||||
* ================================================================ */
|
||||
static ff_parsed_line_t*
|
||||
_roundtrip(const char* timestamp, const char* type, const char* enc,
|
||||
const char* stanza_id, const char* archive_id, const char* replace_id,
|
||||
const char* from_jid, const char* from_resource,
|
||||
const char* to_jid, const char* to_resource, int marked_read,
|
||||
const char* message)
|
||||
{
|
||||
char tmppath[] = "/tmp/proftest_rt_XXXXXX";
|
||||
int fd = mkstemp(tmppath);
|
||||
assert_true(fd >= 0);
|
||||
|
||||
FILE* fp = fdopen(fd, "w");
|
||||
assert_non_null(fp);
|
||||
|
||||
ff_write_line(fp, timestamp, type, enc,
|
||||
stanza_id, archive_id, replace_id,
|
||||
from_jid, from_resource,
|
||||
to_jid, to_resource, marked_read,
|
||||
message);
|
||||
fclose(fp);
|
||||
|
||||
/* Read & parse */
|
||||
fp = fopen(tmppath, "r");
|
||||
assert_non_null(fp);
|
||||
|
||||
gboolean truncated = FALSE;
|
||||
char* buf = ff_readline(fp, &truncated);
|
||||
fclose(fp);
|
||||
unlink(tmppath);
|
||||
|
||||
assert_non_null(buf);
|
||||
assert_false(truncated);
|
||||
|
||||
ff_parsed_line_t* pl = ff_parse_line(buf);
|
||||
free(buf);
|
||||
|
||||
return pl;
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
* Write → parse round-trip tests
|
||||
* ================================================================ */
|
||||
|
||||
void
|
||||
test_ff_roundtrip_simple_chat(void** state)
|
||||
{
|
||||
ff_parsed_line_t* pl = _roundtrip(
|
||||
"2025-06-15T12:30:00+00:00", "chat", "none",
|
||||
NULL, NULL, NULL,
|
||||
"alice@example.com", NULL,
|
||||
NULL, NULL, -1,
|
||||
"Hello, world!");
|
||||
|
||||
assert_non_null(pl);
|
||||
assert_string_equal(pl->timestamp_str, "2025-06-15T12:30:00+00:00");
|
||||
assert_string_equal(pl->type, "chat");
|
||||
assert_string_equal(pl->enc, "none");
|
||||
assert_string_equal(pl->from_jid, "alice@example.com");
|
||||
assert_null(pl->stanza_id);
|
||||
assert_null(pl->archive_id);
|
||||
assert_null(pl->replace_id);
|
||||
assert_string_equal(pl->message, "Hello, world!");
|
||||
ff_parsed_line_free(pl);
|
||||
}
|
||||
|
||||
void
|
||||
test_ff_roundtrip_with_all_metadata(void** state)
|
||||
{
|
||||
ff_parsed_line_t* pl = _roundtrip(
|
||||
"2025-06-15T12:30:00+00:00", "chat", "omemo",
|
||||
"sid-abc-123", "aid-xyz-789", "corrects-old-id",
|
||||
"bob@example.com", "phone",
|
||||
NULL, NULL, -1,
|
||||
"Encrypted message.");
|
||||
|
||||
assert_non_null(pl);
|
||||
assert_string_equal(pl->stanza_id, "sid-abc-123");
|
||||
assert_string_equal(pl->archive_id, "aid-xyz-789");
|
||||
assert_string_equal(pl->replace_id, "corrects-old-id");
|
||||
assert_string_equal(pl->enc, "omemo");
|
||||
assert_string_equal(pl->from_jid, "bob@example.com");
|
||||
assert_string_equal(pl->from_resource, "phone");
|
||||
assert_string_equal(pl->message, "Encrypted message.");
|
||||
ff_parsed_line_free(pl);
|
||||
}
|
||||
|
||||
void
|
||||
test_ff_roundtrip_with_resource(void** state)
|
||||
{
|
||||
ff_parsed_line_t* pl = _roundtrip(
|
||||
"2025-01-01T00:00:00Z", "chat", "none",
|
||||
NULL, NULL, NULL,
|
||||
"user@jabber.org", "Profanity.abc123",
|
||||
NULL, NULL, -1,
|
||||
"hi");
|
||||
|
||||
assert_non_null(pl);
|
||||
assert_string_equal(pl->from_jid, "user@jabber.org");
|
||||
assert_string_equal(pl->from_resource, "Profanity.abc123");
|
||||
assert_string_equal(pl->message, "hi");
|
||||
ff_parsed_line_free(pl);
|
||||
}
|
||||
|
||||
void
|
||||
test_ff_roundtrip_newline_in_body(void** state)
|
||||
{
|
||||
ff_parsed_line_t* pl = _roundtrip(
|
||||
"2025-03-01T10:00:00Z", "chat", "none",
|
||||
NULL, NULL, NULL,
|
||||
"alice@x.com", NULL,
|
||||
NULL, NULL, -1,
|
||||
"line1\nline2\nline3");
|
||||
|
||||
assert_non_null(pl);
|
||||
assert_string_equal(pl->message, "line1\nline2\nline3");
|
||||
ff_parsed_line_free(pl);
|
||||
}
|
||||
|
||||
void
|
||||
test_ff_roundtrip_pipe_in_stanza_id(void** state)
|
||||
{
|
||||
/* pipes in stanza_id must be escaped in metadata */
|
||||
ff_parsed_line_t* pl = _roundtrip(
|
||||
"2025-03-01T10:00:00Z", "chat", "none",
|
||||
"id|with|pipes", "aid|also|has|pipes", NULL,
|
||||
"a@b.com", NULL,
|
||||
NULL, NULL, -1,
|
||||
"test");
|
||||
|
||||
assert_non_null(pl);
|
||||
assert_string_equal(pl->stanza_id, "id|with|pipes");
|
||||
assert_string_equal(pl->archive_id, "aid|also|has|pipes");
|
||||
ff_parsed_line_free(pl);
|
||||
}
|
||||
|
||||
void
|
||||
test_ff_roundtrip_backslash_in_body(void** state)
|
||||
{
|
||||
ff_parsed_line_t* pl = _roundtrip(
|
||||
"2025-03-01T10:00:00Z", "chat", "none",
|
||||
NULL, NULL, NULL,
|
||||
"a@b.com", NULL,
|
||||
NULL, NULL, -1,
|
||||
"path\\to\\file C:\\Users\\test");
|
||||
|
||||
assert_non_null(pl);
|
||||
assert_string_equal(pl->message, "path\\to\\file C:\\Users\\test");
|
||||
ff_parsed_line_free(pl);
|
||||
}
|
||||
|
||||
void
|
||||
test_ff_roundtrip_unicode_body(void** state)
|
||||
{
|
||||
ff_parsed_line_t* pl = _roundtrip(
|
||||
"2025-03-01T10:00:00Z", "chat", "none",
|
||||
NULL, NULL, NULL,
|
||||
"a@b.com", NULL,
|
||||
NULL, NULL, -1,
|
||||
"Привет мир 🌍 日本語テスト");
|
||||
|
||||
assert_non_null(pl);
|
||||
assert_string_equal(pl->message, "Привет мир 🌍 日本語テスト");
|
||||
ff_parsed_line_free(pl);
|
||||
}
|
||||
|
||||
void
|
||||
test_ff_roundtrip_empty_body(void** state)
|
||||
{
|
||||
ff_parsed_line_t* pl = _roundtrip(
|
||||
"2025-03-01T10:00:00Z", "chat", "none",
|
||||
NULL, NULL, NULL,
|
||||
"a@b.com", NULL,
|
||||
NULL, NULL, -1,
|
||||
"");
|
||||
|
||||
assert_non_null(pl);
|
||||
assert_string_equal(pl->message, "");
|
||||
ff_parsed_line_free(pl);
|
||||
}
|
||||
|
||||
void
|
||||
test_ff_roundtrip_colonspace_in_resource(void** state)
|
||||
{
|
||||
/* ": " in resource must be escaped during write and unescaped on parse */
|
||||
ff_parsed_line_t* pl = _roundtrip(
|
||||
"2025-03-01T10:00:00Z", "chat", "none",
|
||||
NULL, NULL, NULL,
|
||||
"a@b.com", "res: with: colons",
|
||||
NULL, NULL, -1,
|
||||
"msg");
|
||||
|
||||
assert_non_null(pl);
|
||||
assert_string_equal(pl->from_jid, "a@b.com");
|
||||
assert_string_equal(pl->from_resource, "res: with: colons");
|
||||
assert_string_equal(pl->message, "msg");
|
||||
ff_parsed_line_free(pl);
|
||||
}
|
||||
|
||||
void
|
||||
test_ff_roundtrip_muc_type(void** state)
|
||||
{
|
||||
ff_parsed_line_t* pl = _roundtrip(
|
||||
"2025-03-01T10:00:00Z", "muc", "none",
|
||||
NULL, NULL, NULL,
|
||||
"room@conference.x.com", "nick",
|
||||
NULL, NULL, -1,
|
||||
"hello room");
|
||||
|
||||
assert_non_null(pl);
|
||||
assert_string_equal(pl->type, "muc");
|
||||
assert_string_equal(pl->from_jid, "room@conference.x.com");
|
||||
assert_string_equal(pl->from_resource, "nick");
|
||||
ff_parsed_line_free(pl);
|
||||
}
|
||||
|
||||
void
|
||||
test_ff_roundtrip_omemo_enc(void** state)
|
||||
{
|
||||
ff_parsed_line_t* pl = _roundtrip(
|
||||
"2025-03-01T10:00:00Z", "chat", "omemo",
|
||||
"sid-1", NULL, NULL,
|
||||
"a@b.com", NULL,
|
||||
NULL, NULL, -1,
|
||||
"secret");
|
||||
|
||||
assert_non_null(pl);
|
||||
assert_string_equal(pl->enc, "omemo");
|
||||
ff_parsed_line_free(pl);
|
||||
}
|
||||
|
||||
void
|
||||
test_ff_roundtrip_replace_id(void** state)
|
||||
{
|
||||
/* LMC (Last Message Correction): corrects: field round-trip */
|
||||
ff_parsed_line_t* pl = _roundtrip(
|
||||
"2025-03-01T10:00:00Z", "chat", "none",
|
||||
"new-id", NULL, "old-id-to-correct",
|
||||
"a@b.com", NULL,
|
||||
NULL, NULL, -1,
|
||||
"corrected text");
|
||||
|
||||
assert_non_null(pl);
|
||||
assert_string_equal(pl->stanza_id, "new-id");
|
||||
assert_string_equal(pl->replace_id, "old-id-to-correct");
|
||||
assert_string_equal(pl->message, "corrected text");
|
||||
ff_parsed_line_free(pl);
|
||||
}
|
||||
|
||||
void
|
||||
test_ff_roundtrip_to_jid_and_marked_read(void** state)
|
||||
{
|
||||
/* to_jid, to_resource, and marked_read round-trip */
|
||||
ff_parsed_line_t* pl = _roundtrip(
|
||||
"2025-06-15T12:30:00+00:00", "chat", "none",
|
||||
"sid-1", NULL, NULL,
|
||||
"alice@example.com", "phone",
|
||||
"bob@example.com", "laptop", 1,
|
||||
"Hello Bob!");
|
||||
|
||||
assert_non_null(pl);
|
||||
assert_string_equal(pl->from_jid, "alice@example.com");
|
||||
assert_string_equal(pl->from_resource, "phone");
|
||||
assert_string_equal(pl->to_jid, "bob@example.com");
|
||||
assert_string_equal(pl->to_resource, "laptop");
|
||||
assert_int_equal(pl->marked_read, 1);
|
||||
assert_string_equal(pl->message, "Hello Bob!");
|
||||
ff_parsed_line_free(pl);
|
||||
|
||||
/* marked_read = 0 (unread) */
|
||||
pl = _roundtrip(
|
||||
"2025-06-15T12:31:00+00:00", "chat", "none",
|
||||
NULL, NULL, NULL,
|
||||
"bob@example.com", NULL,
|
||||
"alice@example.com", NULL, 0,
|
||||
"Reply");
|
||||
|
||||
assert_non_null(pl);
|
||||
assert_string_equal(pl->to_jid, "alice@example.com");
|
||||
assert_null(pl->to_resource);
|
||||
assert_int_equal(pl->marked_read, 0);
|
||||
ff_parsed_line_free(pl);
|
||||
|
||||
/* marked_read = -1 (unset) — should NOT appear in output */
|
||||
pl = _roundtrip(
|
||||
"2025-06-15T12:32:00+00:00", "chat", "none",
|
||||
NULL, NULL, NULL,
|
||||
"a@b.com", NULL,
|
||||
NULL, NULL, -1,
|
||||
"no read flag");
|
||||
|
||||
assert_non_null(pl);
|
||||
assert_null(pl->to_jid);
|
||||
assert_null(pl->to_resource);
|
||||
assert_int_equal(pl->marked_read, -1);
|
||||
ff_parsed_line_free(pl);
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
* Escape / unescape symmetry tests
|
||||
* ================================================================ */
|
||||
|
||||
void
|
||||
test_ff_escape_unescape_message_identity(void** state)
|
||||
{
|
||||
const char* inputs[] = {
|
||||
"simple text",
|
||||
"has\\backslash",
|
||||
"has\nnewline",
|
||||
"has\rcarriage return",
|
||||
"all\\together\nnow\r!",
|
||||
"already escaped \\n \\r \\\\",
|
||||
"",
|
||||
};
|
||||
|
||||
for (size_t i = 0; i < sizeof(inputs) / sizeof(inputs[0]); i++) {
|
||||
char* escaped = ff_escape_message(inputs[i]);
|
||||
assert_non_null(escaped);
|
||||
/* escaped must not contain raw newlines */
|
||||
assert_null(strchr(escaped, '\n'));
|
||||
assert_null(strchr(escaped, '\r'));
|
||||
|
||||
char* unescaped = ff_unescape_message(escaped);
|
||||
assert_string_equal(unescaped, inputs[i]);
|
||||
|
||||
g_free(escaped);
|
||||
g_free(unescaped);
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
test_ff_escape_unescape_meta_identity(void** state)
|
||||
{
|
||||
const char* inputs[] = {
|
||||
"simple-id",
|
||||
"has|pipe",
|
||||
"has]bracket",
|
||||
"has\\backslash",
|
||||
"has\nnewline",
|
||||
"all|to]ge\\th\ner",
|
||||
};
|
||||
|
||||
for (size_t i = 0; i < sizeof(inputs) / sizeof(inputs[0]); i++) {
|
||||
char* escaped = ff_escape_meta_value(inputs[i]);
|
||||
assert_non_null(escaped);
|
||||
|
||||
char* unescaped = ff_unescape_meta_value(escaped);
|
||||
assert_string_equal(unescaped, inputs[i]);
|
||||
|
||||
g_free(escaped);
|
||||
g_free(unescaped);
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
test_ff_escape_meta_null_returns_null(void** state)
|
||||
{
|
||||
assert_null(ff_escape_meta_value(NULL));
|
||||
assert_null(ff_escape_meta_value(""));
|
||||
assert_null(ff_unescape_meta_value(NULL));
|
||||
}
|
||||
|
||||
void
|
||||
test_ff_escape_message_null_returns_empty(void** state)
|
||||
{
|
||||
char* result = ff_escape_message(NULL);
|
||||
assert_non_null(result);
|
||||
assert_string_equal(result, "");
|
||||
g_free(result);
|
||||
|
||||
result = ff_unescape_message(NULL);
|
||||
assert_non_null(result);
|
||||
assert_string_equal(result, "");
|
||||
g_free(result);
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
* jid_to_dir tests
|
||||
* ================================================================ */
|
||||
|
||||
void
|
||||
test_ff_jid_to_dir_simple(void** state)
|
||||
{
|
||||
char* dir = ff_jid_to_dir("alice");
|
||||
assert_non_null(dir);
|
||||
assert_string_equal(dir, "alice");
|
||||
g_free(dir);
|
||||
}
|
||||
|
||||
void
|
||||
test_ff_jid_to_dir_with_at(void** state)
|
||||
{
|
||||
char* dir = ff_jid_to_dir("alice@example.com");
|
||||
assert_non_null(dir);
|
||||
assert_string_equal(dir, "alice_at_example.com");
|
||||
g_free(dir);
|
||||
}
|
||||
|
||||
void
|
||||
test_ff_jid_to_dir_path_traversal_rejected(void** state)
|
||||
{
|
||||
/* Slashes and '..' must be sanitized */
|
||||
char* dir = ff_jid_to_dir("../../etc/passwd");
|
||||
assert_non_null(dir);
|
||||
/* No slashes or '..' sequences should remain */
|
||||
assert_null(strchr(dir, '/'));
|
||||
assert_null(strstr(dir, ".."));
|
||||
g_free(dir);
|
||||
}
|
||||
|
||||
void
|
||||
test_ff_jid_to_dir_null(void** state)
|
||||
{
|
||||
assert_null(ff_jid_to_dir(NULL));
|
||||
assert_null(ff_jid_to_dir(""));
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
* Parser edge-case tests
|
||||
* ================================================================ */
|
||||
|
||||
void
|
||||
test_ff_parse_line_empty(void** state)
|
||||
{
|
||||
assert_null(ff_parse_line(NULL));
|
||||
assert_null(ff_parse_line(""));
|
||||
}
|
||||
|
||||
void
|
||||
test_ff_parse_line_comment(void** state)
|
||||
{
|
||||
assert_null(ff_parse_line("# this is a comment"));
|
||||
assert_null(ff_parse_line("# profanity chat log — UTF-8, LF line endings"));
|
||||
}
|
||||
|
||||
void
|
||||
test_ff_parse_line_legacy_format(void** state)
|
||||
{
|
||||
/* Legacy chatlog.c format: {timestamp} - {sender}: {message} */
|
||||
ff_parsed_line_t* pl = ff_parse_line(
|
||||
"2025-06-15T12:30:00+00:00 - alice@example.com: Hello legacy");
|
||||
|
||||
assert_non_null(pl);
|
||||
assert_string_equal(pl->timestamp_str, "2025-06-15T12:30:00+00:00");
|
||||
assert_string_equal(pl->from_jid, "alice@example.com");
|
||||
assert_string_equal(pl->message, "Hello legacy");
|
||||
assert_string_equal(pl->type, "chat");
|
||||
assert_string_equal(pl->enc, "none");
|
||||
ff_parsed_line_free(pl);
|
||||
}
|
||||
|
||||
void
|
||||
test_ff_parse_line_no_metadata(void** state)
|
||||
{
|
||||
/* Simple format without [] brackets */
|
||||
ff_parsed_line_t* pl = ff_parse_line(
|
||||
"2025-06-15T12:30:00Z alice@example.com: Simple message");
|
||||
|
||||
assert_non_null(pl);
|
||||
assert_string_equal(pl->from_jid, "alice@example.com");
|
||||
assert_string_equal(pl->message, "Simple message");
|
||||
ff_parsed_line_free(pl);
|
||||
}
|
||||
|
||||
void
|
||||
test_ff_parse_line_invalid_timestamp(void** state)
|
||||
{
|
||||
/* Invalid timestamp should cause parse failure */
|
||||
ff_parsed_line_t* pl = ff_parse_line(
|
||||
"not-a-timestamp [chat|none] a@b.com: msg");
|
||||
|
||||
assert_null(pl);
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
* Type / enc conversion round-trip
|
||||
* ================================================================ */
|
||||
|
||||
void
|
||||
test_ff_type_str_roundtrip(void** state)
|
||||
{
|
||||
assert_int_equal(PROF_MSG_TYPE_CHAT, ff_get_message_type_type(ff_get_message_type_str(PROF_MSG_TYPE_CHAT)));
|
||||
assert_int_equal(PROF_MSG_TYPE_MUC, ff_get_message_type_type(ff_get_message_type_str(PROF_MSG_TYPE_MUC)));
|
||||
assert_int_equal(PROF_MSG_TYPE_MUCPM, ff_get_message_type_type(ff_get_message_type_str(PROF_MSG_TYPE_MUCPM)));
|
||||
}
|
||||
|
||||
void
|
||||
test_ff_enc_str_roundtrip(void** state)
|
||||
{
|
||||
assert_int_equal(PROF_MSG_ENC_NONE, ff_get_message_enc_type(ff_get_message_enc_str(PROF_MSG_ENC_NONE)));
|
||||
assert_int_equal(PROF_MSG_ENC_OTR, ff_get_message_enc_type(ff_get_message_enc_str(PROF_MSG_ENC_OTR)));
|
||||
assert_int_equal(PROF_MSG_ENC_PGP, ff_get_message_enc_type(ff_get_message_enc_str(PROF_MSG_ENC_PGP)));
|
||||
assert_int_equal(PROF_MSG_ENC_OX, ff_get_message_enc_type(ff_get_message_enc_str(PROF_MSG_ENC_OX)));
|
||||
assert_int_equal(PROF_MSG_ENC_OMEMO, ff_get_message_enc_type(ff_get_message_enc_str(PROF_MSG_ENC_OMEMO)));
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
* Additional round-trip tests
|
||||
* ================================================================ */
|
||||
|
||||
void
|
||||
test_ff_roundtrip_bracket_in_stanza_id(void** state)
|
||||
{
|
||||
/* ']' in stanza_id must be escaped as \] to avoid breaking metadata parser */
|
||||
ff_parsed_line_t* pl = _roundtrip(
|
||||
"2025-03-01T10:00:00Z", "chat", "none",
|
||||
"id]with]brackets", "aid]also]has]brackets", NULL,
|
||||
"a@b.com", NULL,
|
||||
NULL, NULL, -1,
|
||||
"test brackets");
|
||||
|
||||
assert_non_null(pl);
|
||||
assert_string_equal(pl->stanza_id, "id]with]brackets");
|
||||
assert_string_equal(pl->archive_id, "aid]also]has]brackets");
|
||||
assert_string_equal(pl->message, "test brackets");
|
||||
ff_parsed_line_free(pl);
|
||||
}
|
||||
|
||||
void
|
||||
test_ff_roundtrip_backslash_in_resource(void** state)
|
||||
{
|
||||
/* backslash in from_resource must survive round-trip */
|
||||
ff_parsed_line_t* pl = _roundtrip(
|
||||
"2025-03-01T10:00:00Z", "chat", "none",
|
||||
NULL, NULL, NULL,
|
||||
"a@b.com", "res\\with\\backslash",
|
||||
NULL, NULL, -1,
|
||||
"msg");
|
||||
|
||||
assert_non_null(pl);
|
||||
assert_string_equal(pl->from_jid, "a@b.com");
|
||||
assert_string_equal(pl->from_resource, "res\\with\\backslash");
|
||||
assert_string_equal(pl->message, "msg");
|
||||
ff_parsed_line_free(pl);
|
||||
}
|
||||
|
||||
void
|
||||
test_ff_roundtrip_mucpm_type(void** state)
|
||||
{
|
||||
ff_parsed_line_t* pl = _roundtrip(
|
||||
"2025-03-01T10:00:00Z", "mucpm", "none",
|
||||
NULL, NULL, NULL,
|
||||
"room@conference.x.com", "nick",
|
||||
NULL, NULL, -1,
|
||||
"private message");
|
||||
|
||||
assert_non_null(pl);
|
||||
assert_string_equal(pl->type, "mucpm");
|
||||
assert_string_equal(pl->from_resource, "nick");
|
||||
ff_parsed_line_free(pl);
|
||||
}
|
||||
|
||||
void
|
||||
test_ff_roundtrip_all_enc_types(void** state)
|
||||
{
|
||||
const char* enc_types[] = { "none", "otr", "pgp", "ox", "omemo" };
|
||||
for (size_t i = 0; i < sizeof(enc_types) / sizeof(enc_types[0]); i++) {
|
||||
ff_parsed_line_t* pl = _roundtrip(
|
||||
"2025-03-01T10:00:00Z", "chat", enc_types[i],
|
||||
NULL, NULL, NULL,
|
||||
"a@b.com", NULL,
|
||||
NULL, NULL, -1,
|
||||
"msg");
|
||||
|
||||
assert_non_null(pl);
|
||||
assert_string_equal(pl->enc, enc_types[i]);
|
||||
ff_parsed_line_free(pl);
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
test_ff_roundtrip_crlf_handling(void** state)
|
||||
{
|
||||
/* Write a line, then read it with \r\n ending — parser should strip \r */
|
||||
char tmppath[] = "/tmp/proftest_crlf_XXXXXX";
|
||||
int fd = mkstemp(tmppath);
|
||||
assert_true(fd >= 0);
|
||||
|
||||
FILE* fp = fdopen(fd, "w");
|
||||
assert_non_null(fp);
|
||||
|
||||
/* Write a raw line with \r\n ending */
|
||||
fprintf(fp, "2025-03-01T10:00:00Z [chat|none] a@b.com: hello\r\n");
|
||||
fclose(fp);
|
||||
|
||||
fp = fopen(tmppath, "r");
|
||||
assert_non_null(fp);
|
||||
|
||||
gboolean truncated = FALSE;
|
||||
char* buf = ff_readline(fp, &truncated);
|
||||
fclose(fp);
|
||||
unlink(tmppath);
|
||||
|
||||
assert_non_null(buf);
|
||||
ff_parsed_line_t* pl = ff_parse_line(buf);
|
||||
free(buf);
|
||||
|
||||
assert_non_null(pl);
|
||||
assert_string_equal(pl->from_jid, "a@b.com");
|
||||
assert_string_equal(pl->message, "hello");
|
||||
ff_parsed_line_free(pl);
|
||||
}
|
||||
|
||||
void
|
||||
test_ff_roundtrip_to_jid_special_chars(void** state)
|
||||
{
|
||||
/* to: and to_res: with pipe and bracket chars that need escaping */
|
||||
ff_parsed_line_t* pl = _roundtrip(
|
||||
"2025-03-01T10:00:00Z", "chat", "none",
|
||||
NULL, NULL, NULL,
|
||||
"a@b.com", NULL,
|
||||
"to|user@c.com", "res]with|special", -1,
|
||||
"msg with special to");
|
||||
|
||||
assert_non_null(pl);
|
||||
assert_string_equal(pl->to_jid, "to|user@c.com");
|
||||
assert_string_equal(pl->to_resource, "res]with|special");
|
||||
assert_string_equal(pl->message, "msg with special to");
|
||||
ff_parsed_line_free(pl);
|
||||
}
|
||||
|
||||
void
|
||||
test_ff_roundtrip_multiple_lines(void** state)
|
||||
{
|
||||
/* Write several lines, read and parse them all sequentially */
|
||||
char tmppath[] = "/tmp/proftest_multi_XXXXXX";
|
||||
int fd = mkstemp(tmppath);
|
||||
assert_true(fd >= 0);
|
||||
|
||||
FILE* fp = fdopen(fd, "w");
|
||||
assert_non_null(fp);
|
||||
|
||||
ff_write_line(fp, "2025-01-01T00:00:01Z", "chat", "none",
|
||||
NULL, NULL, NULL, "a@b.com", NULL,
|
||||
NULL, NULL, -1, "first");
|
||||
ff_write_line(fp, "2025-01-01T00:00:02Z", "chat", "none",
|
||||
NULL, NULL, NULL, "c@d.com", NULL,
|
||||
NULL, NULL, -1, "second");
|
||||
ff_write_line(fp, "2025-01-01T00:00:03Z", "muc", "omemo",
|
||||
"sid-3", NULL, NULL, "room@conf.com", "nick",
|
||||
NULL, NULL, -1, "third");
|
||||
fclose(fp);
|
||||
|
||||
fp = fopen(tmppath, "r");
|
||||
assert_non_null(fp);
|
||||
|
||||
int count = 0;
|
||||
const char* expected_msgs[] = { "first", "second", "third" };
|
||||
const char* expected_from[] = { "a@b.com", "c@d.com", "room@conf.com" };
|
||||
|
||||
while (1) {
|
||||
gboolean truncated = FALSE;
|
||||
char* buf = ff_readline(fp, &truncated);
|
||||
if (!buf)
|
||||
break;
|
||||
ff_parsed_line_t* pl = ff_parse_line(buf);
|
||||
free(buf);
|
||||
if (pl) {
|
||||
assert_true(count < 3);
|
||||
assert_string_equal(pl->message, expected_msgs[count]);
|
||||
assert_string_equal(pl->from_jid, expected_from[count]);
|
||||
ff_parsed_line_free(pl);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
fclose(fp);
|
||||
unlink(tmppath);
|
||||
|
||||
assert_int_equal(count, 3);
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
* Additional parser edge-case tests
|
||||
* ================================================================ */
|
||||
|
||||
void
|
||||
test_ff_parsed_line_free_null_safe(void** state)
|
||||
{
|
||||
/* ff_parsed_line_free(NULL) must not crash */
|
||||
ff_parsed_line_free(NULL);
|
||||
}
|
||||
|
||||
void
|
||||
test_ff_parse_line_no_space_rejected(void** state)
|
||||
{
|
||||
/* A line with no spaces at all cannot be parsed */
|
||||
assert_null(ff_parse_line("noseparatoratall"));
|
||||
}
|
||||
|
||||
void
|
||||
test_ff_parse_line_unclosed_bracket(void** state)
|
||||
{
|
||||
/* Unclosed metadata bracket should return NULL */
|
||||
assert_null(ff_parse_line("2025-03-01T10:00:00Z [chat|none a@b.com: msg"));
|
||||
}
|
||||
54
tests/unittests/test_database_export.h
Normal file
54
tests/unittests/test_database_export.h
Normal file
@@ -0,0 +1,54 @@
|
||||
/* test_database_export.h — unit tests for flatfile write/parse round-trip,
|
||||
* escape/unescape, and jid_to_dir helpers used by export/import. */
|
||||
|
||||
/* write → parse round-trip */
|
||||
void test_ff_roundtrip_simple_chat(void** state);
|
||||
void test_ff_roundtrip_with_all_metadata(void** state);
|
||||
void test_ff_roundtrip_with_resource(void** state);
|
||||
void test_ff_roundtrip_newline_in_body(void** state);
|
||||
void test_ff_roundtrip_pipe_in_stanza_id(void** state);
|
||||
void test_ff_roundtrip_backslash_in_body(void** state);
|
||||
void test_ff_roundtrip_unicode_body(void** state);
|
||||
void test_ff_roundtrip_empty_body(void** state);
|
||||
void test_ff_roundtrip_colonspace_in_resource(void** state);
|
||||
void test_ff_roundtrip_muc_type(void** state);
|
||||
void test_ff_roundtrip_omemo_enc(void** state);
|
||||
void test_ff_roundtrip_replace_id(void** state);
|
||||
void test_ff_roundtrip_to_jid_and_marked_read(void** state);
|
||||
|
||||
/* escape / unescape symmetry */
|
||||
void test_ff_escape_unescape_message_identity(void** state);
|
||||
void test_ff_escape_unescape_meta_identity(void** state);
|
||||
void test_ff_escape_meta_null_returns_null(void** state);
|
||||
void test_ff_escape_message_null_returns_empty(void** state);
|
||||
|
||||
/* jid_to_dir */
|
||||
void test_ff_jid_to_dir_simple(void** state);
|
||||
void test_ff_jid_to_dir_with_at(void** state);
|
||||
void test_ff_jid_to_dir_path_traversal_rejected(void** state);
|
||||
void test_ff_jid_to_dir_null(void** state);
|
||||
|
||||
/* parser edge cases */
|
||||
void test_ff_parse_line_empty(void** state);
|
||||
void test_ff_parse_line_comment(void** state);
|
||||
void test_ff_parse_line_legacy_format(void** state);
|
||||
void test_ff_parse_line_no_metadata(void** state);
|
||||
void test_ff_parse_line_invalid_timestamp(void** state);
|
||||
|
||||
/* type/enc conversion round-trip */
|
||||
void test_ff_type_str_roundtrip(void** state);
|
||||
void test_ff_enc_str_roundtrip(void** state);
|
||||
|
||||
/* additional round-trip */
|
||||
void test_ff_roundtrip_bracket_in_stanza_id(void** state);
|
||||
void test_ff_roundtrip_backslash_in_resource(void** state);
|
||||
void test_ff_roundtrip_mucpm_type(void** state);
|
||||
void test_ff_roundtrip_all_enc_types(void** state);
|
||||
void test_ff_roundtrip_crlf_handling(void** state);
|
||||
void test_ff_roundtrip_to_jid_special_chars(void** state);
|
||||
void test_ff_roundtrip_multiple_lines(void** state);
|
||||
|
||||
/* additional parser edge cases */
|
||||
void test_ff_parsed_line_free_null_safe(void** state);
|
||||
void test_ff_parse_line_no_space_rejected(void** state);
|
||||
void test_ff_parse_line_unclosed_bracket(void** state);
|
||||
1131
tests/unittests/test_database_stress.c
Normal file
1131
tests/unittests/test_database_stress.c
Normal file
File diff suppressed because it is too large
Load Diff
28
tests/unittests/test_database_stress.h
Normal file
28
tests/unittests/test_database_stress.h
Normal file
@@ -0,0 +1,28 @@
|
||||
/* test_database_stress.h — stress tests for database I/O, MUC, large messages,
|
||||
* export/import roundtrip under load, and index/cache correctness. */
|
||||
|
||||
/* High-throughput write + parse */
|
||||
void test_stress_rapid_write_parse_1000(void** state);
|
||||
void test_stress_rapid_write_parse_10000(void** state);
|
||||
|
||||
/* MUC (chat room) messages */
|
||||
void test_stress_muc_many_participants(void** state);
|
||||
void test_stress_muc_rapid_messages(void** state);
|
||||
|
||||
/* Large messages */
|
||||
void test_stress_large_message_1mb(void** state);
|
||||
void test_stress_large_message_body_with_special_chars(void** state);
|
||||
void test_stress_many_large_messages_100(void** state);
|
||||
|
||||
/* Index and ID cache correctness under load */
|
||||
void test_stress_index_build_10000_lines(void** state);
|
||||
void test_stress_index_extend_append(void** state);
|
||||
void test_stress_id_cache_dedup_correctness(void** state);
|
||||
|
||||
/* Export / import roundtrip under load */
|
||||
void test_stress_export_merge_dedup_1000(void** state);
|
||||
void test_stress_mixed_types_and_encodings(void** state);
|
||||
|
||||
/* LMC correction chains under load */
|
||||
void test_stress_lmc_chain_deep(void** state);
|
||||
void test_stress_lmc_many_corrections(void** state);
|
||||
@@ -1,41 +0,0 @@
|
||||
/*
|
||||
* stub_ai.c - Stubs for AI window functions
|
||||
*/
|
||||
|
||||
#include "ui/window.h"
|
||||
#include "ai/ai_client.h"
|
||||
|
||||
ProfWin*
|
||||
win_create_ai(AISession* session)
|
||||
{
|
||||
/* Return NULL to simulate failure in unit tests */
|
||||
(void)session;
|
||||
return NULL;
|
||||
}
|
||||
|
||||
void
|
||||
aiwin_display_response(ProfAiWin* win, const char* response)
|
||||
{
|
||||
/* Stub: do nothing */
|
||||
(void)win;
|
||||
(void)response;
|
||||
}
|
||||
|
||||
void
|
||||
aiwin_display_error(ProfAiWin* win, const char* error_msg)
|
||||
{
|
||||
/* Stub: do nothing */
|
||||
(void)win;
|
||||
(void)error_msg;
|
||||
}
|
||||
|
||||
void
|
||||
win_print_outgoing(ProfWin* window, const char* show_char, const char* const id, const char* const replace_id, const char* const message)
|
||||
{
|
||||
/* Stub: do nothing */
|
||||
(void)window;
|
||||
(void)show_char;
|
||||
(void)id;
|
||||
(void)replace_id;
|
||||
(void)message;
|
||||
}
|
||||
@@ -36,7 +36,8 @@
|
||||
#include "test_form.h"
|
||||
#include "test_callbacks.h"
|
||||
#include "test_plugins_disco.h"
|
||||
#include "test_ai_client.h"
|
||||
#include "test_database_export.h"
|
||||
#include "test_database_stress.h"
|
||||
|
||||
#define muc_unit_test(f) cmocka_unit_test_setup_teardown(f, muc_before_test, muc_after_test)
|
||||
|
||||
@@ -658,42 +659,73 @@ main(int argc, char* argv[])
|
||||
cmocka_unit_test_setup_teardown(test_cmd_force_encryption_invalid_policy, load_preferences, close_preferences),
|
||||
cmocka_unit_test_setup_teardown(test_allow_unencrypted_message_invalid_mode, load_preferences, close_preferences),
|
||||
|
||||
// AI client tests
|
||||
cmocka_unit_test_setup_teardown(test_ai_client_init, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_add_provider, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_remove_provider, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_list_providers, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_set_provider_key, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_get_provider_key, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_session_create, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_session_ref_unref, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_session_add_message, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_session_clear_history, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_session_set_model, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_session_api_key_is_copied, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_add_provider_update_existing, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_add_provider_null_name_returns_null, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_add_provider_null_url_returns_null, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_session_create_null_provider_returns_null, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_session_create_null_model_returns_null, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_session_api_key_null_when_no_key_set, ai_client_setup, ai_client_teardown),
|
||||
/* Provider autocomplete tests */
|
||||
cmocka_unit_test_setup_teardown(test_ai_providers_find_forward, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_providers_find_forward_perplexity, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_providers_find_forward_custom, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_providers_find_forward_no_match, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_providers_find_forward_partial_match, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_providers_find_next, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_providers_find_previous, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_providers_find_null_search_str, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_providers_find_empty_search_str, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_providers_find_case_insensitive, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test(test_ai_json_escape),
|
||||
cmocka_unit_test(test_ai_json_escape_null),
|
||||
cmocka_unit_test(test_ai_json_escape_empty),
|
||||
cmocka_unit_test(test_ai_json_escape_special_chars),
|
||||
cmocka_unit_test(test_ai_json_escape_percent_signs),
|
||||
cmocka_unit_test(test_ai_json_escape_backslash_quote),
|
||||
// Flatfile export/import round-trip
|
||||
cmocka_unit_test(test_ff_roundtrip_simple_chat),
|
||||
cmocka_unit_test(test_ff_roundtrip_with_all_metadata),
|
||||
cmocka_unit_test(test_ff_roundtrip_with_resource),
|
||||
cmocka_unit_test(test_ff_roundtrip_newline_in_body),
|
||||
cmocka_unit_test(test_ff_roundtrip_pipe_in_stanza_id),
|
||||
cmocka_unit_test(test_ff_roundtrip_backslash_in_body),
|
||||
cmocka_unit_test(test_ff_roundtrip_unicode_body),
|
||||
cmocka_unit_test(test_ff_roundtrip_empty_body),
|
||||
cmocka_unit_test(test_ff_roundtrip_colonspace_in_resource),
|
||||
cmocka_unit_test(test_ff_roundtrip_muc_type),
|
||||
cmocka_unit_test(test_ff_roundtrip_omemo_enc),
|
||||
cmocka_unit_test(test_ff_roundtrip_replace_id),
|
||||
cmocka_unit_test(test_ff_roundtrip_to_jid_and_marked_read),
|
||||
|
||||
// Escape / unescape
|
||||
cmocka_unit_test(test_ff_escape_unescape_message_identity),
|
||||
cmocka_unit_test(test_ff_escape_unescape_meta_identity),
|
||||
cmocka_unit_test(test_ff_escape_meta_null_returns_null),
|
||||
cmocka_unit_test(test_ff_escape_message_null_returns_empty),
|
||||
|
||||
// jid_to_dir
|
||||
cmocka_unit_test(test_ff_jid_to_dir_simple),
|
||||
cmocka_unit_test(test_ff_jid_to_dir_with_at),
|
||||
cmocka_unit_test(test_ff_jid_to_dir_path_traversal_rejected),
|
||||
cmocka_unit_test(test_ff_jid_to_dir_null),
|
||||
|
||||
// Parser edge cases
|
||||
cmocka_unit_test(test_ff_parse_line_empty),
|
||||
cmocka_unit_test(test_ff_parse_line_comment),
|
||||
cmocka_unit_test(test_ff_parse_line_legacy_format),
|
||||
cmocka_unit_test(test_ff_parse_line_no_metadata),
|
||||
cmocka_unit_test(test_ff_parse_line_invalid_timestamp),
|
||||
|
||||
// Type/enc round-trip
|
||||
cmocka_unit_test(test_ff_type_str_roundtrip),
|
||||
cmocka_unit_test(test_ff_enc_str_roundtrip),
|
||||
|
||||
// Additional round-trip
|
||||
cmocka_unit_test(test_ff_roundtrip_bracket_in_stanza_id),
|
||||
cmocka_unit_test(test_ff_roundtrip_backslash_in_resource),
|
||||
cmocka_unit_test(test_ff_roundtrip_mucpm_type),
|
||||
cmocka_unit_test(test_ff_roundtrip_all_enc_types),
|
||||
cmocka_unit_test(test_ff_roundtrip_crlf_handling),
|
||||
cmocka_unit_test(test_ff_roundtrip_to_jid_special_chars),
|
||||
cmocka_unit_test(test_ff_roundtrip_multiple_lines),
|
||||
|
||||
// Additional parser edge cases
|
||||
cmocka_unit_test(test_ff_parsed_line_free_null_safe),
|
||||
cmocka_unit_test(test_ff_parse_line_no_space_rejected),
|
||||
cmocka_unit_test(test_ff_parse_line_unclosed_bracket),
|
||||
|
||||
// Stress tests
|
||||
cmocka_unit_test(test_stress_rapid_write_parse_1000),
|
||||
cmocka_unit_test(test_stress_rapid_write_parse_10000),
|
||||
cmocka_unit_test(test_stress_muc_many_participants),
|
||||
cmocka_unit_test(test_stress_muc_rapid_messages),
|
||||
cmocka_unit_test(test_stress_large_message_1mb),
|
||||
cmocka_unit_test(test_stress_large_message_body_with_special_chars),
|
||||
cmocka_unit_test(test_stress_many_large_messages_100),
|
||||
cmocka_unit_test(test_stress_index_build_10000_lines),
|
||||
cmocka_unit_test(test_stress_index_extend_append),
|
||||
cmocka_unit_test(test_stress_id_cache_dedup_correctness),
|
||||
cmocka_unit_test(test_stress_export_merge_dedup_1000),
|
||||
cmocka_unit_test(test_stress_mixed_types_and_encodings),
|
||||
cmocka_unit_test(test_stress_lmc_chain_deep),
|
||||
cmocka_unit_test(test_stress_lmc_many_corrections),
|
||||
};
|
||||
return cmocka_run_group_tests(all_tests, NULL, NULL);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user