All checks were successful
Build & Push Friends Image to Gitea Registry / build-and-push (push) Successful in 10s
544 lines
17 KiB
JavaScript
544 lines
17 KiB
JavaScript
import dotenv from "dotenv";
|
|
import express from "express";
|
|
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
dotenv.config();
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
|
|
const app = express();
|
|
const port = Number(process.env.PORT || 38110);
|
|
const dataDir = path.resolve(__dirname, process.env.DATA_DIR || "./data");
|
|
const monicaBaseUrl = (process.env.MONICA_BASE_URL || "https://crm.mischlabs.de").replace(/\/$/, "");
|
|
const monicaApiToken = process.env.MONICA_API_TOKEN || "";
|
|
|
|
app.use(express.json({ limit: "1mb" }));
|
|
app.use(express.static(path.join(__dirname, "public")));
|
|
|
|
app.get("/api/health", async (_req, res) => {
|
|
res.json({
|
|
ok: true,
|
|
monicaConfigured: Boolean(monicaApiToken),
|
|
monicaBaseUrl,
|
|
});
|
|
});
|
|
|
|
app.get("/api/contacts", async (req, res) => {
|
|
const query = String(req.query.query || "").trim();
|
|
if (!monicaApiToken) {
|
|
res.json({
|
|
mode: "demo",
|
|
data: demoContacts(query),
|
|
});
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const limit = 150;
|
|
const suffix = query ? `?query=${encodeURIComponent(query)}&limit=${limit}` : `?limit=${limit}`;
|
|
const payload = await monicaFetch(`/api/contacts${suffix}`);
|
|
const contacts = Array.isArray(payload.data) ? payload.data : [];
|
|
res.json({
|
|
mode: "monica",
|
|
data: contacts.map((contact) => ({
|
|
id: contact.id,
|
|
name: contact.complete_name || [contact.first_name, contact.last_name].filter(Boolean).join(" "),
|
|
description: contact.description || "",
|
|
})),
|
|
});
|
|
} catch (error) {
|
|
res.status(502).json({ error: "Monica contacts could not be loaded.", detail: error.message });
|
|
}
|
|
});
|
|
|
|
app.get("/api/contacts/:id", async (req, res) => {
|
|
const id = Number(req.params.id);
|
|
if (!monicaApiToken) {
|
|
res.json(demoContactDetails(id));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const contactPayload = await monicaFetch(`/api/contacts/${id}`);
|
|
const contact = contactPayload.data || {};
|
|
|
|
let emails = [];
|
|
let phones = [];
|
|
try {
|
|
const fieldsPayload = await monicaFetch(`/api/contacts/${id}/contactfields`);
|
|
const fields = Array.isArray(fieldsPayload.data) ? fieldsPayload.data : [];
|
|
emails = fields.filter(f => f.contact_field_type?.name?.toLowerCase().includes("email") || f.contact_field_type_id === 1).map(f => f.data);
|
|
phones = fields.filter(f => f.contact_field_type?.name?.toLowerCase().includes("phone") || f.contact_field_type_id === 2 || f.contact_field_type_id === 3 || f.contact_field_type?.name?.toLowerCase().includes("telefon") || f.contact_field_type?.name?.toLowerCase().includes("mobil")).map(f => f.data);
|
|
} catch (e) {
|
|
console.log("Could not load contact fields:", e.message);
|
|
}
|
|
|
|
let addressStr = "";
|
|
try {
|
|
const addressPayload = await monicaFetch(`/api/contacts/${id}/addresses`);
|
|
const addresses = Array.isArray(addressPayload.data) ? addressPayload.data : [];
|
|
if (addresses.length > 0) {
|
|
const addr = addresses[0];
|
|
addressStr = [addr.street, addr.postal_code, addr.city].filter(Boolean).join(", ");
|
|
}
|
|
} catch (e) {
|
|
console.log("Could not load addresses:", e.message);
|
|
}
|
|
|
|
res.json({
|
|
id: contact.id,
|
|
first_name: contact.first_name || "",
|
|
last_name: contact.last_name || "",
|
|
name: contact.complete_name || [contact.first_name, contact.last_name].filter(Boolean).join(" "),
|
|
description: contact.description || "",
|
|
birthdate: contact.birthdate || "",
|
|
email: emails[0] || "",
|
|
phone: phones[0] || "",
|
|
address: addressStr || "",
|
|
});
|
|
} catch (error) {
|
|
res.status(502).json({ error: "Monica contact details could not be loaded.", detail: error.message });
|
|
}
|
|
});
|
|
|
|
app.post("/api/contacts/create", async (req, res) => {
|
|
const { first_name, last_name, description, email, phone, address } = req.body || {};
|
|
if (!first_name) {
|
|
res.status(400).json({ error: "First name is required." });
|
|
return;
|
|
}
|
|
|
|
if (!monicaApiToken) {
|
|
const newId = Math.floor(Math.random() * 1000) + 10;
|
|
const newContact = {
|
|
id: newId,
|
|
first_name,
|
|
last_name,
|
|
name: [first_name, last_name].filter(Boolean).join(" "),
|
|
description: description || "Neu angelegter Demo-Kontakt",
|
|
email: email || "",
|
|
phone: phone || "",
|
|
address: address || "",
|
|
};
|
|
res.json({ ok: true, mode: "demo", data: newContact });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const contactPayload = await monicaFetch("/api/contacts", {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
first_name,
|
|
last_name: last_name || "",
|
|
description: description || "",
|
|
}),
|
|
});
|
|
const contact = contactPayload.data || {};
|
|
const contactId = contact.id;
|
|
|
|
if (contactId && email) {
|
|
try {
|
|
await monicaFetch("/api/contactfields", {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
contact_id: contactId,
|
|
contact_field_type_id: 1, // Email
|
|
data: email,
|
|
}),
|
|
});
|
|
} catch (e) {
|
|
console.error("Could not save email:", e.message);
|
|
}
|
|
}
|
|
|
|
if (contactId && phone) {
|
|
try {
|
|
await monicaFetch("/api/contactfields", {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
contact_id: contactId,
|
|
contact_field_type_id: 3, // Cell phone
|
|
data: phone,
|
|
}),
|
|
});
|
|
} catch (e) {
|
|
console.error("Could not save phone:", e.message);
|
|
}
|
|
}
|
|
|
|
if (contactId && address) {
|
|
try {
|
|
await monicaFetch("/api/addresses", {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
contact_id: contactId,
|
|
name: "Hauptadresse",
|
|
street: address,
|
|
}),
|
|
});
|
|
} catch (e) {
|
|
console.error("Could not save address:", e.message);
|
|
}
|
|
}
|
|
|
|
res.json({
|
|
ok: true,
|
|
mode: "monica",
|
|
data: {
|
|
id: contactId,
|
|
name: contact.complete_name || [first_name, last_name].filter(Boolean).join(" "),
|
|
},
|
|
});
|
|
} catch (error) {
|
|
res.status(502).json({ error: "Contact could not be created in Monica.", detail: error.message });
|
|
}
|
|
});
|
|
|
|
app.post("/api/analyze", async (req, res) => {
|
|
const text = String(req.body.text || "").trim();
|
|
const mode = String(req.body.mode || "auto");
|
|
const contactsList = req.body.contacts || [];
|
|
|
|
if (!text) {
|
|
res.status(400).json({ error: "Text is required." });
|
|
return;
|
|
}
|
|
|
|
res.json(analyzeText(text, mode, contactsList));
|
|
});
|
|
|
|
app.post("/api/save", async (req, res) => {
|
|
const entry = req.body || {};
|
|
const savedAt = new Date().toISOString();
|
|
const localRecord = {
|
|
id: cryptoRandomId(),
|
|
savedAt,
|
|
entry,
|
|
monicaResults: [],
|
|
};
|
|
|
|
if (monicaApiToken && entry.writeToMonica) {
|
|
localRecord.monicaResults = await writeToMonica(entry);
|
|
}
|
|
|
|
await appendLocalRecord(localRecord);
|
|
res.json(localRecord);
|
|
});
|
|
|
|
async function monicaFetch(apiPath, options = {}) {
|
|
const response = await fetch(`${monicaBaseUrl}${apiPath}`, {
|
|
...options,
|
|
headers: {
|
|
Accept: "application/json",
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${monicaApiToken}`,
|
|
...(options.headers || {}),
|
|
},
|
|
});
|
|
|
|
const text = await response.text();
|
|
const payload = text ? JSON.parse(text) : {};
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`${response.status} ${response.statusText}: ${text.slice(0, 500)}`);
|
|
}
|
|
|
|
return payload;
|
|
}
|
|
|
|
async function writeToMonica(entry) {
|
|
const results = [];
|
|
|
|
if (entry.journal?.enabled && entry.journal?.title && entry.journal?.post) {
|
|
results.push(await monicaWrite("journal", "/api/journal", {
|
|
title: entry.journal.title,
|
|
post: entry.journal.post,
|
|
}));
|
|
}
|
|
|
|
const contactId = Number(entry.contact?.id || 0);
|
|
if (contactId && entry.note?.enabled && entry.note?.body) {
|
|
results.push(await monicaWrite("note", "/api/notes", {
|
|
contact_id: contactId,
|
|
body: entry.note.body,
|
|
is_favorited: Boolean(entry.note.isFavorited),
|
|
}));
|
|
}
|
|
|
|
if (contactId && Array.isArray(entry.reminders)) {
|
|
for (const reminder of entry.reminders.filter((item) => item.enabled && item.initial_date && item.title)) {
|
|
results.push(await monicaWrite("reminder", "/api/reminders", {
|
|
contact_id: contactId,
|
|
initial_date: reminder.initial_date,
|
|
frequency_type: reminder.frequency_type || "one_time",
|
|
frequency_number: Number(reminder.frequency_number || 1),
|
|
title: reminder.title,
|
|
description: reminder.description || "",
|
|
delible: true,
|
|
}));
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
async function monicaWrite(type, apiPath, body) {
|
|
try {
|
|
const payload = await monicaFetch(apiPath, {
|
|
method: "POST",
|
|
body: JSON.stringify(body),
|
|
});
|
|
return { type, ok: true, id: payload.data?.id || null };
|
|
} catch (error) {
|
|
return { type, ok: false, error: error.message };
|
|
}
|
|
}
|
|
|
|
function analyzeText(text, mode = "auto", contactsList = []) {
|
|
const normalized = text.replace(/\s+/g, " ").trim();
|
|
const contacts = mode === "journal" ? [] : inferContacts(normalized, contactsList);
|
|
const reminder = inferReminder(normalized);
|
|
const type = mode === "auto" ? inferType(normalized, contacts, reminder) : mode;
|
|
const summary = summarize(normalized);
|
|
const today = new Date();
|
|
|
|
return {
|
|
rawText: text,
|
|
type,
|
|
summary,
|
|
contacts,
|
|
contact: contacts[0] || null,
|
|
note: {
|
|
enabled: type !== "journal",
|
|
body: buildContactNote(normalized, reminder),
|
|
isFavorited: /wichtig|merken|nicht vergessen|unbedingt/i.test(normalized),
|
|
},
|
|
journal: {
|
|
enabled: type === "journal" || type === "mixed",
|
|
title: inferJournalTitle(normalized, today),
|
|
post: normalized,
|
|
},
|
|
reminders: reminder ? [reminder] : [],
|
|
confidence: contacts.length || reminder ? "medium" : "low",
|
|
};
|
|
}
|
|
|
|
function inferType(text, contacts, reminder) {
|
|
if (/tagebuch|journal|heute war|ich fühle|ich habe mich|gedanken/i.test(text) && (contacts.length || reminder)) {
|
|
return "mixed";
|
|
}
|
|
if (/tagebuch|journal|heute war|ich fühle|ich habe mich|gedanken/i.test(text)) {
|
|
return "journal";
|
|
}
|
|
if (contacts.length || reminder) {
|
|
return "contact";
|
|
}
|
|
return "journal";
|
|
}
|
|
|
|
function inferContacts(text, contactsList = []) {
|
|
const candidates = [];
|
|
|
|
// 1. Scan for echten Kontakten (from DB/Monica)
|
|
if (Array.isArray(contactsList) && contactsList.length > 0) {
|
|
for (const contact of contactsList) {
|
|
if (!contact.name) continue;
|
|
const nameParts = contact.name.trim().split(/\s+/);
|
|
const firstName = nameParts[0];
|
|
const fullName = contact.name.trim();
|
|
|
|
const escFullName = fullName.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
|
|
const escFirstName = firstName.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
|
|
|
|
const regexFull = new RegExp(`\\b${escFullName}\\b`, 'i');
|
|
const regexFirst = new RegExp(`\\b${escFirstName}\\b`, 'i');
|
|
|
|
if (regexFull.test(text) || (firstName.length > 2 && regexFirst.test(text))) {
|
|
if (!candidates.some((item) => item.id === contact.id)) {
|
|
candidates.push({
|
|
id: contact.id,
|
|
name: contact.name,
|
|
description: contact.description || ""
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. Fallback / Ergänzung durch klassische Regexes (wenn keine echten Kontakte gematcht wurden)
|
|
if (candidates.length === 0) {
|
|
const patterns = [
|
|
/(?:mit|bei|von|über|wegen)\s+([A-ZÄÖÜ][a-zäöüß]+(?:\s+[A-ZÄÖÜ][a-zäöüß]+)?)/g,
|
|
/([A-ZÄÖÜ][a-zäöüß]+(?:\s+[A-ZÄÖÜ][a-zäöüß]+)?)\s+(?:getroffen|gesprochen|telefoniert|gesehen)/g,
|
|
/(?:treffe|spreche|telefoniere|schreibe)\s+(?:mit\s+)?([A-ZÄÖÜ][a-zäöüß]+(?:\s+[A-ZÄÖÜ][a-zäöüß]+)?)/g,
|
|
/@([A-Za-zÄÖÜäöüß]+(?:\s+[A-Za-zÄÖÜäöüß]+)?)/g,
|
|
];
|
|
|
|
for (const pattern of patterns) {
|
|
for (const match of text.matchAll(pattern)) {
|
|
const name = cleanupName(match[1]);
|
|
if (name && !candidates.some((item) => item.name.toLowerCase() === name.toLowerCase())) {
|
|
candidates.push({ name });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return candidates.slice(0, 3);
|
|
}
|
|
|
|
function demoContactDetails(id) {
|
|
const details = {
|
|
1: { id: 1, first_name: "Aaron", last_name: "Lingel", name: "Aaron Lingel", description: "Bester Kumpel auf dem Campus", email: "aaron.lingel@example.com", phone: "+49 176 898989", address: "Fischbrötchenallee 42, 22767 Hamburg" },
|
|
2: { id: 2, first_name: "Tom", last_name: "", name: "Tom", description: "Vibecoding-Kollege", email: "tom@example.com", phone: "+49 152 111222", address: "Mischweg 7, 20357 Hamburg" },
|
|
3: { id: 3, first_name: "MrDiderot", last_name: "", name: "MrDiderot", description: "Admin und CRM Host", email: "mrdiderot@mischlabs.de", phone: "+49 170 333444", address: "NAS-Zentrale 1, 20095 Hamburg" },
|
|
};
|
|
return details[id] || { id, first_name: "Unbekannt", last_name: "", name: "Unbekannter Kontakt", description: "", email: "", phone: "", address: "" };
|
|
}
|
|
|
|
function cleanupName(name) {
|
|
const cleaned = name
|
|
.replace(/\b(heute|morgen|gestern|freitag|samstag|sonntag|montag|dienstag|mittwoch|donnerstag)\b/gi, "")
|
|
.trim();
|
|
if (/^(ich|er|sie|wir|frag|frage|denk)$/i.test(cleaned)) return "";
|
|
return cleaned;
|
|
}
|
|
|
|
function inferReminder(text) {
|
|
const date = inferReminderDate(text);
|
|
if (!date) return null;
|
|
|
|
const titleMatch = text.match(/(?:erinnere mich|nachfragen|frag(?:e)?|denk daran|nicht vergessen)[,:]?\s*(.+)$/i);
|
|
const title = titleMatch ? cleanupReminderTitle(titleMatch[1]) : "Nachfassen";
|
|
|
|
return {
|
|
enabled: true,
|
|
title: title.slice(0, 160),
|
|
description: text,
|
|
initial_date: date,
|
|
frequency_type: "one_time",
|
|
frequency_number: 1,
|
|
};
|
|
}
|
|
|
|
function cleanupReminderTitle(title) {
|
|
return title
|
|
.split(/[.!?]/)[0]
|
|
.replace(/^(ihn|sie|es|mich|uns)\s+/i, "")
|
|
.replace(/\bam\s+(montag|dienstag|mittwoch|donnerstag|freitag|samstag|sonntag)\b,?\s*/i, "")
|
|
.replace(/\bin\s+\d{1,3}\s+(tagen?|wochen?)\b,?\s*/i, "")
|
|
.replace(/\bwie\s+es\s+lief\b/i, "fragen, wie es lief")
|
|
.trim()
|
|
.replace(/^./, (char) => char.toUpperCase()) || "Nachfassen";
|
|
}
|
|
|
|
function inferReminderDate(text) {
|
|
const now = new Date();
|
|
const lower = text.toLowerCase();
|
|
|
|
if (lower.includes("morgen")) return toDateString(addDays(now, 1));
|
|
if (lower.includes("übermorgen") || lower.includes("uebermorgen")) return toDateString(addDays(now, 2));
|
|
|
|
const daysMatch = lower.match(/in\s+(\d{1,3})\s+tagen?/);
|
|
if (daysMatch) return toDateString(addDays(now, Number(daysMatch[1])));
|
|
|
|
const weeksMatch = lower.match(/in\s+(\d{1,2})\s+wochen?/);
|
|
if (weeksMatch) return toDateString(addDays(now, Number(weeksMatch[1]) * 7));
|
|
|
|
const weekdays = {
|
|
montag: 1,
|
|
dienstag: 2,
|
|
mittwoch: 3,
|
|
donnerstag: 4,
|
|
freitag: 5,
|
|
samstag: 6,
|
|
sonntag: 0,
|
|
};
|
|
|
|
for (const [name, day] of Object.entries(weekdays)) {
|
|
if (lower.includes(name)) return toDateString(nextWeekday(now, day));
|
|
}
|
|
|
|
const germanDate = lower.match(/\b(\d{1,2})\.(\d{1,2})(?:\.(\d{2,4}))?\b/);
|
|
if (germanDate) {
|
|
const year = germanDate[3] ? normalizeYear(Number(germanDate[3])) : now.getFullYear();
|
|
return toDateString(new Date(year, Number(germanDate[2]) - 1, Number(germanDate[1])));
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function buildContactNote(text, reminder) {
|
|
if (!reminder) return text;
|
|
return `${text}\n\nErkannte Erinnerung: ${reminder.title} am ${reminder.initial_date}`;
|
|
}
|
|
|
|
function inferJournalTitle(text, date) {
|
|
const firstSentence = text.split(/[.!?]/)[0]?.trim();
|
|
if (firstSentence && firstSentence.length <= 80) return firstSentence;
|
|
return `Eintrag vom ${date.toLocaleDateString("de-DE")}`;
|
|
}
|
|
|
|
function summarize(text) {
|
|
if (text.length <= 180) return text;
|
|
return `${text.slice(0, 177).trim()}...`;
|
|
}
|
|
|
|
function addDays(date, days) {
|
|
const next = new Date(date);
|
|
next.setDate(next.getDate() + days);
|
|
return next;
|
|
}
|
|
|
|
function nextWeekday(date, targetDay) {
|
|
const result = new Date(date);
|
|
const delta = (targetDay + 7 - result.getDay()) % 7 || 7;
|
|
result.setDate(result.getDate() + delta);
|
|
return result;
|
|
}
|
|
|
|
function toDateString(date) {
|
|
return date.toISOString().slice(0, 10);
|
|
}
|
|
|
|
function normalizeYear(year) {
|
|
if (year < 100) return 2000 + year;
|
|
return year;
|
|
}
|
|
|
|
function demoContacts(query) {
|
|
const contacts = [
|
|
{ id: 1, name: "Aaron Lingel", description: "Demo-Kontakt" },
|
|
{ id: 2, name: "Tom", description: "Demo-Kontakt" },
|
|
{ id: 3, name: "MrDiderot", description: "Demo-Kontakt" },
|
|
];
|
|
if (!query) return contacts;
|
|
return contacts.filter((contact) => contact.name.toLowerCase().includes(query.toLowerCase()));
|
|
}
|
|
|
|
async function appendLocalRecord(record) {
|
|
await fs.mkdir(dataDir, { recursive: true });
|
|
const file = path.join(dataDir, "entries.json");
|
|
let entries = [];
|
|
|
|
try {
|
|
entries = JSON.parse(await fs.readFile(file, "utf8"));
|
|
} catch {
|
|
entries = [];
|
|
}
|
|
|
|
entries.unshift(record);
|
|
await fs.writeFile(file, JSON.stringify(entries.slice(0, 500), null, 2));
|
|
}
|
|
|
|
function cryptoRandomId() {
|
|
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
}
|
|
|
|
app.listen(port, () => {
|
|
console.log(`Friends listening on http://localhost:${port}`);
|
|
});
|