daily bookmarks
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user