aktueller stand
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
481
web/app.js
481
web/app.js
@@ -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();
|
||||||
|
|||||||
@@ -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.50–0.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 (0–64)</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>
|
||||||
|
|||||||
101
web/settings.js
101
web/settings.js
@@ -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));
|
||||||
})();
|
})();
|
||||||
|
|||||||
135
web/style.css
135
web/style.css
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user