forked from WHC/Hermit
A prompt can carry files now, and they come in the three ways a desktop user expects: the 📎 button in the composer (QtQuick.Dialogs' FileDialog), files dropped anywhere on the window (a DropArea over it, with an accent border and "drop to attach" while something is dragged), and an image on the clipboard (Ctrl+V). Text keeps the editor's own paste: the composer asks AgentCore::clipboardHasImage() first and only takes the keystroke when there is an image. AgentCore (app/src/core/AgentCore.*): - pending is a property now (QStringList) fed by cst_agent_pending_describe(); attachFile() and attachClipboard() wrap cst_agent_attach_file() / cst_agent_attach_bytes(), clearPending() wraps cst_agent_pending_clear(), and seesImages() reports whether the model is shown images at all. - the engine's description says what the model gets ("png 12.0 KB"), not where it came from, so the window remembers the names it attached and puts them in front: "note.txt — text 21 B". - pendingChanged() fires on attach, on clear and when a turn ends (the prompt consumed the list), which is what makes the chips move. Engine (src/attach.c): - cst_attach_describe() printed every sub-megabyte size as "%.0f KB": a 21-byte text file came out as "text 0 KB". It is "B", "KB" with one decimal or "MB" now, and test_attach still passes. QML: the composer shows one chip per pending file with "clear all"; the hint line mentions 📎/Ctrl+V and says when the model is text-only (images then travel as notes); Main.qml owns the DropArea and the drag feedback. Verified: 44/44 UI checks (8 new: attach ok, one pending, the name is shown, a missing file is refused, one chip in the composer, clear all empties it, the clipboard image is seen and attached), --selftest, make check rc=0 (test_attach included), and screenshots of the chip row. Version 0.8.12.
753 lines
22 KiB
C
753 lines
22 KiB
C
#ifndef _DEFAULT_SOURCE
|
|
#define _DEFAULT_SOURCE
|
|
#endif
|
|
|
|
#include "cJSON.h"
|
|
#include "cst_attach.h"
|
|
#include "cst_pdf.h"
|
|
#include "cst_util.h"
|
|
#include "os_compat.h"
|
|
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
|
|
/* --- base64 -------------------------------------------------------------- */
|
|
|
|
static const char B64[] =
|
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
|
|
char *cst_base64_encode(const void *data, size_t n) {
|
|
const unsigned char *p = data;
|
|
size_t olen = ((n + 2) / 3) * 4;
|
|
char *out = malloc(olen + 1);
|
|
if (!out)
|
|
return NULL;
|
|
size_t o = 0;
|
|
for (size_t i = 0; i < n; i += 3) {
|
|
unsigned v = (unsigned)p[i] << 16;
|
|
if (i + 1 < n)
|
|
v |= (unsigned)p[i + 1] << 8;
|
|
if (i + 2 < n)
|
|
v |= (unsigned)p[i + 2];
|
|
out[o++] = B64[(v >> 18) & 63];
|
|
out[o++] = B64[(v >> 12) & 63];
|
|
out[o++] = i + 1 < n ? B64[(v >> 6) & 63] : '=';
|
|
out[o++] = i + 2 < n ? B64[v & 63] : '=';
|
|
}
|
|
out[o] = 0;
|
|
return out;
|
|
}
|
|
|
|
static int b64_val(unsigned char c) {
|
|
if (c >= 'A' && c <= 'Z')
|
|
return c - 'A';
|
|
if (c >= 'a' && c <= 'z')
|
|
return c - 'a' + 26;
|
|
if (c >= '0' && c <= '9')
|
|
return c - '0' + 52;
|
|
if (c == '+' || c == '-')
|
|
return 62;
|
|
if (c == '/' || c == '_')
|
|
return 63;
|
|
return -1;
|
|
}
|
|
|
|
char *cst_base64_decode(const char *s, size_t n, size_t *out_len) {
|
|
if (out_len)
|
|
*out_len = 0;
|
|
if (!s)
|
|
return NULL;
|
|
char *out = malloc(n / 4 * 3 + 4);
|
|
if (!out)
|
|
return NULL;
|
|
size_t o = 0;
|
|
unsigned acc = 0;
|
|
int bits = 0;
|
|
for (size_t i = 0; i < n; i++) {
|
|
unsigned char c = (unsigned char)s[i];
|
|
if (c == '=')
|
|
break;
|
|
if (c == ' ' || c == '\t' || c == '\r' || c == '\n')
|
|
continue;
|
|
int v = b64_val(c);
|
|
if (v < 0) {
|
|
free(out);
|
|
return NULL;
|
|
}
|
|
acc = (acc << 6) | (unsigned)v;
|
|
bits += 6;
|
|
if (bits >= 8) {
|
|
bits -= 8;
|
|
out[o++] = (char)((acc >> bits) & 0xFF);
|
|
}
|
|
}
|
|
if (out_len)
|
|
*out_len = o;
|
|
return out;
|
|
}
|
|
|
|
/* --- format sniffing ----------------------------------------------------- */
|
|
|
|
static int be32(const unsigned char *p) {
|
|
return (p[0] << 24) | (p[1] << 16) | (p[2] << 8) | p[3];
|
|
}
|
|
|
|
static int le32(const unsigned char *p) {
|
|
return (int)((unsigned)p[0] | ((unsigned)p[1] << 8) |
|
|
((unsigned)p[2] << 16) | ((unsigned)p[3] << 24));
|
|
}
|
|
|
|
static int le16(const unsigned char *p) {
|
|
return (int)((unsigned)p[0] | ((unsigned)p[1] << 8));
|
|
}
|
|
|
|
static const char *mime_of_ext(const char *mime) {
|
|
if (strcmp(mime, "application/pdf") == 0 ||
|
|
strcmp(mime, "text/plain") == 0)
|
|
return "txt"; /* a document is stored as the text it was reduced to */
|
|
if (strcmp(mime, "image/png") == 0)
|
|
return "png";
|
|
if (strcmp(mime, "image/jpeg") == 0)
|
|
return "jpg";
|
|
if (strcmp(mime, "image/gif") == 0)
|
|
return "gif";
|
|
if (strcmp(mime, "image/webp") == 0)
|
|
return "webp";
|
|
if (strcmp(mime, "image/bmp") == 0)
|
|
return "bmp";
|
|
return "bin";
|
|
}
|
|
|
|
/* A JPEG SOF marker carries the frame size; skip it when scanning. */
|
|
static int jpeg_sof(int marker) {
|
|
switch (marker) {
|
|
case 0xC0:
|
|
case 0xC1:
|
|
case 0xC2:
|
|
case 0xC3:
|
|
case 0xC5:
|
|
case 0xC6:
|
|
case 0xC7:
|
|
case 0xC9:
|
|
case 0xCA:
|
|
case 0xCB:
|
|
case 0xCD:
|
|
case 0xCE:
|
|
case 0xCF:
|
|
return 1;
|
|
default:
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
int cst_attach_sniff(const void *data, size_t n, char *mime, size_t mime_sz) {
|
|
const unsigned char *p = data;
|
|
const char *m = NULL;
|
|
if (n >= 8 && memcmp(p, "\x89PNG\r\n\x1a\n", 8) == 0)
|
|
m = "image/png";
|
|
else if (n >= 3 && p[0] == 0xFF && p[1] == 0xD8 && p[2] == 0xFF)
|
|
m = "image/jpeg";
|
|
else if (n >= 6 && memcmp(p, "GIF87a", 6) == 0)
|
|
m = "image/gif";
|
|
else if (n >= 6 && memcmp(p, "GIF89a", 6) == 0)
|
|
m = "image/gif";
|
|
else if (n >= 12 && memcmp(p, "RIFF", 4) == 0 && memcmp(p + 8, "WEBP", 4) == 0)
|
|
m = "image/webp";
|
|
else if (n >= 2 && p[0] == 'B' && p[1] == 'M')
|
|
m = "image/bmp";
|
|
if (!m)
|
|
return -1;
|
|
snprintf(mime, mime_sz, "%s", m);
|
|
return 0;
|
|
}
|
|
|
|
/* PNG / GIF / BMP are direct; JPEG needs a marker walk; WebP has three
|
|
* container flavours. */
|
|
int cst_attach_dimensions(const void *data, size_t n, int *w, int *h) {
|
|
const unsigned char *p = data;
|
|
*w = *h = 0;
|
|
char mime[32];
|
|
if (cst_attach_sniff(data, n, mime, sizeof(mime)) != 0)
|
|
return -1;
|
|
|
|
if (strcmp(mime, "image/png") == 0) {
|
|
/* 8 signature + 4 length + "IHDR" + w + h */
|
|
if (n < 24 || memcmp(p + 12, "IHDR", 4) != 0)
|
|
return -1;
|
|
*w = be32(p + 16);
|
|
*h = be32(p + 20);
|
|
return 0;
|
|
}
|
|
if (strcmp(mime, "image/gif") == 0) {
|
|
if (n < 10)
|
|
return -1;
|
|
*w = le16(p + 6);
|
|
*h = le16(p + 8);
|
|
return 0;
|
|
}
|
|
if (strcmp(mime, "image/bmp") == 0) {
|
|
if (n < 26)
|
|
return -1;
|
|
int wi = le32(p + 18);
|
|
int hi = le32(p + 22);
|
|
if (wi < 0)
|
|
wi = -wi; /* bottom-up rows are stored with a negative height */
|
|
if (hi < 0)
|
|
hi = -hi;
|
|
*w = wi;
|
|
*h = hi;
|
|
return 0;
|
|
}
|
|
if (strcmp(mime, "image/webp") == 0) {
|
|
if (n < 30)
|
|
return -1;
|
|
const unsigned char *c = p + 12;
|
|
if (memcmp(c, "VP8 ", 4) == 0) {
|
|
/* 4 tag + 3 start code, then 16-bit width / height (14 bits each) */
|
|
if (n < 30 || !(c[7] == 0x9D && c[8] == 0x01 && c[9] == 0x2A))
|
|
return -1;
|
|
*w = le16(c + 10) & 0x3FFF;
|
|
*h = le16(c + 12) & 0x3FFF;
|
|
return 0;
|
|
}
|
|
if (memcmp(c, "VP8L", 4) == 0) {
|
|
if (n < 25 || c[8] != 0x2F)
|
|
return -1;
|
|
unsigned b = (unsigned)c[9] | ((unsigned)c[10] << 8) |
|
|
((unsigned)c[11] << 16) | ((unsigned)c[12] << 24);
|
|
*w = (int)(b & 0x3FFF) + 1;
|
|
*h = (int)((b >> 14) & 0x3FFF) + 1;
|
|
return 0;
|
|
}
|
|
if (memcmp(c, "VP8X", 4) == 0) {
|
|
if (n < 30)
|
|
return -1;
|
|
*w = (int)((unsigned)c[12] | ((unsigned)c[13] << 8) |
|
|
((unsigned)c[14] << 16)) + 1;
|
|
*h = (int)((unsigned)c[15] | ((unsigned)c[16] << 8) |
|
|
((unsigned)c[17] << 16)) + 1;
|
|
return 0;
|
|
}
|
|
return -1;
|
|
}
|
|
if (strcmp(mime, "image/jpeg") == 0) {
|
|
size_t i = 2;
|
|
while (i + 3 < n) {
|
|
if (p[i] != 0xFF)
|
|
return -1; /* lost sync */
|
|
unsigned marker = p[i + 1];
|
|
i += 2;
|
|
if (marker == 0xD8 || (marker >= 0xD0 && marker <= 0xD7))
|
|
continue; /* standalone markers: no payload */
|
|
if (marker == 0xD9 || marker == 0x01)
|
|
continue;
|
|
if (i + 1 >= n)
|
|
return -1;
|
|
size_t seg = (size_t)((p[i] << 8) | p[i + 1]);
|
|
if (seg < 2 || i + seg > n)
|
|
return -1;
|
|
if (jpeg_sof((int)marker) && seg >= 7) {
|
|
*h = (p[i + 3] << 8) | p[i + 4];
|
|
*w = (p[i + 5] << 8) | p[i + 6];
|
|
return 0;
|
|
}
|
|
i += seg;
|
|
}
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
/* --- storage ------------------------------------------------------------- */
|
|
|
|
/* <session dir>/attachments/<session stem> — beside the JSONL so moving the
|
|
* workspace keeps the two together. */
|
|
int cst_attach_dir(const char *session_path, char *out, size_t out_sz) {
|
|
if (!session_path || !session_path[0])
|
|
return -1;
|
|
char dir[OS_PATH_MAX];
|
|
snprintf(dir, sizeof(dir), "%s", session_path);
|
|
char *slash = strrchr(dir, '/');
|
|
#ifdef _WIN32
|
|
char *bslash = strrchr(dir, '\\');
|
|
if (bslash && (!slash || bslash > slash))
|
|
slash = bslash;
|
|
#endif
|
|
char leaf[256];
|
|
const char *base = slash ? slash + 1 : dir;
|
|
size_t bl = strlen(base);
|
|
if (bl >= sizeof(leaf))
|
|
bl = sizeof(leaf) - 1;
|
|
memcpy(leaf, base, bl);
|
|
leaf[bl] = 0;
|
|
if (slash)
|
|
*(slash + 1) = 0;
|
|
else {
|
|
dir[0] = '.';
|
|
dir[1] = 0;
|
|
}
|
|
char *dot = strstr(leaf, ".jsonl");
|
|
if (dot)
|
|
*dot = 0;
|
|
char rel[320];
|
|
snprintf(rel, sizeof(rel), "attachments/%s", leaf[0] ? leaf : "session");
|
|
char full[OS_PATH_MAX];
|
|
if (os_path_join(full, sizeof(full), dir, rel) != 0)
|
|
return -1;
|
|
if (os_mkdir_p(full, 0700) != 0)
|
|
return -1;
|
|
snprintf(out, out_sz, "%s", full);
|
|
return 0;
|
|
}
|
|
|
|
/* Stored names are "<id>.<ext>" made by store(); anything else in a JSONL
|
|
* (hand-edited or crafted) could point outside the attachment directory. */
|
|
int cst_attach_rel_ok(const char *rel) {
|
|
if (!rel || !rel[0] || rel[0] == '.')
|
|
return 0;
|
|
for (const char *p = rel; *p; p++) {
|
|
char c = *p;
|
|
if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
|
|
(c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.'))
|
|
return 0;
|
|
}
|
|
return strstr(rel, "..") == NULL;
|
|
}
|
|
|
|
int cst_attach_full_path(const char *session_path, const cst_attachment_t *a,
|
|
char *out, size_t out_sz) {
|
|
if (!a || !cst_attach_rel_ok(a->rel))
|
|
return -1;
|
|
char dir[OS_PATH_MAX];
|
|
if (cst_attach_dir(session_path, dir, sizeof(dir)) != 0)
|
|
return -1;
|
|
return os_path_join(out, out_sz, dir, a->rel);
|
|
}
|
|
|
|
static char *read_whole(const char *path, size_t *out_len, size_t max) {
|
|
FILE *f = fopen(path, "rb");
|
|
if (!f)
|
|
return NULL;
|
|
cst_buf_t b = {0};
|
|
char chunk[16384];
|
|
size_t n;
|
|
while ((n = fread(chunk, 1, sizeof(chunk), f)) > 0) {
|
|
if (b.len + n > max || cst_buf_append(&b, chunk, n) != 0) {
|
|
cst_buf_free(&b);
|
|
fclose(f);
|
|
return NULL;
|
|
}
|
|
}
|
|
fclose(f);
|
|
*out_len = b.len;
|
|
return cst_buf_steal(&b);
|
|
}
|
|
|
|
/* FNV over the bytes, the name and the clock: short, stable, unique enough
|
|
* inside one session. */
|
|
static void make_id(const char *name, const char *data, size_t n, char *out,
|
|
size_t out_sz) {
|
|
uint64_t h = cst_fnv1a64(CST_FNV_INIT, data, n > 4096 ? 4096 : n);
|
|
h = cst_fnv1a64(h, name, strlen(name));
|
|
h = cst_fnv1a64(h, &n, sizeof(n));
|
|
double now = os_now_ms();
|
|
h = cst_fnv1a64(h, &now, sizeof(now));
|
|
h = cst_fnv1a64(h, &(int){os_getpid()}, sizeof(int));
|
|
snprintf(out, out_sz, "img-%08x", (unsigned)(h & 0xFFFFFFFFu));
|
|
}
|
|
|
|
/* n bytes, NUL-terminated: a text payload is handed around as a C string. */
|
|
static char *memdup_z(const char *data, size_t n) {
|
|
char *copy = malloc(n + 1);
|
|
if (!copy)
|
|
return NULL;
|
|
memcpy(copy, data, n);
|
|
copy[n] = 0;
|
|
return copy;
|
|
}
|
|
|
|
/* What a picked file is, plus the payload to store for it: an image stays as
|
|
* it is, a PDF is reduced to its text layer (the mime still says PDF, the
|
|
* stored file holds text), anything else must be UTF-8 text. The caller frees
|
|
* the result; *out_len is its length. */
|
|
static char *prepare(const char *data, size_t n, char *mime, size_t mime_sz,
|
|
int *pages, size_t *out_len, char *err, size_t err_sz) {
|
|
*pages = 0;
|
|
*out_len = n;
|
|
if (cst_attach_sniff(data, n, mime, mime_sz) == 0) {
|
|
if (n > CST_ATTACH_MAX_BYTES) {
|
|
snprintf(err, err_sz, "image is %.1f MB; the limit is %d MB",
|
|
(double)n / (1024 * 1024), (int)(CST_ATTACH_MAX_BYTES >> 20));
|
|
return NULL;
|
|
}
|
|
char *copy = memdup_z(data, n);
|
|
if (!copy)
|
|
snprintf(err, err_sz, "out of memory");
|
|
return copy;
|
|
}
|
|
if (n >= 4 && memcmp(data, "%PDF", 4) == 0) {
|
|
int pg = 0;
|
|
char perr[256];
|
|
char *text = cst_pdf_text(data, n, &pg, perr, sizeof(perr));
|
|
if (!text) {
|
|
snprintf(err, err_sz, "cannot read the PDF: %.200s", perr);
|
|
return NULL;
|
|
}
|
|
snprintf(mime, mime_sz, "application/pdf");
|
|
*pages = pg;
|
|
*out_len = strlen(text);
|
|
return text;
|
|
}
|
|
if (n > CST_ATTACH_TEXT_MAX) {
|
|
snprintf(err, err_sz, "text is %.1f MB; the limit is %d MB",
|
|
(double)n / (1024 * 1024), (int)(CST_ATTACH_TEXT_MAX >> 20));
|
|
return NULL;
|
|
}
|
|
if (memchr(data, 0, n) || !cst_utf8_valid(data, n)) {
|
|
snprintf(err, err_sz,
|
|
"not an image (PNG, JPEG, GIF, WebP or BMP), a PDF or UTF-8 "
|
|
"text; the content decides, not the extension");
|
|
return NULL;
|
|
}
|
|
snprintf(mime, mime_sz, "text/plain");
|
|
char *copy = memdup_z(data, n);
|
|
if (!copy)
|
|
snprintf(err, err_sz, "out of memory");
|
|
return copy;
|
|
}
|
|
|
|
/* Write one payload whose kind is already known. */
|
|
static cst_attachment_t *store(const char *session_path, const char *name,
|
|
const char *data, size_t n, const char *mime,
|
|
int pages, const char *origin, char *err,
|
|
size_t err_sz) {
|
|
if (!name || !name[0])
|
|
name = "image";
|
|
char dir[OS_PATH_MAX];
|
|
if (cst_attach_dir(session_path, dir, sizeof(dir)) != 0) {
|
|
snprintf(err, err_sz, "cannot create the attachment directory");
|
|
return NULL;
|
|
}
|
|
cst_attachment_t *a = calloc(1, sizeof(*a));
|
|
if (!a) {
|
|
snprintf(err, err_sz, "out of memory");
|
|
return NULL;
|
|
}
|
|
make_id(name, data, n, a->id, sizeof(a->id));
|
|
snprintf(a->rel, sizeof(a->rel), "%s.%s", a->id, mime_of_ext(mime));
|
|
snprintf(a->name, sizeof(a->name), "%s", name && name[0] ? name : "image");
|
|
snprintf(a->mime, sizeof(a->mime), "%s", mime);
|
|
snprintf(a->origin, sizeof(a->origin), "%s", origin ? origin : "user");
|
|
snprintf(a->detail, sizeof(a->detail), "auto");
|
|
a->bytes = (long long)n;
|
|
a->pages = pages;
|
|
if (cst_attach_is_image(a))
|
|
cst_attach_dimensions(data, n, &a->width, &a->height);
|
|
|
|
char full[OS_PATH_MAX];
|
|
if (cst_attach_full_path(session_path, a, full, sizeof(full)) != 0) {
|
|
snprintf(err, err_sz, "attachment path is too long");
|
|
free(a);
|
|
return NULL;
|
|
}
|
|
FILE *f = fopen(full, "wb");
|
|
if (!f) {
|
|
snprintf(err, err_sz, "cannot write %.200s", full);
|
|
free(a);
|
|
return NULL;
|
|
}
|
|
size_t w = fwrite(data, 1, n, f);
|
|
if (fclose(f) != 0 || w != n) {
|
|
snprintf(err, err_sz, "cannot write %.200s", full);
|
|
free(a);
|
|
return NULL;
|
|
}
|
|
return a;
|
|
}
|
|
|
|
cst_attachment_t *cst_attach_import_bytes(const char *session_path,
|
|
const char *name, const char *data,
|
|
size_t n, char *err, size_t err_sz) {
|
|
char dummy[8];
|
|
if (!err || !err_sz) {
|
|
err = dummy;
|
|
err_sz = sizeof(dummy);
|
|
}
|
|
err[0] = 0;
|
|
if (!session_path || !session_path[0]) {
|
|
snprintf(err, err_sz, "no session to store the file in");
|
|
return NULL;
|
|
}
|
|
if (!data || !n) {
|
|
snprintf(err, err_sz, "the file is empty");
|
|
return NULL;
|
|
}
|
|
char mime[32];
|
|
if (cst_attach_sniff(data, n, mime, sizeof(mime)) != 0) {
|
|
snprintf(err, err_sz,
|
|
"not a supported image (PNG, JPEG, GIF, WebP or BMP; "
|
|
"checked by content, not by extension)");
|
|
return NULL;
|
|
}
|
|
if (n > CST_ATTACH_MAX_BYTES) {
|
|
snprintf(err, err_sz, "image is %.1f MB; the limit is %d MB",
|
|
(double)n / (1024 * 1024), (int)(CST_ATTACH_MAX_BYTES >> 20));
|
|
return NULL;
|
|
}
|
|
return store(session_path, name, data, n, mime, 0, "user", err, err_sz);
|
|
}
|
|
|
|
cst_attachment_t *cst_attach_import_file(const char *session_path,
|
|
const char *src_path, char *err,
|
|
size_t err_sz) {
|
|
char dummy[8];
|
|
if (!err || !err_sz) {
|
|
err = dummy;
|
|
err_sz = sizeof(dummy);
|
|
}
|
|
err[0] = 0;
|
|
size_t n = 0;
|
|
char *data = read_whole(src_path, &n, CST_ATTACH_MAX_BYTES);
|
|
if (!data) {
|
|
snprintf(err, err_sz, "cannot read %.300s (or it is larger than %d MB)",
|
|
src_path, (int)(CST_ATTACH_MAX_BYTES >> 20));
|
|
return NULL;
|
|
}
|
|
const char *leaf = src_path;
|
|
for (const char *p = src_path; *p; p++)
|
|
if (*p == '/' || *p == '\\')
|
|
leaf = p + 1;
|
|
char mime[32];
|
|
int pages = 0;
|
|
size_t pn = 0;
|
|
char *payload =
|
|
prepare(data, n, mime, sizeof(mime), &pages, &pn, err, err_sz);
|
|
free(data);
|
|
if (!payload)
|
|
return NULL;
|
|
cst_attachment_t *a =
|
|
store(session_path, leaf, payload, pn, mime, pages, "user", err, err_sz);
|
|
free(payload);
|
|
return a;
|
|
}
|
|
|
|
/* Images are opaque and travel as image parts; everything else is text the
|
|
* store holds and the request inlines. */
|
|
int cst_attach_is_image(const cst_attachment_t *a) {
|
|
return a && strncmp(a->mime, "image/", 6) == 0;
|
|
}
|
|
|
|
int cst_attach_is_text(const cst_attachment_t *a) {
|
|
return a && !cst_attach_is_image(a);
|
|
}
|
|
|
|
char *cst_attach_read(const char *session_path, const cst_attachment_t *a,
|
|
size_t *out_len, char *err, size_t err_sz) {
|
|
char dummy[8];
|
|
if (!err || !err_sz) {
|
|
err = dummy;
|
|
err_sz = sizeof(dummy);
|
|
}
|
|
err[0] = 0;
|
|
char full[OS_PATH_MAX];
|
|
if (cst_attach_full_path(session_path, a, full, sizeof(full)) != 0) {
|
|
snprintf(err, err_sz, "attachment has no stored file");
|
|
return NULL;
|
|
}
|
|
char *data = read_whole(full, out_len, CST_ATTACH_MAX_BYTES);
|
|
if (!data) {
|
|
snprintf(err, err_sz,
|
|
"%.200s is missing; it was deleted from the session store",
|
|
a->name);
|
|
return NULL;
|
|
}
|
|
if (!cst_attach_is_image(a)) /* a document is text, nothing to verify */
|
|
return data;
|
|
char mime[32];
|
|
if (cst_attach_sniff(data, *out_len, mime, sizeof(mime)) != 0) {
|
|
snprintf(err, err_sz, "%.200s in the session store is no longer an image",
|
|
a->name);
|
|
free(data);
|
|
return NULL;
|
|
}
|
|
return data;
|
|
}
|
|
|
|
char *cst_attach_data_url(const char *session_path, const cst_attachment_t *a) {
|
|
if (!cst_attach_is_image(a)) /* a document travels as text, not as a URL */
|
|
return NULL;
|
|
char err[256];
|
|
size_t n = 0;
|
|
char *data = cst_attach_read(session_path, a, &n, err, sizeof(err));
|
|
if (!data)
|
|
return NULL;
|
|
char mime[32];
|
|
cst_attach_sniff(data, n, mime, sizeof(mime)); /* the bytes decide */
|
|
char *b64 = cst_base64_encode(data, n);
|
|
free(data);
|
|
if (!b64)
|
|
return NULL;
|
|
cst_buf_t b = {0};
|
|
cst_buf_printf(&b, "data:%s;base64,", mime);
|
|
cst_buf_puts(&b, b64);
|
|
free(b64);
|
|
return cst_buf_steal(&b);
|
|
}
|
|
|
|
void cst_attach_describe(const cst_attachment_t *a, char *out, size_t out_sz) {
|
|
if (!a) {
|
|
snprintf(out, out_sz, "(none)");
|
|
return;
|
|
}
|
|
const char *kind = a->mime;
|
|
char dim[32] = "";
|
|
if (cst_attach_is_image(a)) {
|
|
if (strncmp(a->mime, "image/", 6) == 0)
|
|
kind = a->mime + 6;
|
|
if (a->width > 0 && a->height > 0)
|
|
snprintf(dim, sizeof(dim), "%dx%d ", a->width, a->height);
|
|
} else if (strcmp(a->mime, "application/pdf") == 0) {
|
|
kind = "pdf";
|
|
if (a->pages > 0)
|
|
snprintf(dim, sizeof(dim), "%d page%s ", a->pages,
|
|
a->pages == 1 ? "" : "s");
|
|
} else if (strcmp(a->mime, "text/plain") == 0 || !a->mime[0]) {
|
|
kind = "text";
|
|
}
|
|
out[0] = 0;
|
|
if (a->bytes >= 1024 * 1024)
|
|
snprintf(out, out_sz, "%s%s %.1f MB", dim, kind,
|
|
(double)a->bytes / (1024 * 1024));
|
|
else if (a->bytes >= 1024)
|
|
snprintf(out, out_sz, "%s%s %.1f KB", dim, kind, (double)a->bytes / 1024);
|
|
else
|
|
snprintf(out, out_sz, "%s%s %lld B", dim, kind, a->bytes);
|
|
}
|
|
|
|
/* --- lifetime ------------------------------------------------------------ */
|
|
|
|
void cst_attach_free(cst_attachment_t *a) { free(a); }
|
|
|
|
/* Entries always live in one array (a 1-element array for a single import),
|
|
* so freeing the block releases them all. */
|
|
void cst_attachments_free(cst_attachment_t *list, int n) {
|
|
(void)n;
|
|
free(list);
|
|
}
|
|
|
|
cst_attachment_t *cst_attachments_dup(const cst_attachment_t *list, int n) {
|
|
if (!list || n <= 0)
|
|
return NULL;
|
|
cst_attachment_t *out = calloc((size_t)n, sizeof(*out));
|
|
if (!out)
|
|
return NULL;
|
|
for (int i = 0; i < n; i++)
|
|
out[i] = list[i];
|
|
return out;
|
|
}
|
|
|
|
int cst_attachments_push(cst_attachment_t **list, int *n, int *cap,
|
|
const cst_attachment_t *src) {
|
|
if (!list || !n || !cap || !src)
|
|
return -1;
|
|
if (*n == *cap) {
|
|
int nc = *cap ? *cap * 2 : 4;
|
|
cst_attachment_t *nv = realloc(*list, (size_t)nc * sizeof(**list));
|
|
if (!nv)
|
|
return -1;
|
|
*list = nv;
|
|
*cap = nc;
|
|
}
|
|
(*list)[(*n)++] = *src;
|
|
return 0;
|
|
}
|
|
|
|
/* --- JSONL --------------------------------------------------------------- */
|
|
|
|
char *cst_attachments_to_json(const cst_attachment_t *list, int n) {
|
|
if (!list || n <= 0)
|
|
return NULL;
|
|
cJSON *arr = cJSON_CreateArray();
|
|
if (!arr)
|
|
return NULL;
|
|
for (int i = 0; i < n; i++) {
|
|
const cst_attachment_t *a = &list[i];
|
|
cJSON *o = cJSON_CreateObject();
|
|
cJSON_AddStringToObject(o, "id", a->id);
|
|
cJSON_AddStringToObject(o, "name", a->name);
|
|
cJSON_AddStringToObject(o, "mime", a->mime);
|
|
cJSON_AddStringToObject(o, "rel", a->rel);
|
|
cJSON_AddNumberToObject(o, "bytes", (double)a->bytes);
|
|
if (a->width > 0 && a->height > 0) {
|
|
cJSON_AddNumberToObject(o, "width", a->width);
|
|
cJSON_AddNumberToObject(o, "height", a->height);
|
|
}
|
|
if (a->pages > 0)
|
|
cJSON_AddNumberToObject(o, "pages", a->pages);
|
|
if (a->detail[0] && strcmp(a->detail, "auto") != 0)
|
|
cJSON_AddStringToObject(o, "detail", a->detail);
|
|
if (a->origin[0])
|
|
cJSON_AddStringToObject(o, "origin", a->origin);
|
|
cJSON_AddItemToArray(arr, o);
|
|
}
|
|
char *s = cJSON_PrintUnformatted(arr);
|
|
cJSON_Delete(arr);
|
|
return s;
|
|
}
|
|
|
|
cst_attachment_t *cst_attachments_from_json(const void *array_json, int *n_out) {
|
|
if (n_out)
|
|
*n_out = 0;
|
|
const cJSON *arr = array_json;
|
|
if (!cJSON_IsArray(arr))
|
|
return NULL;
|
|
int n = cJSON_GetArraySize(arr);
|
|
if (n <= 0)
|
|
return NULL;
|
|
cst_attachment_t *list = calloc((size_t)n, sizeof(*list));
|
|
if (!list)
|
|
return NULL;
|
|
int k = 0;
|
|
const cJSON *it = NULL;
|
|
cJSON_ArrayForEach(it, arr) {
|
|
if (!cJSON_IsObject(it))
|
|
continue;
|
|
const cJSON *jid = cJSON_GetObjectItemCaseSensitive(it, "id");
|
|
const cJSON *jrel = cJSON_GetObjectItemCaseSensitive(it, "rel");
|
|
if (!cJSON_IsString(jid) || !cJSON_IsString(jrel) ||
|
|
!cst_attach_rel_ok(jrel->valuestring))
|
|
continue;
|
|
cst_attachment_t *a = &list[k];
|
|
snprintf(a->id, sizeof(a->id), "%s", jid->valuestring);
|
|
snprintf(a->rel, sizeof(a->rel), "%s", jrel->valuestring);
|
|
const cJSON *v = cJSON_GetObjectItemCaseSensitive(it, "name");
|
|
snprintf(a->name, sizeof(a->name), "%s",
|
|
cJSON_IsString(v) ? v->valuestring : a->id);
|
|
v = cJSON_GetObjectItemCaseSensitive(it, "mime");
|
|
snprintf(a->mime, sizeof(a->mime), "%s",
|
|
cJSON_IsString(v) ? v->valuestring : "image/*");
|
|
v = cJSON_GetObjectItemCaseSensitive(it, "bytes");
|
|
a->bytes = cJSON_IsNumber(v) ? (long long)v->valuedouble : 0;
|
|
v = cJSON_GetObjectItemCaseSensitive(it, "width");
|
|
a->width = cJSON_IsNumber(v) ? v->valueint : 0;
|
|
v = cJSON_GetObjectItemCaseSensitive(it, "height");
|
|
a->height = cJSON_IsNumber(v) ? v->valueint : 0;
|
|
v = cJSON_GetObjectItemCaseSensitive(it, "pages");
|
|
a->pages = cJSON_IsNumber(v) ? v->valueint : 0;
|
|
v = cJSON_GetObjectItemCaseSensitive(it, "detail");
|
|
snprintf(a->detail, sizeof(a->detail), "%s",
|
|
cJSON_IsString(v) ? v->valuestring : "auto");
|
|
v = cJSON_GetObjectItemCaseSensitive(it, "origin");
|
|
snprintf(a->origin, sizeof(a->origin), "%s",
|
|
cJSON_IsString(v) ? v->valuestring : "");
|
|
k++;
|
|
}
|
|
if (k == 0) {
|
|
free(list);
|
|
return NULL;
|
|
}
|
|
if (n_out)
|
|
*n_out = k;
|
|
return list;
|
|
}
|