daily bookmarks

This commit is contained in:
2025-12-02 21:21:08 +01:00
parent 3ff25d3f7e
commit 839bd24309
10 changed files with 3303 additions and 49 deletions

View File

@@ -26,6 +26,10 @@ const MAX_POST_TEXT_LENGTH = 4000;
const MIN_TEXT_HASH_LENGTH = 120;
const MAX_BOOKMARK_LABEL_LENGTH = 120;
const MAX_BOOKMARK_QUERY_LENGTH = 200;
const DAILY_BOOKMARK_TITLE_MAX_LENGTH = 160;
const DAILY_BOOKMARK_URL_MAX_LENGTH = 800;
const DAILY_BOOKMARK_NOTES_MAX_LENGTH = 800;
const DAILY_BOOKMARK_MARKER_MAX_LENGTH = 120;
const SPORTS_SCORING_DEFAULTS = {
enabled: 1,
threshold: 5,
@@ -485,6 +489,222 @@ function serializeBookmark(row) {
};
}
function formatDayKeyFromDate(date = new Date()) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function normalizeDayKeyValue(rawValue) {
if (!rawValue && rawValue !== 0) {
return null;
}
if (rawValue instanceof Date) {
if (!Number.isNaN(rawValue.getTime())) {
return formatDayKeyFromDate(rawValue);
}
return null;
}
if (typeof rawValue !== 'string' && typeof rawValue !== 'number') {
return null;
}
const stringValue = String(rawValue).trim();
if (!stringValue) {
return null;
}
const match = stringValue.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
if (match) {
const year = parseInt(match[1], 10);
const month = parseInt(match[2], 10);
const day = parseInt(match[3], 10);
const parsed = new Date(year, month - 1, day);
if (
!Number.isNaN(parsed.getTime())
&& parsed.getFullYear() === year
&& parsed.getMonth() === month - 1
&& parsed.getDate() === day
) {
return formatDayKeyFromDate(parsed);
}
}
const parsed = new Date(stringValue);
if (!Number.isNaN(parsed.getTime())) {
return formatDayKeyFromDate(parsed);
}
return null;
}
function resolveDayKey(dayKey, { defaultToToday = true } = {}) {
const normalized = normalizeDayKeyValue(dayKey);
if (normalized) {
return normalized;
}
return defaultToToday ? formatDayKeyFromDate() : null;
}
function dayKeyToDate(dayKey) {
const normalized = resolveDayKey(dayKey);
const match = normalized.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match) {
return new Date();
}
const year = parseInt(match[1], 10);
const month = parseInt(match[2], 10);
const day = parseInt(match[3], 10);
return new Date(year, month - 1, day);
}
function addDays(date, offsetDays = 0) {
const base = date instanceof Date ? date : new Date();
const result = new Date(base);
result.setDate(result.getDate() + offsetDays);
return result;
}
const DAILY_PLACEHOLDER_PATTERN = /\{\{\s*([^}]+)\s*\}\}/gi;
function resolveDynamicUrlTemplate(template, dayKey) {
if (typeof template !== 'string') {
return '';
}
const baseDayKey = resolveDayKey(dayKey);
const baseDate = dayKeyToDate(baseDayKey);
return template.replace(DAILY_PLACEHOLDER_PATTERN, (_match, contentRaw) => {
const content = (contentRaw || '').trim();
if (!content) {
return '';
}
const counterMatch = content.match(/^counter:\s*([+-]?\d+)([+-]\d+)?$/i);
if (counterMatch) {
const base = parseInt(counterMatch[1], 10);
const offset = counterMatch[2] ? parseInt(counterMatch[2], 10) || 0 : 0;
if (Number.isNaN(base)) {
return '';
}
const date = addDays(baseDate, offset);
return String(base + date.getDate());
}
const placeholderMatch = content.match(/^(date|day|dd|mm|month|yyyy|yy)([+-]\d+)?$/i);
if (!placeholderMatch) {
return content;
}
const token = String(placeholderMatch[1] || '').toLowerCase();
const offset = placeholderMatch[2] ? parseInt(placeholderMatch[2], 10) || 0 : 0;
const date = addDays(baseDate, offset);
switch (token) {
case 'date':
return formatDayKeyFromDate(date);
case 'day':
return String(date.getDate());
case 'dd':
return String(date.getDate()).padStart(2, '0');
case 'month':
case 'mm':
return String(date.getMonth() + 1).padStart(2, '0');
case 'yyyy':
return String(date.getFullYear());
case 'yy':
return String(date.getFullYear()).slice(-2);
default:
return token;
}
});
}
function normalizeDailyBookmarkTitle(value, fallback = '') {
const source = typeof value === 'string' ? value : fallback;
let title = (source || '').trim();
if (!title && fallback) {
title = String(fallback || '').trim();
}
if (!title) {
title = 'Bookmark';
}
title = title.replace(/\s+/g, ' ');
if (title.length > DAILY_BOOKMARK_TITLE_MAX_LENGTH) {
title = title.slice(0, DAILY_BOOKMARK_TITLE_MAX_LENGTH);
}
return title;
}
function normalizeDailyBookmarkUrlTemplate(value) {
if (typeof value !== 'string') {
return null;
}
let template = value.trim();
if (!template) {
return null;
}
if (template.length > DAILY_BOOKMARK_URL_MAX_LENGTH) {
template = template.slice(0, DAILY_BOOKMARK_URL_MAX_LENGTH);
}
return template;
}
function normalizeDailyBookmarkNotes(value) {
if (typeof value !== 'string') {
return '';
}
let notes = value.trim();
if (notes.length > DAILY_BOOKMARK_NOTES_MAX_LENGTH) {
notes = notes.slice(0, DAILY_BOOKMARK_NOTES_MAX_LENGTH);
}
return notes;
}
function normalizeDailyBookmarkMarker(value) {
if (typeof value !== 'string') {
return '';
}
let marker = value.trim();
marker = marker.replace(/\s+/g, ' ');
if (marker.length > DAILY_BOOKMARK_MARKER_MAX_LENGTH) {
marker = marker.slice(0, DAILY_BOOKMARK_MARKER_MAX_LENGTH);
}
return marker;
}
function serializeDailyBookmark(row, dayKey) {
if (!row) {
return null;
}
const resolvedDayKey = resolveDayKey(dayKey);
const resolvedUrl = resolveDynamicUrlTemplate(row.url_template, resolvedDayKey);
return {
id: row.id,
title: row.title,
url_template: row.url_template,
marker: row.marker || '',
resolved_url: resolvedUrl,
notes: row.notes || '',
created_at: sqliteTimestampToUTC(row.created_at),
updated_at: sqliteTimestampToUTC(row.updated_at),
last_completed_at: sqliteTimestampToUTC(row.last_completed_at),
completed_for_day: !!row.completed_for_day,
day_key: resolvedDayKey
};
}
function normalizeFacebookPostUrl(rawValue) {
if (typeof rawValue !== 'string') {
return null;
@@ -927,6 +1147,136 @@ const updateBookmarkLastClickedStmt = db.prepare(`
WHERE id = ?
`);
db.exec(`
CREATE TABLE IF NOT EXISTS daily_bookmarks (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
url_template TEXT NOT NULL,
notes TEXT,
marker TEXT DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
`);
db.exec(`
CREATE TABLE IF NOT EXISTS daily_bookmark_checks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
bookmark_id TEXT NOT NULL,
day_key TEXT NOT NULL,
completed_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (bookmark_id) REFERENCES daily_bookmarks(id) ON DELETE CASCADE,
UNIQUE(bookmark_id, day_key)
);
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_daily_bookmark_checks_day
ON daily_bookmark_checks(day_key);
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_daily_bookmarks_updated
ON daily_bookmarks(updated_at);
`);
ensureColumn('daily_bookmarks', 'marker', 'marker TEXT DEFAULT \'\'');
const listDailyBookmarksStmt = db.prepare(`
SELECT
b.id,
b.title,
b.url_template,
b.notes,
b.marker,
b.created_at,
b.updated_at,
(
SELECT MAX(completed_at)
FROM daily_bookmark_checks c
WHERE c.bookmark_id = b.id
) AS last_completed_at,
EXISTS(
SELECT 1
FROM daily_bookmark_checks c
WHERE c.bookmark_id = b.id
AND c.day_key = @dayKey
) AS completed_for_day
FROM daily_bookmarks b
ORDER BY datetime(b.updated_at) DESC, datetime(b.created_at) DESC, b.title COLLATE NOCASE
`);
const getDailyBookmarkStmt = db.prepare(`
SELECT
b.id,
b.title,
b.url_template,
b.notes,
b.marker,
b.created_at,
b.updated_at,
(
SELECT MAX(completed_at)
FROM daily_bookmark_checks c
WHERE c.bookmark_id = b.id
) AS last_completed_at,
EXISTS(
SELECT 1
FROM daily_bookmark_checks c
WHERE c.bookmark_id = b.id
AND c.day_key = @dayKey
) AS completed_for_day
FROM daily_bookmarks b
WHERE b.id = @bookmarkId
`);
const insertDailyBookmarkStmt = db.prepare(`
INSERT INTO daily_bookmarks (id, title, url_template, notes, marker)
VALUES (@id, @title, @url_template, @notes, @marker)
`);
const findDailyBookmarkByUrlStmt = db.prepare(`
SELECT id
FROM daily_bookmarks
WHERE LOWER(url_template) = LOWER(?)
LIMIT 1
`);
const findOtherDailyBookmarkByUrlStmt = db.prepare(`
SELECT id
FROM daily_bookmarks
WHERE LOWER(url_template) = LOWER(@url)
AND id <> @id
LIMIT 1
`);
const updateDailyBookmarkStmt = db.prepare(`
UPDATE daily_bookmarks
SET title = @title,
url_template = @url_template,
notes = @notes,
marker = @marker,
updated_at = CURRENT_TIMESTAMP
WHERE id = @id
`);
const deleteDailyBookmarkStmt = db.prepare(`
DELETE FROM daily_bookmarks
WHERE id = ?
`);
const upsertDailyBookmarkCheckStmt = db.prepare(`
INSERT INTO daily_bookmark_checks (bookmark_id, day_key)
VALUES (@bookmarkId, @dayKey)
ON CONFLICT(bookmark_id, day_key) DO NOTHING
`);
const deleteDailyBookmarkCheckStmt = db.prepare(`
DELETE FROM daily_bookmark_checks
WHERE bookmark_id = @bookmarkId
AND day_key = @dayKey
`);
ensureColumn('posts', 'checked_count', 'checked_count INTEGER DEFAULT 0');
ensureColumn('posts', 'screenshot_path', 'screenshot_path TEXT');
ensureColumn('posts', 'created_by_profile', 'created_by_profile INTEGER');
@@ -1936,8 +2286,13 @@ const selectAlternateUrlsForPostStmt = db.prepare(`
ORDER BY created_at ASC
`);
const selectPostByTextHashStmt = db.prepare('SELECT * FROM posts WHERE post_text_hash = ?');
const selectChecksForPostStmt = db.prepare('SELECT id, profile_number, checked_at FROM checks WHERE post_id = ?');
const updateCheckPostStmt = db.prepare('UPDATE checks SET post_id = ? WHERE id = ?');
const updateCheckTimestampStmt = db.prepare('UPDATE checks SET checked_at = ? WHERE id = ?');
const deleteCheckByIdStmt = db.prepare('DELETE FROM checks WHERE id = ?');
function storePostUrls(postId, primaryUrl, additionalUrls = []) {
function storePostUrls(postId, primaryUrl, additionalUrls = [], options = {}) {
const { skipContentKeyCheck = false } = options;
if (!postId || !primaryUrl) {
return;
}
@@ -1956,16 +2311,18 @@ function storePostUrls(postId, primaryUrl, additionalUrls = []) {
continue;
}
const candidateKey = extractFacebookContentKey(normalized);
if (!candidateKey || candidateKey !== primaryKey) {
continue;
if (!skipContentKeyCheck) {
const candidateKey = extractFacebookContentKey(normalized);
if (!candidateKey || candidateKey !== primaryKey) {
continue;
}
}
const existingPostId = findPostIdByUrl(normalized);
if (existingPostId && existingPostId !== postId) {
continue;
}
insertPostUrlStmt.run(postId, normalized, 0);
insertPostUrlStmt.run(postId, normalized);
}
}
}
@@ -2289,6 +2646,276 @@ app.delete('/api/bookmarks/:bookmarkId', (req, res) => {
}
});
app.get('/api/daily-bookmarks', (req, res) => {
const dayKey = resolveDayKey(req.query && req.query.day);
try {
const rows = listDailyBookmarksStmt.all({ dayKey });
res.json(rows.map((row) => serializeDailyBookmark(row, dayKey)));
} catch (error) {
console.error('Failed to load daily bookmarks:', error);
res.status(500).json({ error: 'Daily Bookmarks konnten nicht geladen werden' });
}
});
app.post('/api/daily-bookmarks', (req, res) => {
const payload = req.body || {};
const rawDay = (payload && payload.day) || (req.query && req.query.day);
const dayKey = rawDay ? normalizeDayKeyValue(rawDay) : resolveDayKey(rawDay);
if (!dayKey) {
return res.status(400).json({ error: 'Ungültiges Tagesformat' });
}
const normalizedUrl = normalizeDailyBookmarkUrlTemplate(payload.url_template || payload.url);
const normalizedTitle = normalizeDailyBookmarkTitle(payload.title || payload.label, normalizedUrl);
const normalizedNotes = normalizeDailyBookmarkNotes(payload.notes);
const normalizedMarker = normalizeDailyBookmarkMarker(payload.marker || payload.tag);
if (!normalizedUrl) {
return res.status(400).json({ error: 'URL-Template ist erforderlich' });
}
if (findDailyBookmarkByUrlStmt.get(normalizedUrl)) {
return res.status(409).json({ error: 'Bookmark mit dieser URL existiert bereits' });
}
try {
const id = uuidv4();
insertDailyBookmarkStmt.run({
id,
title: normalizedTitle,
url_template: normalizedUrl,
notes: normalizedNotes,
marker: normalizedMarker
});
upsertDailyBookmarkCheckStmt.run({ bookmarkId: id, dayKey });
const saved = getDailyBookmarkStmt.get({ bookmarkId: id, dayKey });
res.status(201).json(serializeDailyBookmark(saved, dayKey));
} catch (error) {
console.error('Failed to create daily bookmark:', error);
res.status(500).json({ error: 'Daily Bookmark konnte nicht erstellt werden' });
}
});
app.post('/api/daily-bookmarks/import', (req, res) => {
const payload = req.body || {};
const rawDay = (payload && payload.day) || (req.query && req.query.day);
const dayKey = rawDay ? normalizeDayKeyValue(rawDay) : resolveDayKey(rawDay);
if (!dayKey) {
return res.status(400).json({ error: 'Ungültiges Tagesformat' });
}
const normalizedMarker = normalizeDailyBookmarkMarker(payload.marker || payload.tag);
const collected = [];
const addValue = (value) => {
if (typeof value !== 'string') {
return;
}
const trimmed = value.trim();
if (trimmed) {
collected.push(trimmed);
}
};
if (Array.isArray(payload.urls)) {
payload.urls.forEach(addValue);
}
if (Array.isArray(payload.url_templates)) {
payload.url_templates.forEach(addValue);
}
if (typeof payload.text === 'string') {
payload.text.split(/\r?\n|,/).forEach(addValue);
}
if (typeof payload.urls_text === 'string') {
payload.urls_text.split(/\r?\n|,/).forEach(addValue);
}
const inputCount = collected.length;
if (!inputCount) {
return res.status(400).json({ error: 'Keine URLs zum Import übergeben' });
}
const normalizedTemplates = collected
.map((raw) => normalizeDailyBookmarkUrlTemplate(raw))
.filter(Boolean);
const invalidCount = inputCount - normalizedTemplates.length;
const uniqueTemplates = [...new Set(normalizedTemplates)];
const duplicateCount = normalizedTemplates.length - uniqueTemplates.length;
const createdItems = [];
let skippedExisting = 0;
const insertMany = db.transaction((templates) => {
for (const template of templates) {
if (findDailyBookmarkByUrlStmt.get(template)) {
skippedExisting += 1;
continue;
}
const id = uuidv4();
const title = normalizeDailyBookmarkTitle('', template);
insertDailyBookmarkStmt.run({
id,
title,
url_template: template,
notes: '',
marker: normalizedMarker
});
upsertDailyBookmarkCheckStmt.run({ bookmarkId: id, dayKey });
const saved = getDailyBookmarkStmt.get({ bookmarkId: id, dayKey });
if (saved) {
createdItems.push(saved);
}
}
});
try {
insertMany(uniqueTemplates);
res.json({
created: createdItems.length,
skipped_existing: skippedExisting,
skipped_invalid: invalidCount,
skipped_duplicates: duplicateCount,
marker: normalizedMarker,
day_key: dayKey,
items: createdItems.map((row) => serializeDailyBookmark(row, dayKey))
});
} catch (error) {
console.error('Failed to import daily bookmarks:', error);
res.status(500).json({ error: 'Import fehlgeschlagen' });
}
});
app.put('/api/daily-bookmarks/:bookmarkId', (req, res) => {
const { bookmarkId } = req.params;
if (!bookmarkId) {
return res.status(400).json({ error: 'Bookmark-ID fehlt' });
}
const payload = req.body || {};
const rawDay = (payload && payload.day) || (req.query && req.query.day);
const dayKey = rawDay ? normalizeDayKeyValue(rawDay) : resolveDayKey(rawDay);
if (!dayKey) {
return res.status(400).json({ error: 'Ungültiges Tagesformat' });
}
const existing = getDailyBookmarkStmt.get({ bookmarkId, dayKey });
if (!existing) {
return res.status(404).json({ error: 'Bookmark nicht gefunden' });
}
const normalizedUrl = normalizeDailyBookmarkUrlTemplate(
payload.url_template ?? payload.url ?? existing.url_template
);
const normalizedTitle = normalizeDailyBookmarkTitle(
payload.title ?? payload.label ?? existing.title,
normalizedUrl || existing.url_template
);
const normalizedNotes = normalizeDailyBookmarkNotes(
payload.notes ?? existing.notes ?? ''
);
const normalizedMarker = normalizeDailyBookmarkMarker(
payload.marker ?? existing.marker ?? ''
);
if (!normalizedUrl) {
return res.status(400).json({ error: 'URL-Template ist erforderlich' });
}
const otherWithUrl = findOtherDailyBookmarkByUrlStmt.get({
url: normalizedUrl,
id: bookmarkId
});
if (otherWithUrl) {
return res.status(409).json({ error: 'Bookmark mit dieser URL existiert bereits' });
}
try {
updateDailyBookmarkStmt.run({
id: bookmarkId,
title: normalizedTitle,
url_template: normalizedUrl,
notes: normalizedNotes,
marker: normalizedMarker
});
const updated = getDailyBookmarkStmt.get({ bookmarkId, dayKey });
res.json(serializeDailyBookmark(updated, dayKey));
} catch (error) {
console.error('Failed to update daily bookmark:', error);
res.status(500).json({ error: 'Daily Bookmark konnte nicht aktualisiert werden' });
}
});
app.delete('/api/daily-bookmarks/:bookmarkId', (req, res) => {
const { bookmarkId } = req.params;
if (!bookmarkId) {
return res.status(400).json({ error: 'Bookmark-ID fehlt' });
}
try {
const result = deleteDailyBookmarkStmt.run(bookmarkId);
if (!result.changes) {
return res.status(404).json({ error: 'Bookmark nicht gefunden' });
}
res.status(204).send();
} catch (error) {
console.error('Failed to delete daily bookmark:', error);
res.status(500).json({ error: 'Daily Bookmark konnte nicht gelöscht werden' });
}
});
app.post('/api/daily-bookmarks/:bookmarkId/check', (req, res) => {
const { bookmarkId } = req.params;
if (!bookmarkId) {
return res.status(400).json({ error: 'Bookmark-ID fehlt' });
}
const rawDay = (req.body && req.body.day) || (req.query && req.query.day);
const dayKey = rawDay ? normalizeDayKeyValue(rawDay) : resolveDayKey(rawDay);
if (!dayKey) {
return res.status(400).json({ error: 'Ungültiges Tagesformat' });
}
try {
const existing = getDailyBookmarkStmt.get({ bookmarkId, dayKey });
if (!existing) {
return res.status(404).json({ error: 'Bookmark nicht gefunden' });
}
upsertDailyBookmarkCheckStmt.run({ bookmarkId, dayKey });
const updated = getDailyBookmarkStmt.get({ bookmarkId, dayKey });
res.json(serializeDailyBookmark(updated, dayKey));
} catch (error) {
console.error('Failed to complete daily bookmark:', error);
res.status(500).json({ error: 'Daily Bookmark konnte nicht abgehakt werden' });
}
});
app.delete('/api/daily-bookmarks/:bookmarkId/check', (req, res) => {
const { bookmarkId } = req.params;
if (!bookmarkId) {
return res.status(400).json({ error: 'Bookmark-ID fehlt' });
}
const rawDay = (req.body && req.body.day) || (req.query && req.query.day);
const dayKey = rawDay ? normalizeDayKeyValue(rawDay) : resolveDayKey(rawDay);
if (!dayKey) {
return res.status(400).json({ error: 'Ungültiges Tagesformat' });
}
try {
const existing = getDailyBookmarkStmt.get({ bookmarkId, dayKey });
if (!existing) {
return res.status(404).json({ error: 'Bookmark nicht gefunden' });
}
deleteDailyBookmarkCheckStmt.run({ bookmarkId, dayKey });
const updated = getDailyBookmarkStmt.get({ bookmarkId, dayKey });
res.json(serializeDailyBookmark(updated, dayKey));
} catch (error) {
console.error('Failed to undo daily bookmark completion:', error);
res.status(500).json({ error: 'Daily Bookmark konnte nicht zurückgesetzt werden' });
}
});
// Get all posts
app.get('/api/events', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
@@ -3188,6 +3815,183 @@ app.patch('/api/posts/:postId', (req, res) => {
}
});
app.post('/api/posts/merge', (req, res) => {
const { primary_post_id, secondary_post_id, primary_url } = req.body || {};
if (!primary_post_id || !secondary_post_id) {
return res.status(400).json({ error: 'primary_post_id und secondary_post_id sind erforderlich' });
}
if (primary_post_id === secondary_post_id) {
return res.status(400).json({ error: 'Die Post-IDs müssen unterschiedlich sein' });
}
try {
const primaryPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(primary_post_id);
const secondaryPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(secondary_post_id);
if (!primaryPost || !secondaryPost) {
return res.status(404).json({ error: 'Einer der Beiträge wurde nicht gefunden' });
}
const normalizedPrimaryUrl = primary_url
? normalizeFacebookPostUrl(primary_url)
: normalizeFacebookPostUrl(primaryPost.url);
if (!normalizedPrimaryUrl) {
return res.status(400).json({ error: 'primary_url ist ungültig' });
}
const conflictId = findPostIdByUrl(normalizedPrimaryUrl);
if (conflictId && conflictId !== primary_post_id && conflictId !== secondary_post_id) {
return res.status(409).json({ error: 'Die gewählte Haupt-URL gehört bereits zu einem anderen Beitrag' });
}
const collectUrlsForPost = (post) => {
const urls = [];
if (post && post.url) {
urls.push(post.url);
}
if (post && post.id) {
const alternates = selectAlternateUrlsForPostStmt.all(post.id).map(row => row.url);
urls.push(...alternates);
}
return urls;
};
const mergedUrlSet = new Set();
for (const url of [...collectUrlsForPost(primaryPost), ...collectUrlsForPost(secondaryPost)]) {
const normalized = normalizeFacebookPostUrl(url);
if (normalized) {
mergedUrlSet.add(normalized);
}
}
mergedUrlSet.add(normalizedPrimaryUrl);
const alternateUrls = Array.from(mergedUrlSet).filter(url => url !== normalizedPrimaryUrl);
const mergedDeadline = (() => {
if (primaryPost.deadline_at && secondaryPost.deadline_at) {
return new Date(primaryPost.deadline_at) <= new Date(secondaryPost.deadline_at)
? primaryPost.deadline_at
: secondaryPost.deadline_at;
}
return primaryPost.deadline_at || secondaryPost.deadline_at || null;
})();
const mergedTargetCount = Math.max(
validateTargetCount(primaryPost.target_count) || 1,
validateTargetCount(secondaryPost.target_count) || 1
);
const mergedPostText = (primaryPost.post_text && primaryPost.post_text.trim())
? primaryPost.post_text
: (secondaryPost.post_text || null);
const normalizedMergedPostText = mergedPostText ? normalizePostText(mergedPostText) : null;
const mergedPostTextHash = normalizedMergedPostText
? computePostTextHash(normalizedMergedPostText)
: null;
const mergedCreatorName = (primaryPost.created_by_name && primaryPost.created_by_name.trim())
? primaryPost.created_by_name
: (secondaryPost.created_by_name || null);
const mergedCreatorProfile = primaryPost.created_by_profile || secondaryPost.created_by_profile || null;
const mergedTitle = (primaryPost.title && primaryPost.title.trim())
? primaryPost.title
: (secondaryPost.title || null);
const mergeTransaction = db.transaction(() => {
// Move checks from secondary to primary (one per profile)
const primaryChecks = selectChecksForPostStmt.all(primary_post_id);
const secondaryChecks = selectChecksForPostStmt.all(secondary_post_id);
const primaryByProfile = new Map();
for (const check of primaryChecks) {
if (!check || !check.profile_number) {
continue;
}
const existing = primaryByProfile.get(check.profile_number);
if (!existing) {
primaryByProfile.set(check.profile_number, check);
} else {
if (new Date(check.checked_at) < new Date(existing.checked_at)) {
primaryByProfile.set(check.profile_number, check);
} else {
deleteCheckByIdStmt.run(check.id);
}
}
}
for (const check of secondaryChecks) {
if (!check || !check.profile_number) {
continue;
}
const existing = primaryByProfile.get(check.profile_number);
if (!existing) {
updateCheckPostStmt.run(primary_post_id, check.id);
primaryByProfile.set(check.profile_number, check);
} else {
const existingDate = new Date(existing.checked_at);
const candidateDate = new Date(check.checked_at);
if (candidateDate < existingDate) {
updateCheckTimestampStmt.run(check.checked_at, existing.id);
}
deleteCheckByIdStmt.run(check.id);
}
}
// Delete secondary post (post_urls are cascaded)
db.prepare('DELETE FROM posts WHERE id = ?').run(secondary_post_id);
const primaryContentKey = extractFacebookContentKey(normalizedPrimaryUrl);
db.prepare(`
UPDATE posts
SET url = ?, content_key = ?, target_count = ?, created_by_name = ?, created_by_profile = ?,
deadline_at = ?, title = ?, post_text = ?, post_text_hash = ?, last_change = CURRENT_TIMESTAMP
WHERE id = ?
`).run(
normalizedPrimaryUrl,
primaryContentKey || null,
mergedTargetCount,
mergedCreatorName,
mergedCreatorProfile,
mergedDeadline,
mergedTitle,
normalizedMergedPostText,
mergedPostTextHash,
primary_post_id
);
storePostUrls(primary_post_id, normalizedPrimaryUrl, alternateUrls, { skipContentKeyCheck: true });
recalcCheckedCount(primary_post_id);
const updated = db.prepare('SELECT * FROM posts WHERE id = ?').get(primary_post_id);
return updated;
});
const merged = mergeTransaction();
if (secondaryPost && secondaryPost.screenshot_path) {
const secondaryFile = path.join(screenshotDir, secondaryPost.screenshot_path);
if (fs.existsSync(secondaryFile)) {
try {
fs.unlinkSync(secondaryFile);
} catch (cleanupError) {
console.warn('Konnte Screenshot des zusammengeführten Beitrags nicht entfernen:', cleanupError.message);
}
}
}
const mapped = mapPostRow(merged);
broadcastPostChange(mapped, { reason: 'merged' });
broadcastPostDeletion(secondary_post_id, { reason: 'merged' });
res.json(mapped);
} catch (error) {
console.error('Merge failed:', error);
res.status(500).json({ error: 'Beiträge konnten nicht gemerged werden' });
}
});
// Delete post
app.delete('/api/posts/:postId', (req, res) => {
try {
@@ -3526,8 +4330,16 @@ function sanitizeAIComment(text) {
return '';
}
// Clean up AI output: drop hidden tags, replace dashes, normalize spacing.
return text
.replace(/<think>[\s\S]*?<\/think>/gi, '')
.replace(/[-–—]+/g, (match, offset, full) => {
const prev = full[offset - 1];
const next = full[offset + match.length];
const prevIsWord = prev && /[A-Za-z0-9ÄÖÜäöüß]/.test(prev);
const nextIsWord = next && /[A-Za-z0-9ÄÖÜäöüß]/.test(next);
return prevIsWord && nextIsWord ? match : ', ';
})
.replace(/\s{2,}/g, ' ')
.trim();
}