aktueller stand

This commit is contained in:
2025-12-29 19:45:08 +01:00
parent fde5ab91c8
commit 677eac2632
6 changed files with 1888 additions and 272 deletions

View File

@@ -25,6 +25,7 @@ const SEARCH_POST_HIDE_THRESHOLD = 2;
const SEARCH_POST_RETENTION_DAYS = 90; const SEARCH_POST_RETENTION_DAYS = 90;
const MAX_POST_TEXT_LENGTH = 4000; const MAX_POST_TEXT_LENGTH = 4000;
const MIN_TEXT_HASH_LENGTH = 120; const MIN_TEXT_HASH_LENGTH = 120;
const MIN_SIMILAR_TEXT_LENGTH = 60;
const MAX_BOOKMARK_LABEL_LENGTH = 120; const MAX_BOOKMARK_LABEL_LENGTH = 120;
const MAX_BOOKMARK_QUERY_LENGTH = 200; const MAX_BOOKMARK_QUERY_LENGTH = 200;
const DAILY_BOOKMARK_TITLE_MAX_LENGTH = 160; const DAILY_BOOKMARK_TITLE_MAX_LENGTH = 160;
@@ -96,6 +97,10 @@ const SPORTS_SCORING_TERMS_DEFAULTS = {
'album', 'tour', 'podcast', 'karriere', 'job', 'stellenangebot', 'bewerbung' 'album', 'tour', 'podcast', 'karriere', 'job', 'stellenangebot', 'bewerbung'
] ]
}; };
const SIMILARITY_DEFAULTS = {
text_threshold: 0.85,
image_distance_threshold: 6
};
const screenshotDir = path.join(__dirname, 'data', 'screenshots'); const screenshotDir = path.join(__dirname, 'data', 'screenshots');
if (!fs.existsSync(screenshotDir)) { if (!fs.existsSync(screenshotDir)) {
@@ -282,6 +287,8 @@ function ensureColumn(table, column, definition) {
ensureColumn('posts', 'post_text', 'post_text TEXT'); ensureColumn('posts', 'post_text', 'post_text TEXT');
ensureColumn('posts', 'post_text_hash', 'post_text_hash TEXT'); ensureColumn('posts', 'post_text_hash', 'post_text_hash TEXT');
ensureColumn('posts', 'content_key', 'content_key TEXT'); ensureColumn('posts', 'content_key', 'content_key TEXT');
ensureColumn('posts', 'first_image_hash', 'first_image_hash TEXT');
ensureColumn('posts', 'first_image_url', 'first_image_url TEXT');
db.exec(` db.exec(`
CREATE INDEX IF NOT EXISTS idx_posts_content_key CREATE INDEX IF NOT EXISTS idx_posts_content_key
@@ -692,6 +699,61 @@ function computePostTextHash(text) {
return crypto.createHash('sha256').update(text, 'utf8').digest('hex'); return crypto.createHash('sha256').update(text, 'utf8').digest('hex');
} }
function tokenizeSimilarityText(text) {
if (!text) {
return [];
}
const tokens = text.toLowerCase().match(/[\p{L}\p{N}]+/gu) || [];
return tokens.filter(token => token.length > 1);
}
function computeTextSimilarity(a, b) {
if (!a || !b) {
return 0;
}
const tokensA = tokenizeSimilarityText(a);
const tokensB = tokenizeSimilarityText(b);
if (!tokensA.length || !tokensB.length) {
return 0;
}
const setA = new Set(tokensA);
const setB = new Set(tokensB);
let intersection = 0;
for (const token of setA) {
if (setB.has(token)) {
intersection += 1;
}
}
const union = setA.size + setB.size - intersection;
if (union <= 0) {
return 0;
}
return intersection / union;
}
function hammingDistanceHex(a, b) {
if (!a || !b) {
return null;
}
const left = String(a).toLowerCase().replace(/^0x/, '');
const right = String(b).toLowerCase().replace(/^0x/, '');
if (left.length !== right.length) {
return null;
}
let xor;
try {
xor = BigInt(`0x${left}`) ^ BigInt(`0x${right}`);
} catch (error) {
return null;
}
let distance = 0;
while (xor > 0n) {
distance += Number(xor & 1n);
xor >>= 1n;
}
return distance;
}
function normalizeBookmarkQuery(value) { function normalizeBookmarkQuery(value) {
if (typeof value !== 'string') { if (typeof value !== 'string') {
return null; return null;
@@ -1186,6 +1248,43 @@ function buildProfileStatuses(requiredProfiles, checks) {
}; };
} }
function buildCompletedProfileSet(rows) {
const completedSet = new Set();
rows.forEach((row) => {
const profileNumber = sanitizeProfileNumber(row.profile_number);
if (profileNumber) {
completedSet.add(profileNumber);
}
});
return completedSet;
}
function countUniqueProfileChecks(checks) {
const uniqueProfiles = new Set();
checks.forEach((check) => {
const profileNumber = sanitizeProfileNumber(check.profile_number);
if (profileNumber) {
uniqueProfiles.add(profileNumber);
}
});
return uniqueProfiles.size;
}
function shouldEnforceProfileOrder({ post, requiredProfiles, completedSet, ignoreOrder = false }) {
if (ignoreOrder) {
return false;
}
if (!post || !requiredProfiles.length) {
return false;
}
const isExpired = post.deadline_at ? new Date(post.deadline_at) < new Date() : false;
if (isExpired) {
return false;
}
const isComplete = requiredProfiles.every(profile => completedSet.has(profile));
return !isComplete;
}
function recalcCheckedCount(postId) { function recalcCheckedCount(postId) {
const post = db.prepare('SELECT id, target_count, checked_count FROM posts WHERE id = ?').get(postId); const post = db.prepare('SELECT id, target_count, checked_count FROM posts WHERE id = ?').get(postId);
if (!post) { if (!post) {
@@ -1195,7 +1294,7 @@ function recalcCheckedCount(postId) {
const checks = db.prepare('SELECT id, profile_number, checked_at FROM checks WHERE post_id = ?').all(postId); const checks = db.prepare('SELECT id, profile_number, checked_at FROM checks WHERE post_id = ?').all(postId);
const requiredProfiles = getRequiredProfiles(post.target_count); const requiredProfiles = getRequiredProfiles(post.target_count);
const { statuses } = buildProfileStatuses(requiredProfiles, checks); const { statuses } = buildProfileStatuses(requiredProfiles, checks);
const checkedCount = statuses.filter(status => status.status === 'done').length; const checkedCount = Math.min(requiredProfiles.length, countUniqueProfileChecks(checks));
const updates = []; const updates = [];
const params = []; const params = [];
@@ -1234,6 +1333,8 @@ db.exec(`
post_text TEXT, post_text TEXT,
post_text_hash TEXT, post_text_hash TEXT,
content_key TEXT, content_key TEXT,
first_image_hash TEXT,
first_image_url TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_change DATETIME DEFAULT CURRENT_TIMESTAMP last_change DATETIME DEFAULT CURRENT_TIMESTAMP
); );
@@ -1346,6 +1447,15 @@ db.exec(`
); );
`); `);
db.exec(`
CREATE TABLE IF NOT EXISTS similarity_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
text_threshold REAL DEFAULT ${SIMILARITY_DEFAULTS.text_threshold},
image_distance_threshold INTEGER DEFAULT ${SIMILARITY_DEFAULTS.image_distance_threshold},
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
`);
ensureColumn('moderation_settings', 'sports_terms', 'sports_terms TEXT'); ensureColumn('moderation_settings', 'sports_terms', 'sports_terms TEXT');
ensureColumn('moderation_settings', 'sports_auto_hide_enabled', 'sports_auto_hide_enabled INTEGER DEFAULT 0'); ensureColumn('moderation_settings', 'sports_auto_hide_enabled', 'sports_auto_hide_enabled INTEGER DEFAULT 0');
@@ -2037,6 +2147,7 @@ function extractRateLimitInfo(response, provider) {
} }
const RATE_LIMIT_KEYWORDS = ['rate limit', 'ratelimit', 'quota', 'limit', 'too many requests', 'insufficient_quota', 'billing', 'exceeded', 'exceed']; const RATE_LIMIT_KEYWORDS = ['rate limit', 'ratelimit', 'quota', 'limit', 'too many requests', 'insufficient_quota', 'billing', 'exceeded', 'exceed'];
const AI_COMMENT_RETRY_LIMIT = 5;
function determineAutoDisable(error) { function determineAutoDisable(error) {
if (!error) { if (!error) {
@@ -3284,6 +3395,64 @@ function persistModerationSettings({ enabled, threshold, weights, terms, autoHid
}; };
} }
function normalizeSimilarityTextThreshold(value) {
const parsed = parseFloat(value);
if (Number.isNaN(parsed)) {
return SIMILARITY_DEFAULTS.text_threshold;
}
return Math.min(0.99, Math.max(0.5, parsed));
}
function normalizeSimilarityImageDistance(value) {
const parsed = parseInt(value, 10);
if (Number.isNaN(parsed)) {
return SIMILARITY_DEFAULTS.image_distance_threshold;
}
return Math.min(64, Math.max(0, parsed));
}
function loadSimilaritySettings() {
let settings = db.prepare('SELECT * FROM similarity_settings WHERE id = 1').get();
if (!settings) {
db.prepare(`
INSERT INTO similarity_settings (id, text_threshold, image_distance_threshold, updated_at)
VALUES (1, ?, ?, CURRENT_TIMESTAMP)
`).run(SIMILARITY_DEFAULTS.text_threshold, SIMILARITY_DEFAULTS.image_distance_threshold);
settings = {
text_threshold: SIMILARITY_DEFAULTS.text_threshold,
image_distance_threshold: SIMILARITY_DEFAULTS.image_distance_threshold
};
}
return {
text_threshold: normalizeSimilarityTextThreshold(settings.text_threshold),
image_distance_threshold: normalizeSimilarityImageDistance(settings.image_distance_threshold)
};
}
function persistSimilaritySettings({ textThreshold, imageDistanceThreshold }) {
const normalizedText = normalizeSimilarityTextThreshold(textThreshold);
const normalizedImage = normalizeSimilarityImageDistance(imageDistanceThreshold);
const existing = db.prepare('SELECT id FROM similarity_settings WHERE id = 1').get();
if (existing) {
db.prepare(`
UPDATE similarity_settings
SET text_threshold = ?, image_distance_threshold = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = 1
`).run(normalizedText, normalizedImage);
} else {
db.prepare(`
INSERT INTO similarity_settings (id, text_threshold, image_distance_threshold, updated_at)
VALUES (1, ?, ?, CURRENT_TIMESTAMP)
`).run(normalizedText, normalizedImage);
}
return {
text_threshold: normalizedText,
image_distance_threshold: normalizedImage
};
}
function expandPhotoUrlHostVariants(url) { function expandPhotoUrlHostVariants(url) {
if (typeof url !== 'string' || !url) { if (typeof url !== 'string' || !url) {
return []; return [];
@@ -3396,6 +3565,10 @@ const selectAlternateUrlsForPostStmt = db.prepare(`
ORDER BY created_at ASC ORDER BY created_at ASC
`); `);
const selectPostByTextHashStmt = db.prepare('SELECT * FROM posts WHERE post_text_hash = ?'); const selectPostByTextHashStmt = db.prepare('SELECT * FROM posts WHERE post_text_hash = ?');
const selectPostsForSimilarityStmt = db.prepare(`
SELECT id, url, title, created_by_name, created_at, post_text, first_image_hash
FROM posts
`);
const selectChecksForPostStmt = db.prepare('SELECT id, profile_number, checked_at FROM checks WHERE post_id = ?'); 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 updateCheckPostStmt = db.prepare('UPDATE checks SET post_id = ? WHERE id = ?');
const updateCheckTimestampStmt = db.prepare('UPDATE checks SET checked_at = ? WHERE id = ?'); const updateCheckTimestampStmt = db.prepare('UPDATE checks SET checked_at = ? WHERE id = ?');
@@ -3576,7 +3749,7 @@ function mapPostRow(post) {
const checks = db.prepare('SELECT id, profile_number, checked_at FROM checks WHERE post_id = ? ORDER BY checked_at ASC').all(post.id); const checks = db.prepare('SELECT id, profile_number, checked_at FROM checks WHERE post_id = ? ORDER BY checked_at ASC').all(post.id);
const requiredProfiles = getRequiredProfiles(post.target_count); const requiredProfiles = getRequiredProfiles(post.target_count);
const { statuses, completedChecks } = buildProfileStatuses(requiredProfiles, checks); const { statuses, completedChecks } = buildProfileStatuses(requiredProfiles, checks);
const checkedCount = statuses.filter(status => status.status === 'done').length; const checkedCount = Math.min(requiredProfiles.length, countUniqueProfileChecks(checks));
const screenshotFile = post.screenshot_path ? path.join(screenshotDir, post.screenshot_path) : null; const screenshotFile = post.screenshot_path ? path.join(screenshotDir, post.screenshot_path) : null;
const screenshotPath = screenshotFile && fs.existsSync(screenshotFile) const screenshotPath = screenshotFile && fs.existsSync(screenshotFile)
? `/api/posts/${post.id}/screenshot` ? `/api/posts/${post.id}/screenshot`
@@ -3645,7 +3818,9 @@ function mapPostRow(post) {
alternate_urls: alternateUrls, alternate_urls: alternateUrls,
post_text: post.post_text || null, post_text: post.post_text || null,
post_text_hash: post.post_text_hash || null, post_text_hash: post.post_text_hash || null,
content_key: post.content_key || postContentKey || null content_key: post.content_key || postContentKey || null,
first_image_hash: post.first_image_hash || null,
first_image_url: post.first_image_url || null
}; };
} }
@@ -4607,6 +4782,106 @@ app.get('/api/posts/by-url', (req, res) => {
} }
}); });
app.post('/api/posts/similar', (req, res) => {
try {
const { url, post_text, first_image_hash } = req.body || {};
const normalizedUrl = normalizeFacebookPostUrl(url);
if (!normalizedUrl) {
return res.status(400).json({ error: 'url must be a valid Facebook link' });
}
const existing = findPostByUrl(normalizedUrl);
if (existing) {
return res.json({ match: null });
}
const settings = loadSimilaritySettings();
const normalizedText = normalizePostText(post_text);
const textEligible = normalizedText && normalizedText.length >= MIN_SIMILAR_TEXT_LENGTH;
const normalizedImageHash = typeof first_image_hash === 'string'
? first_image_hash.trim().toLowerCase()
: null;
const cleanedImageHash = (normalizedImageHash && /^[0-9a-f]{16}$/.test(normalizedImageHash))
? normalizedImageHash
: null;
let bestText = null;
let bestImage = null;
const rows = selectPostsForSimilarityStmt.all();
for (const row of rows) {
if (!row) {
continue;
}
if (textEligible && row.post_text) {
const candidateText = normalizePostText(row.post_text);
if (candidateText) {
const score = computeTextSimilarity(normalizedText, candidateText);
if (!bestText || score > bestText.score) {
bestText = { post: row, score };
}
}
}
if (cleanedImageHash && row.first_image_hash) {
const distance = hammingDistanceHex(cleanedImageHash, row.first_image_hash);
if (distance !== null && (!bestImage || distance < bestImage.distance)) {
bestImage = { post: row, distance };
}
}
}
const textMatch = bestText && bestText.score >= settings.text_threshold ? bestText : null;
const imageMatch = bestImage && bestImage.distance <= settings.image_distance_threshold ? bestImage : null;
if (!textMatch && !imageMatch) {
return res.json({ match: null });
}
const imageScore = (match) => match ? 1 - (match.distance / 64) : 0;
let selected = textMatch || imageMatch;
let reason = textMatch ? 'text' : 'image';
let textScore = textMatch ? textMatch.score : null;
let imageDistance = imageMatch ? imageMatch.distance : null;
if (textMatch && imageMatch) {
if (textMatch.post.id === imageMatch.post.id) {
selected = textMatch;
reason = 'text+image';
} else {
const textScoreValue = textMatch.score;
const imageScoreValue = imageScore(imageMatch);
if (imageScoreValue > textScoreValue) {
selected = imageMatch;
reason = 'image';
} else {
selected = textMatch;
reason = 'text';
}
}
}
const matchPost = selected.post;
const responsePayload = {
match: {
id: matchPost.id,
url: matchPost.url,
title: matchPost.title,
created_by_name: matchPost.created_by_name,
created_at: sqliteTimestampToUTC(matchPost.created_at)
},
similarity: {
text: textScore,
image_distance: imageDistance
},
reason
};
res.json(responsePayload);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.post('/api/search-posts', (req, res) => { app.post('/api/search-posts', (req, res) => {
try { try {
const { url, candidates, skip_increment, force_hide, sports_auto_hide } = req.body || {}; const { url, candidates, skip_increment, force_hide, sports_auto_hide } = req.body || {};
@@ -4861,6 +5136,15 @@ app.post('/api/posts', (req, res) => {
const normalizedPostText = normalizePostText(post_text); const normalizedPostText = normalizePostText(post_text);
const postTextHash = computePostTextHash(normalizedPostText); const postTextHash = computePostTextHash(normalizedPostText);
const contentKey = extractFacebookContentKey(normalizedUrl); const contentKey = extractFacebookContentKey(normalizedUrl);
const normalizedImageHash = typeof req.body.first_image_hash === 'string'
? req.body.first_image_hash.trim().toLowerCase()
: null;
const cleanedImageHash = (normalizedImageHash && /^[0-9a-f]{16}$/.test(normalizedImageHash))
? normalizedImageHash
: null;
const normalizedImageUrl = typeof req.body.first_image_url === 'string'
? normalizeFacebookPostUrl(req.body.first_image_url) || req.body.first_image_url.trim()
: null;
const useTextHashDedup = normalizedPostText && normalizedPostText.length >= MIN_TEXT_HASH_LENGTH && postTextHash; const useTextHashDedup = normalizedPostText && normalizedPostText.length >= MIN_TEXT_HASH_LENGTH && postTextHash;
if (useTextHashDedup) { if (useTextHashDedup) {
@@ -4912,9 +5196,11 @@ app.post('/api/posts', (req, res) => {
post_text, post_text,
post_text_hash, post_text_hash,
content_key, content_key,
first_image_hash,
first_image_url,
last_change last_change
) )
VALUES (?, ?, ?, ?, 0, NULL, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) VALUES (?, ?, ?, ?, 0, NULL, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
`); `);
stmt.run( stmt.run(
id, id,
@@ -4926,7 +5212,9 @@ app.post('/api/posts', (req, res) => {
normalizedDeadline, normalizedDeadline,
normalizedPostText, normalizedPostText,
postTextHash, postTextHash,
contentKey || null contentKey || null,
cleanedImageHash || null,
normalizedImageUrl || null
); );
const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(id); const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(id);
@@ -5084,7 +5372,7 @@ app.put('/api/posts/:postId', (req, res) => {
app.post('/api/posts/:postId/check', (req, res) => { app.post('/api/posts/:postId/check', (req, res) => {
try { try {
const { postId } = req.params; const { postId } = req.params;
const { profile_number } = req.body; const { profile_number, ignore_order } = req.body || {};
const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId); const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId);
if (!post) { if (!post) {
@@ -5113,9 +5401,14 @@ app.post('/api/posts/:postId/check', (req, res) => {
profileValue = storedProfile || requiredProfiles[0]; profileValue = storedProfile || requiredProfiles[0];
} }
if (!requiredProfiles.includes(profileValue)) { const completedRows = db.prepare('SELECT profile_number FROM checks WHERE post_id = ?').all(postId);
return res.status(409).json({ error: 'Dieses Profil ist für diesen Beitrag nicht erforderlich.' }); const completedSet = buildCompletedProfileSet(completedRows);
} const shouldEnforceOrder = shouldEnforceProfileOrder({
post,
requiredProfiles,
completedSet,
ignoreOrder: !!ignore_order
});
const existingCheck = db.prepare( const existingCheck = db.prepare(
'SELECT id FROM checks WHERE post_id = ? AND profile_number = ?' 'SELECT id FROM checks WHERE post_id = ? AND profile_number = ?'
@@ -5129,16 +5422,9 @@ app.post('/api/posts/:postId/check', (req, res) => {
// Allow creator to check immediately, regardless of profile number // Allow creator to check immediately, regardless of profile number
const isCreator = post.created_by_profile === profileValue; const isCreator = post.created_by_profile === profileValue;
if (requiredProfiles.length > 0 && !isCreator) { if (shouldEnforceOrder && requiredProfiles.length > 0 && !isCreator) {
const prerequisiteProfiles = requiredProfiles.slice(0, requiredProfiles.indexOf(profileValue)); const prerequisiteProfiles = requiredProfiles.slice(0, requiredProfiles.indexOf(profileValue));
if (prerequisiteProfiles.length) { if (prerequisiteProfiles.length) {
const completedRows = db.prepare('SELECT profile_number FROM checks WHERE post_id = ?').all(postId);
const completedSet = new Set(
completedRows
.map(row => sanitizeProfileNumber(row.profile_number))
.filter(Boolean)
);
const missingPrerequisites = prerequisiteProfiles.filter(num => !completedSet.has(num)); const missingPrerequisites = prerequisiteProfiles.filter(num => !completedSet.has(num));
if (missingPrerequisites.length) { if (missingPrerequisites.length) {
return res.status(409).json({ return res.status(409).json({
@@ -5168,7 +5454,7 @@ app.post('/api/posts/:postId/check', (req, res) => {
app.post('/api/posts/:postId/urls', (req, res) => { app.post('/api/posts/:postId/urls', (req, res) => {
try { try {
const { postId } = req.params; const { postId } = req.params;
const { urls } = req.body || {}; const { urls, skip_content_key_check, first_image_hash, first_image_url } = req.body || {};
const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId); const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId);
if (!post) { if (!post) {
@@ -5176,10 +5462,37 @@ app.post('/api/posts/:postId/urls', (req, res) => {
} }
const candidateList = Array.isArray(urls) ? urls : []; const candidateList = Array.isArray(urls) ? urls : [];
const alternateUrls = collectPostAlternateUrls(post.url, candidateList); let alternateUrls = [];
storePostUrls(post.id, post.url, alternateUrls); const skipContentKey = !!skip_content_key_check;
if (skipContentKey) {
const normalized = collectNormalizedFacebookUrls(post.url, candidateList);
alternateUrls = normalized.filter(url => url !== post.url);
} else {
alternateUrls = collectPostAlternateUrls(post.url, candidateList);
}
storePostUrls(post.id, post.url, alternateUrls, { skipContentKeyCheck: skipContentKey });
removeSearchSeenEntries([post.url, ...alternateUrls]); removeSearchSeenEntries([post.url, ...alternateUrls]);
const normalizedImageHash = typeof first_image_hash === 'string'
? first_image_hash.trim().toLowerCase()
: null;
const cleanedImageHash = (normalizedImageHash && /^[0-9a-f]{16}$/.test(normalizedImageHash))
? normalizedImageHash
: null;
const normalizedImageUrl = typeof first_image_url === 'string'
? normalizeFacebookPostUrl(first_image_url) || first_image_url.trim()
: null;
if ((!post.first_image_hash && cleanedImageHash) || (!post.first_image_url && normalizedImageUrl)) {
db.prepare(`
UPDATE posts
SET first_image_hash = COALESCE(first_image_hash, ?),
first_image_url = COALESCE(first_image_url, ?),
last_change = CURRENT_TIMESTAMP
WHERE id = ?
`).run(cleanedImageHash || null, normalizedImageUrl || null, post.id);
}
const storedAlternates = selectAlternateUrlsForPostStmt.all(post.id).map(row => row.url); const storedAlternates = selectAlternateUrlsForPostStmt.all(post.id).map(row => row.url);
if (alternateUrls.length) { if (alternateUrls.length) {
touchPost(post.id, 'alternate-urls'); touchPost(post.id, 'alternate-urls');
@@ -5197,7 +5510,7 @@ app.post('/api/posts/:postId/urls', (req, res) => {
// Check by URL (for web interface auto-check) // Check by URL (for web interface auto-check)
app.post('/api/check-by-url', (req, res) => { app.post('/api/check-by-url', (req, res) => {
try { try {
const { url, profile_number } = req.body; const { url, profile_number, ignore_order } = req.body || {};
if (!url) { if (!url) {
return res.status(400).json({ error: 'URL is required' }); return res.status(400).json({ error: 'URL is required' });
@@ -5239,9 +5552,14 @@ app.post('/api/check-by-url', (req, res) => {
profileValue = storedProfile || requiredProfiles[0]; profileValue = storedProfile || requiredProfiles[0];
} }
if (!requiredProfiles.includes(profileValue)) { const completedRows = db.prepare('SELECT profile_number FROM checks WHERE post_id = ?').all(post.id);
return res.status(409).json({ error: 'Dieses Profil ist für diesen Beitrag nicht erforderlich.' }); const completedSet = buildCompletedProfileSet(completedRows);
} const shouldEnforceOrder = shouldEnforceProfileOrder({
post,
requiredProfiles,
completedSet,
ignoreOrder: !!ignore_order
});
const existingCheck = db.prepare( const existingCheck = db.prepare(
'SELECT id FROM checks WHERE post_id = ? AND profile_number = ?' 'SELECT id FROM checks WHERE post_id = ? AND profile_number = ?'
@@ -5255,16 +5573,9 @@ app.post('/api/check-by-url', (req, res) => {
// Allow creator to check immediately, regardless of profile number // Allow creator to check immediately, regardless of profile number
const isCreator = post.created_by_profile === profileValue; const isCreator = post.created_by_profile === profileValue;
if (requiredProfiles.length > 0 && !isCreator) { if (shouldEnforceOrder && requiredProfiles.length > 0 && !isCreator) {
const prerequisiteProfiles = requiredProfiles.slice(0, requiredProfiles.indexOf(profileValue)); const prerequisiteProfiles = requiredProfiles.slice(0, requiredProfiles.indexOf(profileValue));
if (prerequisiteProfiles.length) { if (prerequisiteProfiles.length) {
const completedRows = db.prepare('SELECT profile_number FROM checks WHERE post_id = ?').all(post.id);
const completedSet = new Set(
completedRows
.map(row => sanitizeProfileNumber(row.profile_number))
.filter(Boolean)
);
const missingPrerequisites = prerequisiteProfiles.filter(num => !completedSet.has(num)); const missingPrerequisites = prerequisiteProfiles.filter(num => !completedSet.has(num));
if (missingPrerequisites.length) { if (missingPrerequisites.length) {
return res.status(409).json({ return res.status(409).json({
@@ -5293,7 +5604,7 @@ app.post('/api/check-by-url', (req, res) => {
app.post('/api/posts/:postId/profile-status', (req, res) => { app.post('/api/posts/:postId/profile-status', (req, res) => {
try { try {
const { postId } = req.params; const { postId } = req.params;
const { profile_number, status } = req.body || {}; const { profile_number, status, ignore_order } = req.body || {};
const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId); const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId);
if (!post) { if (!post) {
@@ -5331,16 +5642,18 @@ app.post('/api/posts/:postId/profile-status', (req, res) => {
// Allow creator to check immediately, regardless of profile number // Allow creator to check immediately, regardless of profile number
const isCreator = post.created_by_profile === profileValue; const isCreator = post.created_by_profile === profileValue;
if (!isCreator) { const completedRows = db.prepare('SELECT profile_number FROM checks WHERE post_id = ?').all(postId);
const completedSet = buildCompletedProfileSet(completedRows);
const shouldEnforceOrder = shouldEnforceProfileOrder({
post,
requiredProfiles,
completedSet,
ignoreOrder: !!ignore_order
});
if (shouldEnforceOrder && !isCreator) {
const prerequisiteProfiles = requiredProfiles.slice(0, requiredProfiles.indexOf(profileValue)); const prerequisiteProfiles = requiredProfiles.slice(0, requiredProfiles.indexOf(profileValue));
if (prerequisiteProfiles.length) { if (prerequisiteProfiles.length) {
const completedRows = db.prepare('SELECT profile_number FROM checks WHERE post_id = ?').all(postId);
const completedSet = new Set(
completedRows
.map(row => sanitizeProfileNumber(row.profile_number))
.filter(Boolean)
);
const missingPrerequisites = prerequisiteProfiles.filter(num => !completedSet.has(num)); const missingPrerequisites = prerequisiteProfiles.filter(num => !completedSet.has(num));
if (missingPrerequisites.length) { if (missingPrerequisites.length) {
return res.status(409).json({ return res.status(409).json({
@@ -5518,6 +5831,8 @@ app.post('/api/posts/merge', (req, res) => {
const mergedTitle = (primaryPost.title && primaryPost.title.trim()) const mergedTitle = (primaryPost.title && primaryPost.title.trim())
? primaryPost.title ? primaryPost.title
: (secondaryPost.title || null); : (secondaryPost.title || null);
const mergedImageHash = primaryPost.first_image_hash || secondaryPost.first_image_hash || null;
const mergedImageUrl = primaryPost.first_image_url || secondaryPost.first_image_url || null;
const mergeTransaction = db.transaction(() => { const mergeTransaction = db.transaction(() => {
// Move checks from secondary to primary (one per profile) // Move checks from secondary to primary (one per profile)
@@ -5566,7 +5881,7 @@ app.post('/api/posts/merge', (req, res) => {
db.prepare(` db.prepare(`
UPDATE posts UPDATE posts
SET url = ?, content_key = ?, target_count = ?, created_by_name = ?, created_by_profile = ?, SET url = ?, content_key = ?, target_count = ?, created_by_name = ?, created_by_profile = ?,
deadline_at = ?, title = ?, post_text = ?, post_text_hash = ?, last_change = CURRENT_TIMESTAMP deadline_at = ?, title = ?, post_text = ?, post_text_hash = ?, first_image_hash = ?, first_image_url = ?, last_change = CURRENT_TIMESTAMP
WHERE id = ? WHERE id = ?
`).run( `).run(
normalizedPrimaryUrl, normalizedPrimaryUrl,
@@ -5578,6 +5893,8 @@ app.post('/api/posts/merge', (req, res) => {
mergedTitle, mergedTitle,
normalizedMergedPostText, normalizedMergedPostText,
mergedPostTextHash, mergedPostTextHash,
mergedImageHash,
mergedImageUrl,
primary_post_id primary_post_id
); );
@@ -5916,6 +6233,28 @@ app.put('/api/moderation-settings', (req, res) => {
} }
}); });
app.get('/api/similarity-settings', (req, res) => {
try {
const settings = loadSimilaritySettings();
res.json(settings);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.put('/api/similarity-settings', (req, res) => {
try {
const body = req.body || {};
const saved = persistSimilaritySettings({
textThreshold: body.text_threshold,
imageDistanceThreshold: body.image_distance_threshold
});
res.json(saved);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.get('/api/hidden-settings', (req, res) => { app.get('/api/hidden-settings', (req, res) => {
try { try {
const settings = loadHiddenSettings(); const settings = loadHiddenSettings();
@@ -5955,6 +6294,26 @@ function sanitizeAIComment(text) {
// Strip leading label noise some models prepend (e.g. "**Kommentar**, **Inhalt**:") // Strip leading label noise some models prepend (e.g. "**Kommentar**, **Inhalt**:")
const markerPattern = /^(?:\s*(?:\*\*|__)?\s*(kommentar|inhalt|text|content)\s*(?:\*\*|__)?\s*[,;:.\-–—`'"]*\s*)+/i; const markerPattern = /^(?:\s*(?:\*\*|__)?\s*(kommentar|inhalt|text|content)\s*(?:\*\*|__)?\s*[,;:.\-–—`'"]*\s*)+/i;
cleaned = cleaned.replace(markerPattern, ''); cleaned = cleaned.replace(markerPattern, '');
cleaned = cleaned.replace(/```+/g, '');
cleaned = cleaned.replace(/\*\*[^*]+\*\*/g, '');
cleaned = cleaned.replace(/`@([^`]+)`/g, '@$1');
cleaned = cleaned.replace(/`([^`]+)`/g, '$1');
const lines = cleaned.split(/\r?\n/);
while (lines.length) {
const line = lines[0].trim();
if (!line) {
lines.shift();
continue;
}
const labelLine = /^(?:\*\*|__)?\s*(kommentar|inhalt|text|content)\s*(?:\*\*|__)?\s*[:\-–—]*\s*$/i;
if (labelLine.test(line)) {
lines.shift();
continue;
}
break;
}
cleaned = lines.join('\n').replace(/\s*,\s*/g, ', ').replace(/,+\s*$/g, '');
// Clean up AI output: drop hidden tags, replace dashes, normalize spacing. // Clean up AI output: drop hidden tags, replace dashes, normalize spacing.
return cleaned return cleaned
@@ -5967,19 +6326,32 @@ function sanitizeAIComment(text) {
return prevIsWord && nextIsWord ? match : ', '; return prevIsWord && nextIsWord ? match : ', ';
}) })
.replace(/^[\s,;:.\-–—!?\u00a0"'`]+/, '') .replace(/^[\s,;:.\-–—!?\u00a0"'`]+/, '')
.replace(/,+$/g, '')
.replace(/\s{2,}/g, ' ') .replace(/\s{2,}/g, ' ')
.trim(); .trim();
} }
function shouldRetryAIComment(text) {
if (!text || typeof text !== 'string') {
return false;
}
const lower = text.toLowerCase();
const hasCommentOrCharacter = lower.includes('comment') || lower.includes('character');
const hasLengthOrCount = lower.includes('length') || lower.includes('count');
return hasCommentOrCharacter && hasLengthOrCount;
}
async function tryGenerateComment(credential, promptPrefix, postText) { async function tryGenerateComment(credential, promptPrefix, postText) {
const provider = credential.provider; const provider = credential.provider;
const apiKey = credential.api_key; const apiKey = credential.api_key;
const model = credential.model; const model = credential.model;
let comment = '';
let lastResponse = null; let lastResponse = null;
try { try {
for (let attempt = 1; attempt <= AI_COMMENT_RETRY_LIMIT; attempt += 1) {
let comment = '';
if (provider === 'gemini') { if (provider === 'gemini') {
const modelName = model || 'gemini-2.0-flash-exp'; const modelName = model || 'gemini-2.0-flash-exp';
const prompt = promptPrefix + postText; const prompt = promptPrefix + postText;
@@ -6036,7 +6408,6 @@ async function tryGenerateComment(credential, promptPrefix, postText) {
const data = await response.json(); const data = await response.json();
comment = data.candidates?.[0]?.content?.parts?.[0]?.text || ''; comment = data.candidates?.[0]?.content?.parts?.[0]?.text || '';
} else if (provider === 'openai') { } else if (provider === 'openai') {
const modelName = model || 'gpt-3.5-turbo'; const modelName = model || 'gpt-3.5-turbo';
const prompt = promptPrefix + postText; const prompt = promptPrefix + postText;
@@ -6100,7 +6471,6 @@ async function tryGenerateComment(credential, promptPrefix, postText) {
const data = await response.json(); const data = await response.json();
comment = data.choices?.[0]?.message?.content || ''; comment = data.choices?.[0]?.message?.content || '';
} else if (provider === 'claude') { } else if (provider === 'claude') {
const modelName = model || 'claude-3-5-haiku-20241022'; const modelName = model || 'claude-3-5-haiku-20241022';
const prompt = promptPrefix + postText; const prompt = promptPrefix + postText;
@@ -6158,11 +6528,19 @@ async function tryGenerateComment(credential, promptPrefix, postText) {
const data = await response.json(); const data = await response.json();
comment = data.content?.[0]?.text || ''; comment = data.content?.[0]?.text || '';
} else { } else {
throw new Error(`Unsupported AI provider: ${provider}`); throw new Error(`Unsupported AI provider: ${provider}`);
} }
if (shouldRetryAIComment(comment)) {
if (attempt < AI_COMMENT_RETRY_LIMIT) {
continue;
}
const error = new Error('AI response contains forbidden comment length/count metadata');
error.provider = provider;
throw error;
}
const rateInfo = extractRateLimitInfo(lastResponse, provider); const rateInfo = extractRateLimitInfo(lastResponse, provider);
rateInfo.status = lastResponse ? lastResponse.status : null; rateInfo.status = lastResponse ? lastResponse.status : null;
@@ -6170,6 +6548,7 @@ async function tryGenerateComment(credential, promptPrefix, postText) {
comment: sanitizeAIComment(comment), comment: sanitizeAIComment(comment),
rateInfo rateInfo
}; };
}
} catch (error) { } catch (error) {
if (error && !error.provider) { if (error && !error.provider) {
error.provider = provider; error.provider = provider;

View File

@@ -42,6 +42,32 @@ function isOnReelsPage() {
} }
} }
function maybeRedirectPageReelsToMain() {
try {
const { location } = window;
const pathname = location && location.pathname;
if (typeof pathname !== 'string') {
return false;
}
const match = pathname.match(/^\/([^/]+)\/reels\/?$/i);
if (!match) {
return false;
}
const pageSlug = match[1];
if (!pageSlug) {
return false;
}
const targetUrl = `${location.origin}/${pageSlug}/`;
if (location.href === targetUrl) {
return false;
}
location.replace(targetUrl);
return true;
} catch (error) {
return false;
}
}
let debugLoggingEnabled = false; let debugLoggingEnabled = false;
const originalConsoleLog = console.log.bind(console); const originalConsoleLog = console.log.bind(console);
@@ -705,6 +731,140 @@ function expandPhotoUrlHostVariants(url) {
} }
} }
async function fetchPostByUrl(url) {
const normalizedUrl = normalizeFacebookPostUrl(url);
if (!normalizedUrl) {
return null;
}
const response = await backendFetch(`${API_URL}/posts/by-url?url=${encodeURIComponent(normalizedUrl)}`);
if (!response.ok) {
return null;
}
const data = await response.json();
return data && data.id ? data : null;
}
async function fetchPostById(postId) {
if (!postId) {
return null;
}
try {
const response = await backendFetch(`${API_URL}/posts`);
if (!response.ok) {
return null;
}
const posts = await response.json();
if (!Array.isArray(posts)) {
return null;
}
return posts.find(post => post && post.id === postId) || null;
} catch (error) {
return null;
}
}
async function buildSimilarityPayload(postElement) {
let postText = null;
try {
postText = extractPostText(postElement) || null;
} catch (error) {
console.debug('[FB Tracker] Failed to extract post text for similarity:', error);
}
const imageInfo = await getFirstPostImageInfo(postElement);
return {
postText,
firstImageHash: imageInfo.hash,
firstImageUrl: imageInfo.url
};
}
async function findSimilarPost({ url, postText, firstImageHash }) {
if (!url) {
return null;
}
if (!postText && !firstImageHash) {
return null;
}
try {
const payload = {
url,
post_text: postText || null,
first_image_hash: firstImageHash || null
};
const response = await backendFetch(`${API_URL}/posts/similar`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
if (!response.ok) {
return null;
}
const data = await response.json();
return data && data.match ? data : null;
} catch (error) {
console.warn('[FB Tracker] Similarity check failed:', error);
return null;
}
}
function shortenInline(text, maxLength = 64) {
if (!text) {
return '';
}
if (text.length <= maxLength) {
return text;
}
return `${text.slice(0, maxLength - 3)}...`;
}
function formatSimilarityLabel(similarity) {
if (!similarity || !similarity.match) {
return '';
}
const match = similarity.match;
const base = match.title || match.created_by_name || match.url || 'Beitrag';
const details = [];
if (similarity.similarity && typeof similarity.similarity.text === 'number') {
details.push(`Text ${Math.round(similarity.similarity.text * 100)}%`);
}
if (similarity.similarity && typeof similarity.similarity.image_distance === 'number') {
details.push(`Bild Δ${similarity.similarity.image_distance}`);
}
const detailText = details.length ? ` (${details.join(', ')})` : '';
return `Ähnlich zu: ${shortenInline(base, 64)}${detailText}`;
}
async function attachUrlToExistingPost(postId, urls, payload = {}) {
if (!postId) {
return false;
}
try {
const body = {
urls: Array.isArray(urls) ? urls : [],
skip_content_key_check: true
};
if (payload.firstImageHash) {
body.first_image_hash = payload.firstImageHash;
}
if (payload.firstImageUrl) {
body.first_image_url = payload.firstImageUrl;
}
const response = await backendFetch(`${API_URL}/posts/${postId}/urls`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
});
return response.ok;
} catch (error) {
console.warn('[FB Tracker] Failed to attach URL to existing post:', error);
return false;
}
}
// Check if post is already tracked (checks all URL candidates to avoid duplicates) // Check if post is already tracked (checks all URL candidates to avoid duplicates)
async function checkPostStatus(postUrl, allUrlCandidates = []) { async function checkPostStatus(postUrl, allUrlCandidates = []) {
try { try {
@@ -880,15 +1040,20 @@ async function persistAlternatePostUrls(postId, urls = []) {
} }
// Add post to tracking // Add post to tracking
async function markPostChecked(postId, profileNumber) { async function markPostChecked(postId, profileNumber, options = {}) {
try { try {
const ignoreOrder = options && options.ignoreOrder === true;
const returnError = options && options.returnError === true;
console.log('[FB Tracker] Marking post as checked:', postId, 'Profile:', profileNumber); console.log('[FB Tracker] Marking post as checked:', postId, 'Profile:', profileNumber);
const response = await backendFetch(`${API_URL}/posts/${postId}/check`, { const response = await backendFetch(`${API_URL}/posts/${postId}/check`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ profile_number: profileNumber }) body: JSON.stringify({
profile_number: profileNumber,
ignore_order: ignoreOrder
})
}); });
if (response.ok) { if (response.ok) {
@@ -898,15 +1063,21 @@ async function markPostChecked(postId, profileNumber) {
} }
if (response.status === 409) { if (response.status === 409) {
console.log('[FB Tracker] Post already checked by this profile'); const payload = await response.json().catch(() => ({}));
return null; const message = payload && payload.error ? payload.error : 'Beitrag kann aktuell nicht bestätigt werden.';
console.log('[FB Tracker] Post check blocked:', message);
return returnError ? { error: message, status: response.status } : null;
} }
console.error('[FB Tracker] Failed to mark post as checked:', response.status); console.error('[FB Tracker] Failed to mark post as checked:', response.status);
return null; return returnError
? { error: 'Beitrag konnte nicht bestätigt werden.', status: response.status }
: null;
} catch (error) { } catch (error) {
console.error('[FB Tracker] Error marking post as checked:', error); console.error('[FB Tracker] Error marking post as checked:', error);
return null; return (options && options.returnError)
? { error: 'Beitrag konnte nicht bestätigt werden.', status: 0 }
: null;
} }
} }
@@ -920,7 +1091,9 @@ async function addPostToTracking(postUrl, targetCount, profileNumber, options =
} }
let postText = null; let postText = null;
if (options && options.postElement) { if (options && typeof options.postText === 'string') {
postText = options.postText;
} else if (options && options.postElement) {
try { try {
postText = extractPostText(options.postElement) || null; postText = extractPostText(options.postElement) || null;
} catch (error) { } catch (error) {
@@ -967,6 +1140,13 @@ async function addPostToTracking(postUrl, targetCount, profileNumber, options =
payload.post_text = postText; payload.post_text = postText;
} }
if (options && typeof options.firstImageHash === 'string' && options.firstImageHash.trim()) {
payload.first_image_hash = options.firstImageHash.trim();
}
if (options && typeof options.firstImageUrl === 'string' && options.firstImageUrl.trim()) {
payload.first_image_url = options.firstImageUrl.trim();
}
const response = await backendFetch(`${API_URL}/posts`, { const response = await backendFetch(`${API_URL}/posts`, {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -1667,6 +1847,18 @@ async function captureElementScreenshot(element) {
const endY = Math.min(documentHeight, elementBottom + verticalMargin); const endY = Math.min(documentHeight, elementBottom + verticalMargin);
const baseDocTop = Math.max(0, elementTop - verticalMargin); const baseDocTop = Math.max(0, elementTop - verticalMargin);
const restoreScrollPosition = () => {
window.scrollTo({ top: originalScrollY, left: originalScrollX, behavior: 'auto' });
if (document.documentElement) {
document.documentElement.scrollTop = originalScrollY;
document.documentElement.scrollLeft = originalScrollX;
}
if (document.body) {
document.body.scrollTop = originalScrollY;
document.body.scrollLeft = originalScrollX;
}
};
try { try {
let iteration = 0; let iteration = 0;
let targetScroll = startY; let targetScroll = startY;
@@ -1723,7 +1915,9 @@ async function captureElementScreenshot(element) {
} }
} }
} finally { } finally {
window.scrollTo({ top: originalScrollY, left: originalScrollX, behavior: 'auto' }); restoreScrollPosition();
await delay(0);
restoreScrollPosition();
} }
if (!segments.length) { if (!segments.length) {
@@ -1845,6 +2039,125 @@ async function maybeDownscaleScreenshot(imageData) {
} }
} }
function isLikelyPostImage(img) {
if (!img) {
return false;
}
const src = img.currentSrc || img.src || '';
if (!src) {
return false;
}
if (src.startsWith('data:')) {
return false;
}
const lowerSrc = src.toLowerCase();
if (lowerSrc.includes('emoji') || lowerSrc.includes('static.xx') || lowerSrc.includes('sprite')) {
return false;
}
const width = img.naturalWidth || img.width || 0;
const height = img.naturalHeight || img.height || 0;
if (width < 120 || height < 120) {
return false;
}
return true;
}
function waitForImageLoad(img, timeoutMs = 1500) {
return new Promise((resolve) => {
if (!img) {
resolve(false);
return;
}
if (img.complete && img.naturalWidth > 0) {
resolve(true);
return;
}
let resolved = false;
const finish = (value) => {
if (resolved) return;
resolved = true;
resolve(value);
};
const timer = setTimeout(() => finish(false), timeoutMs);
img.addEventListener('load', () => {
clearTimeout(timer);
finish(true);
}, { once: true });
img.addEventListener('error', () => {
clearTimeout(timer);
finish(false);
}, { once: true });
});
}
function buildDHashFromPixels(imageData) {
if (!imageData || !imageData.data) {
return null;
}
const { data } = imageData;
const bits = [];
for (let y = 0; y < 8; y += 1) {
for (let x = 0; x < 8; x += 1) {
const leftIndex = ((y * 9) + x) * 4;
const rightIndex = ((y * 9) + x + 1) * 4;
const left = 0.299 * data[leftIndex] + 0.587 * data[leftIndex + 1] + 0.114 * data[leftIndex + 2];
const right = 0.299 * data[rightIndex] + 0.587 * data[rightIndex + 1] + 0.114 * data[rightIndex + 2];
bits.push(left > right ? 1 : 0);
}
}
let hex = '';
for (let i = 0; i < bits.length; i += 4) {
const value = (bits[i] << 3) | (bits[i + 1] << 2) | (bits[i + 2] << 1) | bits[i + 3];
hex += value.toString(16);
}
return hex.padStart(16, '0');
}
async function computeDHashFromUrl(imageUrl) {
if (!imageUrl) {
return null;
}
try {
const response = await fetch(imageUrl);
if (!response.ok) {
return null;
}
const blob = await response.blob();
const bitmap = await createImageBitmap(blob);
const canvas = document.createElement('canvas');
canvas.width = 9;
canvas.height = 8;
const ctx = canvas.getContext('2d');
if (!ctx) {
return null;
}
ctx.drawImage(bitmap, 0, 0, 9, 8);
const imageData = ctx.getImageData(0, 0, 9, 8);
return buildDHashFromPixels(imageData);
} catch (error) {
return null;
}
}
async function getFirstPostImageInfo(postElement) {
if (!postElement) {
return { hash: null, url: null };
}
const images = Array.from(postElement.querySelectorAll('img')).filter(isLikelyPostImage);
for (const img of images.slice(0, 5)) {
const loaded = await waitForImageLoad(img);
if (!loaded) {
continue;
}
const src = img.currentSrc || img.src;
const hash = await computeDHashFromUrl(src);
if (hash) {
return { hash, url: src };
}
}
return { hash: null, url: null };
}
function getStickyHeaderHeight() { function getStickyHeaderHeight() {
try { try {
const banner = document.querySelector('[role="banner"], header[role="banner"]'); const banner = document.querySelector('[role="banner"], header[role="banner"]');
@@ -1939,6 +2252,9 @@ function extractDeadlineFromPostText(postElement) {
const extractTimeAfterIndex = (text, index) => { const extractTimeAfterIndex = (text, index) => {
const tail = text.slice(index, index + 80); const tail = text.slice(index, index + 80);
if (/^\s*(?:-||—|bis)\s*\d{1,2}\.\d{1,2}\./i.test(tail)) {
return null;
}
const timeMatch = /^\s*(?:\(|\[)?\s*(?:[,;:-]|\b)?\s*(?:um|ab|bis|gegen|spätestens)?\s*(?:den|dem|am)?\s*(?:ca\.?)?\s*(\d{1,2})(?:[:.](\d{2}))?\s*(?:uhr|h)?\s*(?:\)|\])?/i.exec(tail); const timeMatch = /^\s*(?:\(|\[)?\s*(?:[,;:-]|\b)?\s*(?:um|ab|bis|gegen|spätestens)?\s*(?:den|dem|am)?\s*(?:ca\.?)?\s*(\d{1,2})(?:[:.](\d{2}))?\s*(?:uhr|h)?\s*(?:\)|\])?/i.exec(tail);
if (!timeMatch) { if (!timeMatch) {
return null; return null;
@@ -1950,6 +2266,9 @@ function extractDeadlineFromPostText(postElement) {
if (Number.isNaN(hour) || Number.isNaN(minute)) { if (Number.isNaN(hour) || Number.isNaN(minute)) {
return null; return null;
} }
if (hour === 24 && minute === 0) {
return { hour: 23, minute: 59 };
}
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) { if (hour < 0 || hour > 23 || minute < 0 || minute > 59) {
return null; return null;
} }
@@ -1963,6 +2282,22 @@ function extractDeadlineFromPostText(postElement) {
}; };
const foundDates = []; const foundDates = [];
const rangePattern = /\b(\d{1,2})\.(\d{1,2})\.(\d{2,4})\s*(?:-||—|bis)\s*(\d{1,2})\.(\d{1,2})\.(\d{2,4})\b/i;
const rangeMatch = rangePattern.exec(fullText);
if (rangeMatch) {
const endDay = parseInt(rangeMatch[4], 10);
const endMonth = parseInt(rangeMatch[5], 10);
let endYear = parseInt(rangeMatch[6], 10);
if (endYear < 100) {
endYear += 2000;
}
if (endMonth >= 1 && endMonth <= 12 && endDay >= 1 && endDay <= 31) {
const endDate = new Date(endYear, endMonth - 1, endDay, 0, 0, 0, 0);
if (endDate.getMonth() === endMonth - 1 && endDate.getDate() === endDay && endDate > today) {
return toDateTimeLocalString(endDate);
}
}
}
for (const pattern of patterns) { for (const pattern of patterns) {
let match; let match;
@@ -2404,7 +2739,19 @@ async function renderTrackedStatus({
: null; : null;
const isExpired = postData.deadline_at ? new Date(postData.deadline_at) < new Date() : false; const isExpired = postData.deadline_at ? new Date(postData.deadline_at) < new Date() : false;
const canCurrentProfileCheck = postData.next_required_profile === profileNumber; const requiredProfiles = Array.isArray(postData.required_profiles) && postData.required_profiles.length
? postData.required_profiles
.map((value) => {
const parsed = parseInt(value, 10);
if (Number.isNaN(parsed)) {
return null;
}
return Math.min(5, Math.max(1, parsed));
})
.filter(Boolean)
: Array.from({ length: Math.max(1, Math.min(5, parseInt(postData.target_count, 10) || 1)) }, (_, index) => index + 1);
const isCurrentProfileRequired = requiredProfiles.includes(profileNumber);
const canCurrentProfileCheck = isCurrentProfileRequired && postData.next_required_profile === profileNumber;
const isCurrentProfileDone = checks.some(check => check.profile_number === profileNumber); const isCurrentProfileDone = checks.some(check => check.profile_number === profileNumber);
if (isFeedHome && isCurrentProfileDone) { if (isFeedHome && isCurrentProfileDone) {
@@ -2434,16 +2781,23 @@ async function renderTrackedStatus({
${lastCheck ? `<div style="color: #65676b; font-size: 12px; white-space: nowrap;">Letzte: ${lastCheck}</div>` : ''} ${lastCheck ? `<div style="color: #65676b; font-size: 12px; white-space: nowrap;">Letzte: ${lastCheck}</div>` : ''}
`; `;
if (canCurrentProfileCheck && !isExpired && !completed) { if (!isExpired && !completed && !isCurrentProfileDone && isCurrentProfileRequired) {
const checkButtonEnabled = canCurrentProfileCheck;
const buttonColor = checkButtonEnabled ? '#42b72a' : '#f39c12';
const cursorStyle = 'pointer';
const buttonTitle = checkButtonEnabled
? 'Beitrag bestätigen'
: 'Wartet auf vorherige Profile';
statusHtml += ` statusHtml += `
<button class="fb-tracker-check-btn" style=" <button class="fb-tracker-check-btn" title="${buttonTitle}" style="
padding: 4px 12px; padding: 4px 12px;
background-color: #42b72a; background-color: ${buttonColor};
color: white; color: white;
border: none; border: none;
border-radius: 4px; border-radius: 4px;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: ${cursorStyle};
font-size: 13px; font-size: 13px;
white-space: nowrap; white-space: nowrap;
margin-left: 8px; margin-left: 8px;
@@ -2524,9 +2878,9 @@ async function renderTrackedStatus({
checkBtn.disabled = true; checkBtn.disabled = true;
checkBtn.textContent = 'Wird bestätigt...'; checkBtn.textContent = 'Wird bestätigt...';
const result = await markPostChecked(postData.id, profileNumber); const result = await markPostChecked(postData.id, profileNumber, { returnError: true });
if (result) { if (result && !result.error) {
await renderTrackedStatus({ await renderTrackedStatus({
container, container,
postElement, postElement,
@@ -2540,9 +2894,14 @@ async function renderTrackedStatus({
}); });
} else { } else {
checkBtn.disabled = false; checkBtn.disabled = false;
checkBtn.textContent = '✓ Bestätigen';
if (result && result.error) {
showToast(result.error, 'error');
} else {
checkBtn.textContent = 'Fehler - Erneut versuchen'; checkBtn.textContent = 'Fehler - Erneut versuchen';
checkBtn.style.backgroundColor = '#e74c3c'; checkBtn.style.backgroundColor = '#e74c3c';
} }
}
}); });
} }
@@ -2772,6 +3131,36 @@ async function createTrackerUI(postElement, buttonBar, postNum = '?', options =
font-size: 13px; font-size: 13px;
max-width: 160px; max-width: 160px;
" /> " />
<div class="fb-tracker-similarity" style="
display: none;
align-items: center;
gap: 8px;
padding: 4px 8px;
border: 1px solid #f0c36d;
background: #fff6d5;
border-radius: 6px;
font-size: 12px;
color: #6b5b00;
flex-basis: 100%;
">
<span class="fb-tracker-similarity__text"></span>
<button class="fb-tracker-merge-btn" type="button" style="
border: 1px solid #caa848;
background: white;
color: #7a5d00;
padding: 4px 8px;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
font-weight: 600;
">Mergen</button>
<a class="fb-tracker-similarity-link" href="#" target="_blank" rel="noopener" style="
color: #7a5d00;
text-decoration: none;
font-weight: 600;
display: none;
">Öffnen</a>
</div>
<button class="fb-tracker-add-btn" style=" <button class="fb-tracker-add-btn" style="
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white; color: white;
@@ -2792,8 +3181,109 @@ async function createTrackerUI(postElement, buttonBar, postNum = '?', options =
const selectElement = container.querySelector(`#${selectId}`); const selectElement = container.querySelector(`#${selectId}`);
const deadlineInput = container.querySelector(`#${deadlineId}`); const deadlineInput = container.querySelector(`#${deadlineId}`);
selectElement.value = '2'; selectElement.value = '2';
const similarityBox = container.querySelector('.fb-tracker-similarity');
const similarityText = container.querySelector('.fb-tracker-similarity__text');
const mergeButton = container.querySelector('.fb-tracker-merge-btn');
const similarityLink = container.querySelector('.fb-tracker-similarity-link');
const similarityPayloadPromise = buildSimilarityPayload(postElement);
let similarityPayload = null;
let similarityMatch = null;
const mainLinkUrl = postUrlData.mainUrl; const mainLinkUrl = postUrlData.mainUrl;
const resolveSimilarityPayload = async () => {
if (!similarityPayload) {
similarityPayload = await similarityPayloadPromise;
}
return similarityPayload;
};
if (similarityBox && similarityText && mergeButton) {
(async () => {
const payload = await resolveSimilarityPayload();
const similarity = await findSimilarPost({
url: postUrlData.url,
postText: payload.postText,
firstImageHash: payload.firstImageHash
});
if (!similarity || !similarity.match) {
return;
}
similarityMatch = similarity.match;
similarityText.textContent = formatSimilarityLabel(similarity);
similarityBox.style.display = 'flex';
if (!addButton.disabled) {
addButton.textContent = 'Neu speichern';
}
if (similarityLink) {
similarityLink.style.display = 'inline';
similarityLink.textContent = 'Öffnen';
if (similarityMatch.url) {
similarityLink.href = similarityMatch.url;
similarityLink.dataset.ready = '1';
} else {
similarityLink.href = '#';
similarityLink.dataset.ready = '0';
similarityLink.dataset.postId = similarityMatch.id;
}
}
})();
mergeButton.addEventListener('click', async () => {
if (!similarityMatch) {
return;
}
mergeButton.disabled = true;
const previousLabel = mergeButton.textContent;
mergeButton.textContent = 'Mergen...';
const payload = await resolveSimilarityPayload();
const urlCandidates = [postUrlData.url, ...postUrlData.allCandidates];
const uniqueUrls = Array.from(new Set(urlCandidates.filter(Boolean)));
const attached = await attachUrlToExistingPost(similarityMatch.id, uniqueUrls, payload);
if (attached) {
const updatedPost = await fetchPostByUrl(similarityMatch.url);
if (updatedPost) {
await renderTrackedStatus({
container,
postElement,
postData: updatedPost,
profileNumber,
isFeedHome,
isDialogContext,
manualHideInfo,
encodedUrl,
postNum
});
return;
}
}
mergeButton.disabled = false;
mergeButton.textContent = previousLabel;
});
}
if (similarityLink && !similarityLink.dataset.bound) {
similarityLink.dataset.bound = '1';
similarityLink.addEventListener('click', async (event) => {
if (similarityLink.dataset.ready === '1') {
return;
}
event.preventDefault();
const postId = similarityLink.dataset.postId;
if (!postId) {
return;
}
const resolved = await fetchPostById(postId);
if (resolved && resolved.url) {
similarityLink.href = resolved.url;
similarityLink.dataset.ready = '1';
window.open(resolved.url, '_blank', 'noopener');
}
});
}
if (mainLinkUrl) { if (mainLinkUrl) {
const mainLinkButton = document.createElement('button'); const mainLinkButton = document.createElement('button');
mainLinkButton.className = 'fb-tracker-mainlink-btn'; mainLinkButton.className = 'fb-tracker-mainlink-btn';
@@ -2838,11 +3328,15 @@ async function createTrackerUI(postElement, buttonBar, postNum = '?', options =
await delay(220); await delay(220);
const deadlineValue = deadlineInput ? deadlineInput.value : ''; const deadlineValue = deadlineInput ? deadlineInput.value : '';
const payload = await resolveSimilarityPayload();
const result = await addPostToTracking(postUrlData.url, targetCount, profileNumber, { const result = await addPostToTracking(postUrlData.url, targetCount, profileNumber, {
postElement, postElement,
deadline: deadlineValue, deadline: deadlineValue,
candidates: postUrlData.allCandidates candidates: postUrlData.allCandidates,
postText: payload.postText,
firstImageHash: payload.firstImageHash,
firstImageUrl: payload.firstImageUrl
}); });
if (result) { if (result) {
@@ -3236,6 +3730,10 @@ let globalPostCounter = 0;
// Find all Facebook posts on the page // Find all Facebook posts on the page
function findPosts() { function findPosts() {
if (maybeRedirectPageReelsToMain()) {
return;
}
console.log('[FB Tracker] Scanning for posts...'); console.log('[FB Tracker] Scanning for posts...');
const postContainers = findPostContainers(); const postContainers = findPostContainers();
@@ -3343,6 +3841,7 @@ function findPosts() {
// Initialize // Initialize
console.log('[FB Tracker] Initializing...'); console.log('[FB Tracker] Initializing...');
maybeRedirectPageReelsToMain();
// Run multiple times to catch loading posts // Run multiple times to catch loading posts
setTimeout(findPosts, 2000); setTimeout(findPosts, 2000);
@@ -4055,6 +4554,32 @@ function extractPostText(postElement) {
} }
}; };
const hasEmojiChars = (text) => /[\uD800-\uDBFF][\uDC00-\uDFFF]|[\u2600-\u27BF]/.test(text);
const injectEmojiLabels = (root) => {
if (!root || typeof root.querySelectorAll !== 'function') {
return;
}
const emojiNodes = root.querySelectorAll('img[alt], [role="img"][aria-label]');
emojiNodes.forEach((node) => {
const label = node.getAttribute('alt') || node.getAttribute('aria-label');
if (!label || !hasEmojiChars(label)) {
return;
}
const textNode = node.ownerDocument.createTextNode(label);
node.replaceWith(textNode);
});
};
const getTextWithEmojis = (element) => {
if (!element) {
return '';
}
const clone = element.cloneNode(true);
injectEmojiLabels(clone);
return clone.innerText || clone.textContent || '';
};
const SKIP_TEXT_CONTAINERS_SELECTOR = [ const SKIP_TEXT_CONTAINERS_SELECTOR = [
'div[role="textbox"]', 'div[role="textbox"]',
'[contenteditable="true"]', '[contenteditable="true"]',
@@ -4177,7 +4702,7 @@ function extractPostText(postElement) {
logPostText('Selector result skipped (inside disallowed region)', selector, makeSnippet(element.innerText || element.textContent || '')); logPostText('Selector result skipped (inside disallowed region)', selector, makeSnippet(element.innerText || element.textContent || ''));
continue; continue;
} }
tryAddCandidate(element.innerText || element.textContent || '', element, { selector }); tryAddCandidate(getTextWithEmojis(element), element, { selector });
} }
} }
@@ -4200,11 +4725,12 @@ function extractPostText(postElement) {
const clone = postElement.cloneNode(true); const clone = postElement.cloneNode(true);
const elementsToRemove = clone.querySelectorAll(SKIP_TEXT_CONTAINERS_SELECTOR); const elementsToRemove = clone.querySelectorAll(SKIP_TEXT_CONTAINERS_SELECTOR);
elementsToRemove.forEach((node) => node.remove()); elementsToRemove.forEach((node) => node.remove());
injectEmojiLabels(clone);
const cloneText = clone.innerText || clone.textContent || ''; const cloneText = clone.innerText || clone.textContent || '';
fallbackText = cleanCandidate(cloneText); fallbackText = cleanCandidate(cloneText);
logPostText('Fallback extracted from clone', makeSnippet(fallbackText || cloneText)); logPostText('Fallback extracted from clone', makeSnippet(fallbackText || cloneText));
} catch (error) { } catch (error) {
const allText = postElement.innerText || postElement.textContent || ''; const allText = getTextWithEmojis(postElement);
fallbackText = cleanCandidate(allText); fallbackText = cleanCandidate(allText);
logPostText('Fallback extracted from original element', makeSnippet(fallbackText || allText)); logPostText('Fallback extracted from original element', makeSnippet(fallbackText || allText));
} }
@@ -5071,19 +5597,19 @@ async function addAICommentButton(container, postElement) {
let postId = container.dataset.postId || ''; let postId = container.dataset.postId || '';
if (postId) { if (postId) {
latestData = await markPostChecked(postId, effectiveProfile); latestData = await markPostChecked(postId, effectiveProfile, { ignoreOrder: true });
if (!latestData && decodedUrl) { if (!latestData && decodedUrl) {
const refreshed = await checkPostStatus(decodedUrl); const refreshed = await checkPostStatus(decodedUrl);
if (refreshed && refreshed.id) { if (refreshed && refreshed.id) {
container.dataset.postId = refreshed.id; container.dataset.postId = refreshed.id;
latestData = await markPostChecked(refreshed.id, effectiveProfile) || refreshed; latestData = await markPostChecked(refreshed.id, effectiveProfile, { ignoreOrder: true }) || refreshed;
} }
} }
} else if (decodedUrl) { } else if (decodedUrl) {
const refreshed = await checkPostStatus(decodedUrl); const refreshed = await checkPostStatus(decodedUrl);
if (refreshed && refreshed.id) { if (refreshed && refreshed.id) {
container.dataset.postId = refreshed.id; container.dataset.postId = refreshed.id;
latestData = await markPostChecked(refreshed.id, effectiveProfile) || refreshed; latestData = await markPostChecked(refreshed.id, effectiveProfile, { ignoreOrder: true }) || refreshed;
} }
} }

View File

@@ -300,6 +300,14 @@ const includeExpiredToggle = document.getElementById('includeExpiredToggle');
const mergeControls = document.getElementById('mergeControls'); const mergeControls = document.getElementById('mergeControls');
const mergeModeToggle = document.getElementById('mergeModeToggle'); const mergeModeToggle = document.getElementById('mergeModeToggle');
const mergeSubmitBtn = document.getElementById('mergeSubmitBtn'); const mergeSubmitBtn = document.getElementById('mergeSubmitBtn');
const pendingBulkControls = document.getElementById('pendingBulkControls');
const pendingBulkCountSelect = document.getElementById('pendingBulkCountSelect');
const pendingBulkOpenBtn = document.getElementById('pendingBulkOpenBtn');
const pendingAutoOpenToggle = document.getElementById('pendingAutoOpenToggle');
const pendingAutoOpenOverlay = document.getElementById('pendingAutoOpenOverlay');
const pendingAutoOpenOverlayPanel = document.getElementById('pendingAutoOpenOverlayPanel');
const pendingAutoOpenCountdown = document.getElementById('pendingAutoOpenCountdown');
const pendingBulkStatus = document.getElementById('pendingBulkStatus');
const REFRESH_SETTINGS_KEY = 'trackerRefreshSettings'; const REFRESH_SETTINGS_KEY = 'trackerRefreshSettings';
const SORT_SETTINGS_KEY = 'trackerSortSettings'; const SORT_SETTINGS_KEY = 'trackerSortSettings';
@@ -313,6 +321,12 @@ const BOOKMARK_WINDOW_DAYS = 28;
const BOOKMARK_SUFFIXES = ['Gewinnspiel', 'gewinnen', 'verlosen']; const BOOKMARK_SUFFIXES = ['Gewinnspiel', 'gewinnen', 'verlosen'];
const BOOKMARK_PREFS_KEY = 'trackerBookmarkPreferences'; const BOOKMARK_PREFS_KEY = 'trackerBookmarkPreferences';
const INCLUDE_EXPIRED_STORAGE_KEY = 'trackerIncludeExpired'; const INCLUDE_EXPIRED_STORAGE_KEY = 'trackerIncludeExpired';
const PENDING_BULK_COUNT_STORAGE_KEY = 'trackerPendingBulkCount';
const PENDING_AUTO_OPEN_STORAGE_KEY = 'trackerPendingAutoOpen';
const DEFAULT_PENDING_BULK_COUNT = 5;
const PENDING_AUTO_OPEN_DELAY_MS = 1500;
const PENDING_OPEN_COOLDOWN_STORAGE_KEY = 'trackerPendingOpenCooldown';
const PENDING_OPEN_COOLDOWN_MS = 40 * 60 * 1000;
function loadIncludeExpiredPreference() { function loadIncludeExpiredPreference() {
try { try {
@@ -337,6 +351,110 @@ function persistIncludeExpiredPreference(value) {
} }
} }
function getPendingOpenCooldownStorageKey(profileNumber = currentProfile) {
const safeProfile = profileNumber || currentProfile || 1;
return `${PENDING_OPEN_COOLDOWN_STORAGE_KEY}:${safeProfile}`;
}
function loadPendingOpenCooldownMap(profileNumber = currentProfile) {
const storageKey = getPendingOpenCooldownStorageKey(profileNumber);
try {
const raw = localStorage.getItem(storageKey);
if (raw) {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object') {
const now = Date.now();
const cleaned = {};
Object.entries(parsed).forEach(([id, timestamp]) => {
const value = Number(timestamp);
if (Number.isFinite(value) && now - value < PENDING_OPEN_COOLDOWN_MS) {
cleaned[id] = value;
}
});
if (Object.keys(cleaned).length !== Object.keys(parsed).length) {
localStorage.setItem(storageKey, JSON.stringify(cleaned));
}
return cleaned;
}
}
} catch (error) {
console.warn('Konnte Cooldown-Daten für offene Beiträge nicht laden:', error);
}
return {};
}
function persistPendingOpenCooldownMap(profileNumber, map) {
const storageKey = getPendingOpenCooldownStorageKey(profileNumber);
try {
localStorage.setItem(storageKey, JSON.stringify(map || {}));
} catch (error) {
console.warn('Konnte Cooldown-Daten für offene Beiträge nicht speichern:', error);
}
}
function isPendingOpenCooldownActive(postId) {
if (!postId) {
return false;
}
const timestamp = pendingOpenCooldownMap[postId];
if (!timestamp) {
return false;
}
const elapsed = Date.now() - timestamp;
if (elapsed < PENDING_OPEN_COOLDOWN_MS) {
return true;
}
delete pendingOpenCooldownMap[postId];
persistPendingOpenCooldownMap(currentProfile, pendingOpenCooldownMap);
return false;
}
function recordPendingOpen(postId) {
if (!postId) {
return;
}
pendingOpenCooldownMap[postId] = Date.now();
persistPendingOpenCooldownMap(currentProfile, pendingOpenCooldownMap);
}
function loadPendingBulkCount() {
try {
const stored = localStorage.getItem(PENDING_BULK_COUNT_STORAGE_KEY);
const value = parseInt(stored, 10);
if (!Number.isNaN(value) && [1, 5, 10, 15, 20].includes(value)) {
return value;
}
} catch (error) {
console.warn('Konnte Bulk-Anzahl für offene Beiträge nicht laden:', error);
}
return DEFAULT_PENDING_BULK_COUNT;
}
function persistPendingBulkCount(value) {
try {
localStorage.setItem(PENDING_BULK_COUNT_STORAGE_KEY, String(value));
} catch (error) {
console.warn('Konnte Bulk-Anzahl für offene Beiträge nicht speichern:', error);
}
}
function loadPendingAutoOpenEnabled() {
try {
return localStorage.getItem(PENDING_AUTO_OPEN_STORAGE_KEY) === '1';
} catch (error) {
console.warn('Konnte Auto-Öffnen-Status nicht laden:', error);
return false;
}
}
function persistPendingAutoOpenEnabled(enabled) {
try {
localStorage.setItem(PENDING_AUTO_OPEN_STORAGE_KEY, enabled ? '1' : '0');
} catch (error) {
console.warn('Konnte Auto-Öffnen-Status nicht speichern:', error);
}
}
function updateIncludeExpiredToggleUI() { function updateIncludeExpiredToggleUI() {
if (!includeExpiredToggle) { if (!includeExpiredToggle) {
return; return;
@@ -345,6 +463,13 @@ function updateIncludeExpiredToggleUI() {
} }
includeExpiredPosts = loadIncludeExpiredPreference(); includeExpiredPosts = loadIncludeExpiredPreference();
let pendingBulkCount = loadPendingBulkCount();
let pendingAutoOpenEnabled = loadPendingAutoOpenEnabled();
let pendingAutoOpenTriggered = false;
let pendingAutoOpenTimerId = null;
let pendingAutoOpenCountdownIntervalId = null;
let pendingProcessingBatch = false;
let pendingOpenCooldownMap = loadPendingOpenCooldownMap(currentProfile);
function updateIncludeExpiredToggleVisibility() { function updateIncludeExpiredToggleVisibility() {
if (!includeExpiredToggle) { if (!includeExpiredToggle) {
@@ -388,6 +513,26 @@ function updateMergeControlsUI() {
} }
} }
function updatePendingBulkControls(filteredCount = 0) {
if (!pendingBulkControls) {
return;
}
const isPendingTab = currentTab === 'pending';
pendingBulkControls.hidden = !isPendingTab;
pendingBulkControls.style.display = isPendingTab ? 'flex' : 'none';
if (pendingBulkOpenBtn) {
pendingBulkOpenBtn.disabled = !isPendingTab || pendingProcessingBatch || filteredCount === 0;
}
}
function setPendingBulkStatus(message = '', isError = false) {
if (!pendingBulkStatus) {
return;
}
pendingBulkStatus.textContent = message || '';
pendingBulkStatus.classList.toggle('bulk-status--error', !!isError);
}
function initializeFocusParams() { function initializeFocusParams() {
try { try {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
@@ -1635,6 +1780,16 @@ function updateSortDirectionToggleUI() {
} }
} }
function getDefaultSortDirectionForMode(mode) {
if (mode === 'deadline') {
return 'asc';
}
if (mode === 'smart') {
return 'desc';
}
return null;
}
function normalizeRequiredProfiles(post) { function normalizeRequiredProfiles(post) {
if (Array.isArray(post.required_profiles) && post.required_profiles.length) { if (Array.isArray(post.required_profiles) && post.required_profiles.length) {
return post.required_profiles return post.required_profiles
@@ -1714,6 +1869,224 @@ function updateFilteredCount(tab, count) {
tabFilteredCounts[key] = Math.max(0, count || 0); tabFilteredCounts[key] = Math.max(0, count || 0);
} }
function getPostListState() {
const postItems = posts.map((post) => ({
post,
status: computePostStatus(post)
}));
const sortedItems = [...postItems].sort(comparePostItems);
let filteredItems = sortedItems;
if (currentTab === 'pending') {
filteredItems = sortedItems.filter((item) => !item.status.isExpired && item.status.canCurrentProfileCheck && !item.status.isComplete);
} else {
filteredItems = includeExpiredPosts
? sortedItems
: sortedItems.filter((item) => !item.status.isExpired && !item.status.isComplete);
}
const tabTotalCount = filteredItems.length;
const searchInput = document.getElementById('searchInput');
const searchValue = searchInput && typeof searchInput.value === 'string' ? searchInput.value.trim() : '';
const searchActive = Boolean(searchValue);
if (searchActive) {
const searchTerm = searchValue.toLowerCase();
filteredItems = filteredItems.filter((item) => {
const post = item.post;
return (
(post.title && post.title.toLowerCase().includes(searchTerm)) ||
(post.url && post.url.toLowerCase().includes(searchTerm)) ||
(post.created_by_name && post.created_by_name.toLowerCase().includes(searchTerm)) ||
(post.id && post.id.toLowerCase().includes(searchTerm))
);
});
}
return {
sortedItems,
filteredItems,
tabTotalCount,
searchActive,
searchValue
};
}
function clearPendingAutoOpenCountdown() {
if (pendingAutoOpenCountdownIntervalId) {
clearInterval(pendingAutoOpenCountdownIntervalId);
pendingAutoOpenCountdownIntervalId = null;
}
}
function updatePendingAutoOpenCountdownLabel(remainingMs) {
if (!pendingAutoOpenCountdown) {
return;
}
const safeMs = Math.max(0, remainingMs);
const seconds = safeMs / 1000;
const formatted = seconds >= 10 ? seconds.toFixed(0) : seconds.toFixed(1);
pendingAutoOpenCountdown.textContent = formatted;
}
function hidePendingAutoOpenOverlay() {
clearPendingAutoOpenCountdown();
if (pendingAutoOpenOverlay) {
pendingAutoOpenOverlay.classList.remove('visible');
pendingAutoOpenOverlay.hidden = true;
}
}
function showPendingAutoOpenOverlay(delayMs) {
if (!pendingAutoOpenOverlay) {
return;
}
const duration = Math.max(0, delayMs);
hidePendingAutoOpenOverlay();
pendingAutoOpenOverlay.hidden = false;
requestAnimationFrame(() => pendingAutoOpenOverlay.classList.add('visible'));
updatePendingAutoOpenCountdownLabel(duration);
const start = Date.now();
pendingAutoOpenCountdownIntervalId = setInterval(() => {
const remaining = Math.max(0, duration - (Date.now() - start));
updatePendingAutoOpenCountdownLabel(remaining);
if (remaining <= 0) {
clearPendingAutoOpenCountdown();
}
}, 100);
}
function cancelPendingAutoOpen(showMessage = false) {
if (pendingAutoOpenTimerId) {
clearTimeout(pendingAutoOpenTimerId);
pendingAutoOpenTimerId = null;
}
pendingAutoOpenTriggered = false;
hidePendingAutoOpenOverlay();
if (showMessage) {
setPendingBulkStatus('Automatisches Öffnen abgebrochen.', false);
}
}
function getPendingVisibleCandidates() {
if (currentTab !== 'pending') {
return { items: [], totalVisible: 0, cooldownBlocked: 0 };
}
const { filteredItems } = getPostListState();
const visibleCount = Math.min(filteredItems.length, getVisibleCount(currentTab));
const visibleItems = filteredItems
.slice(0, visibleCount)
.filter(({ post }) => post && post.url);
const items = visibleItems.filter(({ post }) => !isPendingOpenCooldownActive(post.id));
const cooldownBlocked = Math.max(0, visibleItems.length - items.length);
return { items, totalVisible: visibleItems.length, cooldownBlocked };
}
function openPendingBatch({ auto = false } = {}) {
if (pendingProcessingBatch) {
return;
}
if (!auto) {
cancelPendingAutoOpen(false);
}
const { items: candidates, totalVisible, cooldownBlocked } = getPendingVisibleCandidates();
if (!candidates.length) {
if (!auto) {
if (totalVisible === 0) {
setPendingBulkStatus('Keine offenen Beiträge zum Öffnen.', true);
} else if (cooldownBlocked > 0) {
setPendingBulkStatus('Alle sichtbaren Beiträge sind noch im Cooldown (40 min).', true);
} else {
setPendingBulkStatus('Keine offenen Beiträge zum Öffnen.', true);
}
}
pendingAutoOpenTriggered = false;
return;
}
const count = pendingBulkCount || DEFAULT_PENDING_BULK_COUNT;
const selection = candidates.slice(0, count);
pendingProcessingBatch = true;
if (pendingBulkOpenBtn) {
pendingBulkOpenBtn.disabled = true;
}
if (!auto) {
setPendingBulkStatus('');
} else {
setPendingBulkStatus(`Öffne automatisch ${selection.length} Links...`, false);
}
selection.forEach(({ post }) => {
if (post && post.url) {
window.open(post.url, '_blank', 'noopener');
recordPendingOpen(post.id);
}
});
pendingProcessingBatch = false;
if (pendingBulkOpenBtn) {
pendingBulkOpenBtn.disabled = false;
}
if (auto) {
setPendingBulkStatus('');
pendingAutoOpenTriggered = false;
}
}
function maybeAutoOpenPending(reason = '', delayMs = PENDING_AUTO_OPEN_DELAY_MS) {
if (!isPostsViewActive()) {
hidePendingAutoOpenOverlay();
return;
}
if (currentTab !== 'pending') {
hidePendingAutoOpenOverlay();
return;
}
if (!pendingAutoOpenEnabled) {
hidePendingAutoOpenOverlay();
return;
}
if (pendingProcessingBatch) {
return;
}
if (pendingAutoOpenTriggered) {
return;
}
const { items: candidates } = getPendingVisibleCandidates();
if (!candidates.length) {
hidePendingAutoOpenOverlay();
return;
}
if (pendingAutoOpenTimerId) {
clearTimeout(pendingAutoOpenTimerId);
pendingAutoOpenTimerId = null;
}
hidePendingAutoOpenOverlay();
pendingAutoOpenTriggered = true;
const delay = typeof delayMs === 'number' ? Math.max(0, delayMs) : PENDING_AUTO_OPEN_DELAY_MS;
if (delay === 0) {
if (pendingAutoOpenEnabled) {
openPendingBatch({ auto: true });
} else {
pendingAutoOpenTriggered = false;
}
return;
}
showPendingAutoOpenOverlay(delay);
pendingAutoOpenTimerId = setTimeout(() => {
pendingAutoOpenTimerId = null;
hidePendingAutoOpenOverlay();
if (pendingAutoOpenEnabled) {
openPendingBatch({ auto: true });
} else {
pendingAutoOpenTriggered = false;
}
}, delay);
}
function cleanupLoadMoreObserver() { function cleanupLoadMoreObserver() {
if (loadMoreObserver && observedLoadMoreElement) { if (loadMoreObserver && observedLoadMoreElement) {
loadMoreObserver.unobserve(observedLoadMoreElement); loadMoreObserver.unobserve(observedLoadMoreElement);
@@ -1828,6 +2201,7 @@ function setTab(tab, { updateUrl = true } = {}) {
updateTabInUrl(); updateTabInUrl();
} }
renderPosts(); renderPosts();
maybeAutoOpenPending('tab');
} }
function initializeTabFromUrl() { function initializeTabFromUrl() {
@@ -3011,7 +3385,9 @@ function applyProfileNumber(profileNumber, { fromBackend = false } = {}) {
} }
resetVisibleCount(); resetVisibleCount();
pendingOpenCooldownMap = loadPendingOpenCooldownMap(currentProfile);
renderPosts(); renderPosts();
maybeAutoOpenPending('profile');
} }
// Load profile from localStorage // Load profile from localStorage
@@ -3066,6 +3442,42 @@ if (includeExpiredToggle) {
}); });
} }
if (pendingBulkCountSelect) {
pendingBulkCountSelect.value = String(pendingBulkCount);
pendingBulkCountSelect.addEventListener('change', () => {
const value = parseInt(pendingBulkCountSelect.value, 10);
if (!Number.isNaN(value)) {
pendingBulkCount = value;
persistPendingBulkCount(value);
}
});
}
if (pendingBulkOpenBtn) {
pendingBulkOpenBtn.addEventListener('click', () => openPendingBatch());
}
if (pendingAutoOpenOverlayPanel) {
pendingAutoOpenOverlayPanel.addEventListener('click', () => cancelPendingAutoOpen(true));
}
if (pendingAutoOpenToggle) {
pendingAutoOpenToggle.checked = !!pendingAutoOpenEnabled;
pendingAutoOpenToggle.addEventListener('change', () => {
pendingAutoOpenEnabled = pendingAutoOpenToggle.checked;
persistPendingAutoOpenEnabled(pendingAutoOpenEnabled);
pendingAutoOpenTriggered = false;
if (!pendingAutoOpenEnabled && pendingAutoOpenTimerId) {
cancelPendingAutoOpen(false);
}
if (pendingAutoOpenEnabled) {
maybeAutoOpenPending('toggle');
} else {
hidePendingAutoOpenOverlay();
}
});
}
// Tab switching // Tab switching
document.querySelectorAll('.tab-btn').forEach(btn => { document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
@@ -3164,6 +3576,11 @@ if (sortModeSelect) {
sortModeSelect.addEventListener('change', () => { sortModeSelect.addEventListener('change', () => {
const value = sortModeSelect.value; const value = sortModeSelect.value;
sortMode = VALID_SORT_MODES.has(value) ? value : DEFAULT_SORT_SETTINGS.mode; sortMode = VALID_SORT_MODES.has(value) ? value : DEFAULT_SORT_SETTINGS.mode;
const defaultDirection = getDefaultSortDirectionForMode(sortMode);
if (defaultDirection) {
sortDirection = defaultDirection;
updateSortDirectionToggleUI();
}
saveSortMode(); saveSortMode();
resetVisibleCount(); resetVisibleCount();
renderPosts(); renderPosts();
@@ -3201,6 +3618,7 @@ async function fetchPosts({ showLoader = true } = {}) {
} }
isFetchingPosts = true; isFetchingPosts = true;
cancelPendingAutoOpen(false);
try { try {
if (showLoader) { if (showLoader) {
@@ -3218,6 +3636,7 @@ async function fetchPosts({ showLoader = true } = {}) {
await normalizeLoadedPostUrls(); await normalizeLoadedPostUrls();
sortPostsByCreatedAt(); sortPostsByCreatedAt();
renderPosts(); renderPosts();
maybeAutoOpenPending('load');
} catch (error) { } catch (error) {
if (showLoader) { if (showLoader) {
showError('Fehler beim Laden der Beiträge. Stelle sicher, dass das Backend läuft.'); showError('Fehler beim Laden der Beiträge. Stelle sicher, dass das Backend läuft.');
@@ -3487,45 +3906,18 @@ function renderPosts() {
updateTabButtons(); updateTabButtons();
cleanupLoadMoreObserver(); cleanupLoadMoreObserver();
const postItems = posts.map((post) => ({ const {
post, sortedItems,
status: computePostStatus(post) filteredItems: filteredItemsResult,
})); tabTotalCount,
searchActive
} = getPostListState();
const sortedItems = [...postItems].sort(comparePostItems); let filteredItems = filteredItemsResult;
const focusCandidateEntry = (!focusHandled && (focusPostIdParam || focusNormalizedUrl)) const focusCandidateEntry = (!focusHandled && (focusPostIdParam || focusNormalizedUrl))
? sortedItems.find((item) => doesPostMatchFocus(item.post)) ? sortedItems.find((item) => doesPostMatchFocus(item.post))
: null; : null;
let filteredItems = sortedItems;
if (currentTab === 'pending') {
filteredItems = sortedItems.filter((item) => !item.status.isExpired && item.status.canCurrentProfileCheck && !item.status.isComplete);
} else {
filteredItems = includeExpiredPosts
? sortedItems
: sortedItems.filter((item) => !item.status.isExpired && !item.status.isComplete);
}
const tabTotalCount = filteredItems.length;
const searchInput = document.getElementById('searchInput');
const searchValue = searchInput && typeof searchInput.value === 'string' ? searchInput.value.trim() : '';
const searchActive = Boolean(searchValue);
if (searchActive) {
const searchTerm = searchValue.toLowerCase();
filteredItems = filteredItems.filter((item) => {
const post = item.post;
return (
(post.title && post.title.toLowerCase().includes(searchTerm)) ||
(post.url && post.url.toLowerCase().includes(searchTerm)) ||
(post.created_by_name && post.created_by_name.toLowerCase().includes(searchTerm)) ||
(post.id && post.id.toLowerCase().includes(searchTerm))
);
});
}
if (!focusHandled && focusCandidateEntry && !searchActive) { if (!focusHandled && focusCandidateEntry && !searchActive) {
const candidateVisibleInCurrentTab = filteredItems.some(({ post }) => doesPostMatchFocus(post)); const candidateVisibleInCurrentTab = filteredItems.some(({ post }) => doesPostMatchFocus(post));
if (!candidateVisibleInCurrentTab) { if (!candidateVisibleInCurrentTab) {
@@ -3554,6 +3946,7 @@ function renderPosts() {
} }
updateFilteredCount(currentTab, filteredItems.length); updateFilteredCount(currentTab, filteredItems.length);
updatePendingBulkControls(filteredItems.length);
const visibleCount = Math.min(filteredItems.length, getVisibleCount(currentTab)); const visibleCount = Math.min(filteredItems.length, getVisibleCount(currentTab));
const visibleItems = filteredItems.slice(0, visibleCount); const visibleItems = filteredItems.slice(0, visibleCount);
@@ -4597,6 +4990,26 @@ window.addEventListener('resize', () => {
} }
}); });
window.addEventListener('app:view-change', (event) => {
const view = event && event.detail ? event.detail.view : null;
if (view === 'posts') {
maybeAutoOpenPending('view');
} else {
cancelPendingAutoOpen(false);
}
});
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && pendingAutoOpenEnabled) {
if (pendingAutoOpenTimerId) {
clearTimeout(pendingAutoOpenTimerId);
pendingAutoOpenTimerId = null;
}
pendingAutoOpenTriggered = false;
maybeAutoOpenPending('visibility', PENDING_AUTO_OPEN_DELAY_MS);
}
});
// Initialize // Initialize
async function bootstrapApp() { async function bootstrapApp() {
const authenticated = await ensureAuthenticated(); const authenticated = await ensureAuthenticated();

View File

@@ -178,11 +178,43 @@
</label> </label>
<input type="text" id="searchInput" class="search-input" placeholder="Beiträge durchsuchen..."> <input type="text" id="searchInput" class="search-input" placeholder="Beiträge durchsuchen...">
</div> </div>
<div class="posts-bulk-controls" id="pendingBulkControls" hidden>
<div class="bulk-actions">
<label for="pendingBulkCountSelect">Anzahl</label>
<select id="pendingBulkCountSelect">
<option value="1">1</option>
<option value="5">5</option>
<option value="10">10</option>
<option value="15">15</option>
<option value="20">20</option>
</select>
<label class="auto-open-toggle">
<input type="checkbox" id="pendingAutoOpenToggle">
<span>Auto öffnen</span>
</label>
<button type="button" class="btn btn-secondary" id="pendingBulkOpenBtn">Links öffnen</button>
</div>
<div id="pendingBulkStatus" class="bulk-status" role="status" aria-live="polite"></div>
</div>
</div> </div>
<div id="loading" class="loading">Lade Beiträge...</div> <div id="loading" class="loading">Lade Beiträge...</div>
<div id="error" class="error" style="display: none;"></div> <div id="error" class="error" style="display: none;"></div>
<div id="pendingAutoOpenOverlay" class="auto-open-overlay" hidden>
<div class="auto-open-overlay__panel" id="pendingAutoOpenOverlayPanel">
<div class="auto-open-overlay__badge">Auto-Öffnen startet gleich</div>
<div class="auto-open-overlay__timer">
<span id="pendingAutoOpenCountdown" class="auto-open-overlay__count">0.0</span>
<span class="auto-open-overlay__unit">Sek.</span>
</div>
<p class="auto-open-overlay__text">
Die nächsten offenen Beiträge werden automatisch geöffnet. Abbrechen, falls du noch warten willst.
</p>
<p class="auto-open-overlay__hint">Klicke irgendwo in dieses Panel, um abzubrechen.</p>
</div>
</div>
<div id="postsContainer" class="posts-container"></div> <div id="postsContainer" class="posts-container"></div>
</div> </div>
@@ -1181,6 +1213,36 @@
</form> </form>
</section> </section>
<!-- Similarity settings -->
<section class="settings-section">
<h2 class="section-title">Ähnlichkeits-Erkennung</h2>
<p class="section-description">
Steuert, ab wann Posts als ähnlich gelten (Text-Ähnlichkeit oder Bild-Ähnlichkeit).
</p>
<form id="similaritySettingsForm">
<div class="form-group">
<label for="similarityTextThreshold" class="form-label">Text-Ähnlichkeit (0.500.99)</label>
<input type="number" id="similarityTextThreshold" class="form-input" min="0.5" max="0.99" step="0.01" value="0.85">
<p class="form-help">
Je höher der Wert, desto strenger wird Text-Ähnlichkeit bewertet.
</p>
</div>
<div class="form-group">
<label for="similarityImageThreshold" class="form-label">Bild-Distanz (064)</label>
<input type="number" id="similarityImageThreshold" class="form-input" min="0" max="64" step="1" value="6">
<p class="form-help">
Kleiner Wert = strenger (0 = identischer Hash, 64 = komplett unterschiedlich).
</p>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Ähnlichkeit speichern</button>
</div>
</form>
</section>
<!-- Hidden posts / purge settings --> <!-- Hidden posts / purge settings -->
<section class="settings-section"> <section class="settings-section">
<h2 class="section-title">Versteckte Beiträge bereinigen</h2> <h2 class="section-title">Versteckte Beiträge bereinigen</h2>

View File

@@ -72,6 +72,10 @@ let moderationSettings = {
sports_terms: {}, sports_terms: {},
sports_auto_hide_enabled: false sports_auto_hide_enabled: false
}; };
let similaritySettings = {
text_threshold: 0.85,
image_distance_threshold: 6
};
function handleUnauthorized(response) { function handleUnauthorized(response) {
if (response && response.status === 401) { if (response && response.status === 401) {
@@ -405,6 +409,84 @@ async function saveModerationSettings(event, { silent = false } = {}) {
} }
} }
function normalizeSimilarityTextThresholdInput(value) {
const parsed = parseFloat(value);
if (Number.isNaN(parsed)) {
return 0.85;
}
return Math.min(0.99, Math.max(0.5, parsed));
}
function normalizeSimilarityImageThresholdInput(value) {
const parsed = parseInt(value, 10);
if (Number.isNaN(parsed)) {
return 6;
}
return Math.min(64, Math.max(0, parsed));
}
function applySimilaritySettingsUI() {
const textInput = document.getElementById('similarityTextThreshold');
const imageInput = document.getElementById('similarityImageThreshold');
if (textInput) {
textInput.value = similaritySettings.text_threshold ?? 0.85;
}
if (imageInput) {
imageInput.value = similaritySettings.image_distance_threshold ?? 6;
}
}
async function loadSimilaritySettings() {
const res = await apiFetch(`${API_URL}/similarity-settings`);
if (!res.ok) throw new Error('Konnte Ähnlichkeits-Einstellungen nicht laden');
const data = await res.json();
similaritySettings = {
text_threshold: normalizeSimilarityTextThresholdInput(data.text_threshold),
image_distance_threshold: normalizeSimilarityImageThresholdInput(data.image_distance_threshold)
};
applySimilaritySettingsUI();
}
async function saveSimilaritySettings(event, { silent = false } = {}) {
if (event && typeof event.preventDefault === 'function') {
event.preventDefault();
}
const textInput = document.getElementById('similarityTextThreshold');
const imageInput = document.getElementById('similarityImageThreshold');
const textThreshold = textInput
? normalizeSimilarityTextThresholdInput(textInput.value)
: similaritySettings.text_threshold;
const imageThreshold = imageInput
? normalizeSimilarityImageThresholdInput(imageInput.value)
: similaritySettings.image_distance_threshold;
try {
const res = await apiFetch(`${API_URL}/similarity-settings`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text_threshold: textThreshold,
image_distance_threshold: imageThreshold
})
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || 'Fehler beim Speichern');
}
similaritySettings = await res.json();
applySimilaritySettingsUI();
if (!silent) {
showSuccess('✅ Ähnlichkeitsregeln gespeichert');
}
return true;
} catch (err) {
if (!silent) {
showError('❌ ' + err.message);
}
return false;
}
}
function shorten(text, maxLength = 80) { function shorten(text, maxLength = 80) {
if (typeof text !== 'string') { if (typeof text !== 'string') {
return ''; return '';
@@ -1041,6 +1123,7 @@ async function saveAllSettings(event) {
saveSettings(null, { silent: true }), saveSettings(null, { silent: true }),
saveHiddenSettings(null, { silent: true }), saveHiddenSettings(null, { silent: true }),
saveModerationSettings(null, { silent: true }), saveModerationSettings(null, { silent: true }),
saveSimilaritySettings(null, { silent: true }),
saveAllFriends({ silent: true }) saveAllFriends({ silent: true })
]); ]);
@@ -1208,12 +1291,30 @@ if (sportsScoringToggle && sportsScoreInput) {
} }
} }
const similarityForm = document.getElementById('similaritySettingsForm');
if (similarityForm) {
const textInput = document.getElementById('similarityTextThreshold');
const imageInput = document.getElementById('similarityImageThreshold');
if (textInput) {
textInput.addEventListener('blur', () => {
textInput.value = normalizeSimilarityTextThresholdInput(textInput.value);
});
}
if (imageInput) {
imageInput.addEventListener('blur', () => {
imageInput.value = normalizeSimilarityImageThresholdInput(imageInput.value);
});
}
similarityForm.addEventListener('submit', (e) => saveSimilaritySettings(e));
}
// Initialize // Initialize
Promise.all([ Promise.all([
loadCredentials(), loadCredentials(),
loadSettings(), loadSettings(),
loadHiddenSettings(), loadHiddenSettings(),
loadModerationSettings(), loadModerationSettings(),
loadSimilaritySettings(),
loadProfileFriends() loadProfileFriends()
]).catch(err => showError(err.message)); ]).catch(err => showError(err.message));
})(); })();

View File

@@ -589,6 +589,141 @@ h1 {
margin: 0 4px; margin: 0 4px;
} }
.posts-bulk-controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
justify-content: space-between;
margin-top: 12px;
}
.bulk-actions {
display: inline-flex;
align-items: center;
gap: 8px;
background: #f8fafc;
border: 1px solid #e5e7eb;
padding: 8px 10px;
border-radius: 12px;
}
.bulk-actions label {
color: #6b7280;
font-size: 13px;
}
.bulk-actions select {
background: #ffffff;
border: 1px solid #e5e7eb;
color: #111827;
border-radius: 10px;
padding: 8px 10px;
}
.auto-open-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #6b7280;
}
.auto-open-toggle input {
width: 16px;
height: 16px;
}
.bulk-status {
font-size: 13px;
color: #6b7280;
}
.bulk-status--error {
color: #dc2626;
}
.auto-open-overlay {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background:
radial-gradient(circle at 20% 20%, rgba(37, 99, 235, 0.12), transparent 42%),
radial-gradient(circle at 80% 30%, rgba(6, 182, 212, 0.12), transparent 38%),
rgba(15, 23, 42, 0.6);
z-index: 30;
opacity: 0;
pointer-events: none;
transition: opacity 0.25s ease;
}
.auto-open-overlay.visible {
opacity: 1;
pointer-events: auto;
}
.auto-open-overlay__panel {
width: min(940px, 100%);
background: linear-gradient(150deg, rgba(255, 255, 255, 0.9), rgba(248, 250, 252, 0.96));
border-radius: 22px;
padding: 38px 42px 40px;
box-shadow: 0 32px 90px rgba(15, 23, 42, 0.4);
border: 1px solid rgba(255, 255, 255, 0.6);
text-align: center;
cursor: pointer;
}
.auto-open-overlay__badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 999px;
background: rgba(37, 99, 235, 0.12);
color: #0f172a;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
font-size: 12px;
}
.auto-open-overlay__timer {
display: flex;
align-items: baseline;
justify-content: center;
gap: 12px;
margin: 18px 0 8px;
color: #0f172a;
}
.auto-open-overlay__count {
font-size: clamp(72px, 12vw, 120px);
line-height: 1;
font-weight: 700;
letter-spacing: -0.02em;
}
.auto-open-overlay__unit {
font-size: 22px;
color: #6b7280;
}
.auto-open-overlay__text {
margin: 0 auto;
color: #334155;
max-width: 700px;
font-size: 18px;
}
.auto-open-overlay__hint {
margin: 12px 0 0;
color: #475569;
font-size: 15px;
}
.posts-load-more { .posts-load-more {
display: flex; display: flex;
justify-content: center; justify-content: center;