// ==UserScript==
// @name Twitter/X Timeline Sync
// @description Tracks and syncs your last reading position on Twitter/X, with manual and automatic options. Ideal for keeping track of new posts without losing your place. Uses Tweet ID for precise positioning, with timestamp fallback, and supports reposts.
// @description:de Verfolgt und synchronisiert Ihre letzte Leseposition auf Twitter/X, mit manuellen und automatischen Optionen. Perfekt, um neue Beiträge im Blick zu behalten, ohne die aktuelle Position zu verlieren. Verwendet Tweet-ID für präzise Positionierung, mit Timestamp-Fallback und Unterstützung für Reposts.
// @description:es Rastrea y sincroniza tu última posición de lectura en Twitter/X, con opciones manuales y automáticas. Ideal para mantener el seguimiento de las publicaciones nuevas sin perder tu posición. Usa ID de Tweet para posicionamiento preciso, con fallback a timestamp y soporte para reposts.
// @description:fr Suit et synchronise votre dernière position de lecture sur Twitter/X, avec des options manuelles et automatiques. Idéal pour suivre les nouveaux posts sans perdre votre place actuelle. Utilise l'ID du Tweet pour un positionnement précis, avec repli sur timestamp et prise en charge des reposts.
// @description:zh-CN 跟踪并同步您在 Twitter/X 上的最后阅读位置,提供手动和自动选项。完美解决在查看新帖子时不丢失当前位置的问题。使用 Tweet ID 进行精确位置定位,带有时间戳回退和对转发的支持。
// @description:ru Отслеживает и синхронизирует вашу последнюю позицию чтения на Twitter/X с ручными и автоматическими опциями. Идеально подходит для просмотра новых постов без потери текущей позиции. Использует ID твита для точного позиционирования с резервным использованием временной метки и поддержкой репостов.
// @description:ja Twitter/X での最後の読み取り位置を追跡して同期します。手動および自動オプションを提供します。新しい投稿を見逃さずに現在の位置を維持するのに最適です。ツイートIDを使用して正確な位置特定を行い、タイムスタンプをフォールバックとして使用し、リポストをサポートします。
// @description:pt-BR Rastrea e sincroniza sua última posição de leitura no Twitter/X, com opções manuais e automáticas. Perfeito para acompanhar novos posts sem perder sua posição atual. Usa ID do Tweet para posicionamento preciso, com fallback de timestamp e suporte a reposts.
// @description:hi Twitter/X पर आपकी अंतिम पठन स्थिति को ट्रैक और सिंक करता है, मैनुअल और स्वचालित विकल्पों के साथ। नई पोस्ट देखते समय अपनी वर्तमान स्थिति को खोए बिना इसे ट्रैक करें। सटीक स्थिति के लिए ट्वीट ID का उपयोग करता है, टाइमस्टैम्प फॉलबैक और रीपोस्ट समर्थन के साथ।
// @description:ar يتتبع ويزامن آخر موضع قراءة لك على Twitter/X، مع خيارات يدوية وتلقائية. مثالي لتتبع المشاركات الجديدة دون فقدان موضعك الحالي. يستخدم معرف التغريدة لتحديد الموضع بدقة، مع الرجوع إلى الطابع الزمني ودعم إعادة النشر.
// @description:it Traccia e sincronizza la tua ultima posizione di lettura su Twitter/X, con opzioni manuali e automatiche. Ideale per tenere traccia dei nuovi post senza perdere la posizione attuale. Usa l'ID del Tweet per un posizionamento preciso, con fallback al timestamp e supporto per i repost.
// @description:ko Twitter/X에서 마지막 읽기 위치를 추적하고 동기화합니다. 수동 및 자동 옵션 포함. 새로운 게시물을 확인하면서 현재 위치를 잃지 않도록 이상적입니다. 트윗 ID를 사용하여 정확한 위치 지정을 하고, 타임스탬프 폴백과 리포스트를 지원합니다。
// @icon https://x.com/favicon.ico
// @namespace http://tampermonkey.net/
// @version 2025.8.15
// @author Copiis
// @license MIT
// @match https://x.com/home
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_registerMenuCommand
// ==/UserScript==
(function () {
'use strict';
// Übersetzungen für alle Popup-Nachrichten
const translations = {
en: {
noValidPosition: "❌ No valid reading position to download.",
alreadyDownloaded: "ℹ️ This reading position has already been downloaded.",
downloadSuccess: "✅ Reading position downloaded as {fileName}.",
downloadFailed: "❌ Download failed. Reading position copied to clipboard. Please paste it into a .json file manually.",
downloadClipboardFailed: "❌ Download and clipboard copy failed. Please save manually.",
noPositionFound: "ℹ️ Scroll to set a reading position.",
scriptError: "❌ Error loading the script.",
invalidPosition: "❌ Invalid reading position.",
fileSelectError: "❌ Please select a JSON file.",
fileReadError: "❌ Error reading the file.",
fileDialogError: "❌ Error opening file dialog.",
fileLoadSuccess: "✅ Reading position successfully loaded!",
buttonsError: "❌ Error displaying buttons.",
searchPopup: "🔍 Searching for position: @{authorHandler} - Tweet: {tweetId}... Press SPACE to cancel.",
searchNoPosition: "❌ No reading position available.",
searchScrollPrompt: "ℹ️ Please scroll or click the magnifier.",
tweetIdNotFound: "❌ Tweet ID not found, falling back to timestamp search.",
postDeletedFallback: "ℹ️ Post possibly deleted - using timestamp fallback.",
newPostsDetectionDelayed: "ℹ️ No new posts detected after checking. Please refresh or scroll to load them.",
autoDownloadToggled: "ℹ️ Auto-download {status}.",
enabled: "enabled",
disabled: "disabled"
},
de: {
noValidPosition: "❌ Keine gültige Leseposition zum Downloaden.",
alreadyDownloaded: "ℹ️ Diese Leseposition wurde bereits heruntergeladen.",
downloadSuccess: "✅ Leseposition als {fileName} heruntergeladen.",
downloadFailed: "❌ Download fehlgeschlagen. Leseposition wurde in die Zwischenablage kopiert. Bitte manuell in eine .json-Datei einfügen.",
downloadClipboardFailed: "❌ Download und Kopieren fehlgeschlagen. Bitte manuell speichern.",
noPositionFound: "ℹ️ Scrolle, um eine Leseposition zu setzen.",
scriptError: "❌ Fehler beim Laden des Skripts.",
invalidPosition: "❌ Ungültige Leseposition.",
fileSelectError: "❌ Bitte wähle eine JSON-Datei aus.",
fileReadError: "❌ Fehler beim Lesen der Datei.",
fileDialogError: "❌ Fehler beim Öffnen des Datei-Dialogs.",
fileLoadSuccess: "✅ Leseposition erfolgreich geladen!",
buttonsError: "❌ Fehler beim Anzeigen der Buttons.",
searchPopup: "🔍 Suche läuft für Position: @{authorHandler} - Tweet: {tweetId}... Drücke LEERTASTE zum Abbrechen.",
searchNoPosition: "❌ Keine Leseposition vorhanden.",
searchScrollPrompt: "ℹ️ Bitte scrollen oder Lupe klicken.",
tweetIdNotFound: "❌ Tweet-ID nicht gefunden, Rückfall auf Timestamp-Suche.",
postDeletedFallback: "ℹ️ Beitrag möglicherweise gelöscht - Timestamp-Fallback verwendet.",
newPostsDetectionDelayed: "ℹ️ Keine neuen Beiträge nach Prüfung erkannt. Bitte die Seite aktualisieren oder scrollen, um sie zu laden.",
autoDownloadToggled: "ℹ️ Automatischer Download {status}.",
enabled: "aktiviert",
disabled: "deaktiviert"
},
es: {
noValidPosition: "❌ No hay posición de lectura válida para descargar.",
alreadyDownloaded: "ℹ️ Esta posición de lectura ya ha sido descargada.",
downloadSuccess: "✅ Posición de lectura descargada como {fileName}.",
downloadFailed: "❌ Falló la descarga. La posición de lectura se copió al portapapeles. Pégala manualmente en un archivo .json.",
downloadClipboardFailed: "❌ Falló la descarga y la copia al portapapeles. Por favor, guarda manualmente.",
noPositionFound: "ℹ️ Desplázate para establecer una posición de lectura.",
scriptError: "❌ Error al cargar el script.",
invalidPosition: "❌ Posición de lectura no válida.",
fileSelectError: "❌ Por favor, selecciona un archivo JSON.",
fileReadError: "❌ Error al leer el archivo.",
fileDialogError: "❌ Error al abrir el diálogo de archivo.",
fileLoadSuccess: "✅ ¡Posición de lectura cargada con éxito!",
buttonsError: "❌ Error al mostrar los botones.",
searchPopup: "🔍 Buscando posición: @{authorHandler} - Tweet: {tweetId}... Presiona ESPACIO para cancelar.",
searchNoPosition: "❌ No hay posición de lectura disponible.",
searchScrollPrompt: "ℹ️ Por favor, desplázate o haz clic en la lupa.",
tweetIdNotFound: "❌ ID de Tweet no encontrado, retrocediendo a búsqueda por timestamp.",
postDeletedFallback: "ℹ️ Publicación posiblemente eliminada - usando fallback de timestamp.",
newPostsDetectionDelayed: "ℹ️ No se detectaron nuevas publicaciones después de verificar. Por favor, actualiza o desplázate para cargarlas.",
autoDownloadToggled: "ℹ️ Descarga automática {status}.",
enabled: "activada",
disabled: "desactivada"
},
fr: {
noValidPosition: "❌ Aucune position de lecture valide à télécharger.",
alreadyDownloaded: "ℹ️ Cette position de lecture a déjà été téléchargée.",
downloadSuccess: "✅ Position de lecture téléchargée sous {fileName}.",
downloadFailed: "❌ Échec du téléchargement. Position de lecture copiée dans le presse-papiers. Veuillez la coller manuellement dans un fichier .json.",
downloadClipboardFailed: "❌ Échec du téléchargement et de la copie dans le presse-papiers. Veuillez sauvegarder manuellement.",
noPositionFound: "ℹ️ Faites défiler pour définir une position de lecture.",
scriptError: "❌ Erreur lors du chargement du script.",
invalidPosition: "❌ Position de lecture invalide.",
fileSelectError: "❌ Veuillez sélectionner un fichier JSON.",
fileReadError: "❌ Erreur lors de la lecture du fichier.",
fileDialogError: "❌ Erreur lors de l'ouverture de la boîte de dialogue.",
fileLoadSuccess: "✅ Position de lecture chargée avec succès !",
buttonsError: "❌ Erreur lors de l'affichage des boutons.",
searchPopup: "🔍 Recherche en cours pour position: @{authorHandler} - Tweet: {tweetId}... Appuyez sur ESPACE pour annuler.",
searchNoPosition: "❌ Aucune position de lecture disponible.",
searchScrollPrompt: "ℹ️ Veuillez faire défiler ou cliquer sur la loupe.",
tweetIdNotFound: "❌ ID de Tweet non trouvé, retour à la recherche par timestamp.",
postDeletedFallback: "ℹ️ Post éventuellement supprimé - utilisation du fallback timestamp.",
newPostsDetectionDelayed: "ℹ️ Aucun nouveau post détecté après vérification. Veuillez actualiser ou défiler pour les charger.",
autoDownloadToggled: "ℹ️ Téléchargement automatique {status}.",
enabled: "activé",
disabled: "désactivé"
},
'zh-CN': {
noValidPosition: "❌ 没有有效的阅读位置可以下载。",
alreadyDownloaded: "ℹ️ 此阅读位置已下载。",
downloadSuccess: "✅ 阅读位置已下载为 {fileName}。",
downloadFailed: "❌ 下载失败。阅读位置已复制到剪贴板。请手动粘贴到 .json 文件中。",
downloadClipboardFailed: "❌ 下载和剪贴板复制失败。请手动保存。",
noPositionFound: "ℹ️ 滚动以设置阅读位置。",
scriptError: "❌ 加载脚本时出错。",
invalidPosition: "❌ 无效的阅读位置。",
fileSelectError: "❌ 请选择一个 JSON 文件。",
fileReadError: "❌ 读取文件时出错。",
fileDialogError: "❌ 打开文件对话框时出错。",
fileLoadSuccess: "✅ 阅读位置加载成功!",
buttonsError: "❌ 显示按钮时出错。",
searchPopup: "🔍 正在搜索位置: @{authorHandler} - Tweet: {tweetId}... 按空格键取消。",
searchNoPosition: "❌ 没有可用的阅读位置。",
searchScrollPrompt: "ℹ️ 请滚动或点击放大镜。",
tweetIdNotFound: "❌ 未找到推文ID,回退到时间戳搜索。",
postDeletedFallback: "ℹ️ 帖子可能已删除 - 使用时间戳回退。",
newPostsDetectionDelayed: "ℹ️ 检查后未检测到新帖子。请刷新或滚动以加载它们。",
autoDownloadToggled: "ℹ️ 自动下载 {status}。",
enabled: "启用",
disabled: "禁用"
},
ru: {
noValidPosition: "❌ Нет действительной позиции чтения для загрузки.",
alreadyDownloaded: "ℹ️ Эта позиция чтения уже была загружена.",
downloadSuccess: "✅ Позиция чтения загружена как {fileName}.",
downloadFailed: "❌ Не удалось выполнить загрузку. Позиция чтения скопирована в буфер обмена. Пожалуйста, вставьте вручную в файл .json.",
downloadClipboardFailed: "❌ Не удалось выполнить загрузку и копирование в буфер обмена. Пожалуйста, сохраните вручную.",
noPositionFound: "ℹ️ Прокрутите, чтобы установить позицию чтения.",
scriptError: "❌ Ошибка при загрузке скрипта.",
invalidPosition: "❌ Недействительная позиция чтения.",
fileSelectError: "❌ Пожалуйста, выберите файл JSON.",
fileReadError: "❌ Ошибка при чтении файла.",
fileDialogError: "❌ Ошибка при открытии диалогового окна.",
fileLoadSuccess: "✅ Позиция чтения успешно загружена!",
buttonsError: "❌ Ошибка при отображении кнопок.",
searchPopup: "🔍 Поиск позиции: @{authorHandler} - Tweet: {tweetId}... Нажмите ПРОБЕЛ для отмены.",
searchNoPosition: "❌ Позиция чтения недоступна.",
searchScrollPrompt: "ℹ️ Прокрутите или нажмите на лупу.",
tweetIdNotFound: "❌ ID твита не найден, возврат к поиску по временной метке.",
postDeletedFallback: "ℹ️ Пост возможно удален - использование временной метки fallback.",
newPostsDetectionDelayed: "ℹ️ После проверки новых постов не обнаружено. Пожалуйста, обновите или прокрутите, чтобы загрузить их.",
autoDownloadToggled: "ℹ️ Автоматическая загрузка {status}.",
enabled: "включено",
disabled: "отключено"
},
ja: {
noValidPosition: "❌ ダウンロードする有効な読み取り位置がありません。",
alreadyDownloaded: "ℹ️ この読み取り位置はすでにダウンロードされています。",
downloadSuccess: "✅ 読み取り位置が{fileName}としてダウンロードされました。",
downloadFailed: "❌ ダウンロードに失敗しました。読み取り位置がクリップボードにコピーされました。手動で.jsonファイルに貼り付けてください。",
downloadClipboardFailed: "❌ ダウンロードおよびクリップボードへのコピーに失敗しました。手動で保存してください。",
noPositionFound: "ℹ️ スクロールして読み取り位置を設定してください。",
scriptError: "❌ スクリプトの読み込み中にエラーが発生しました。",
invalidPosition: "❌ 無効な読み取り位置です。",
fileSelectError: "❌ JSONファイルを選択してください。",
fileReadError: "❌ ファイルの読み込み中にエラーが発生しました。",
fileDialogError: "❌ ファイルダイアログのオープン中にエラーが発生しました。",
fileLoadSuccess: "✅ 読み取り位置が正常にロードされました!",
buttonsError: "❌ ボタンの表示中にエラーが発生しました。",
searchPopup: "🔍 位置を検索中: @{authorHandler} - Tweet: {tweetId}... スペースキーを押してキャンセル。",
searchNoPosition: "❌ 読み取り位置がありません。",
searchScrollPrompt: "ℹ️ スクロールするか、虫眼鏡をクリックしてください。",
tweetIdNotFound: "❌ ツイートIDが見つかりません。タイムスタンプ検索にフォールバックします。",
postDeletedFallback: "ℹ️ 投稿が削除された可能性 - タイムスタンプフォールバックを使用。",
newPostsDetectionDelayed: "ℹ️ チェック後、新しい投稿は検出されませんでした。ページを更新するかスクロールしてロードしてください。",
autoDownloadToggled: "ℹ️ 自動ダウンロード {status}。",
enabled: "有効",
disabled: "無効"
},
'pt-BR': {
noValidPosition: "❌ Nenhuma posição de leitura válida para download.",
alreadyDownloaded: "ℹ️ Esta posição de leitura já foi baixada.",
downloadSuccess: "✅ Posição de leitura baixada como {fileName}.",
downloadFailed: "❌ Falha no download. Posição de leitura copiada para a área de transferência. Cole manualmente em um arquivo .json.",
downloadClipboardFailed: "❌ Falha no download e na cópia para a área de transferência. Por favor, salve manualmente.",
noPositionFound: "ℹ️ Role para definir uma posição de leitura.",
scriptError: "❌ Erro ao carregar o script.",
invalidPosition: "❌ Posição de leitura inválida.",
fileSelectError: "❌ Por favor, selecione um arquivo JSON.",
fileReadError: "❌ Erro ao ler o arquivo.",
fileDialogError: "❌ Erro ao abrir o diálogo de arquivo.",
fileLoadSuccess: "✅ Posição de leitura carregada com sucesso!",
buttonsError: "❌ Erro ao exibir os botões.",
searchPopup: "🔍 Pesquisando posição: @{authorHandler} - Tweet: {tweetId}... Pressione ESPAÇO para cancelar.",
searchNoPosition: "❌ Nenhuma posição de leitura disponível.",
searchScrollPrompt: "ℹ️ Role ou clique na lupa.",
tweetIdNotFound: "❌ ID do Tweet não encontrado, retornando à busca por timestamp.",
postDeletedFallback: "ℹ️ Post possivelmente deletado - usando fallback de timestamp.",
newPostsDetectionDelayed: "ℹ️ Nenhum novo post detectado após verificação. Por favor, atualize ou role para carregá-los.",
autoDownloadToggled: "ℹ️ Download automático {status}.",
enabled: "ativado",
disabled: "desativado"
},
hi: {
noValidPosition: "❌ डाउनलोड करने के लिए कोई वैध पढ़ने की स्थिति नहीं है।",
alreadyDownloaded: "ℹ️ यह पढ़ने की स्थिति पहले ही डाउनलोड की जा चुकी है।",
downloadSuccess: "✅ पढ़ने की स्थिति {fileName} के रूप में डाउनलोड की गई।",
downloadFailed: "❌ डाउनलोड विफल। पढ़ने की स्थिति क्लिपबोर्ड में कॉपी की गई है। कृपया इसे मैन्युअल रूप से .json फ़ाइल में पेस्ट करें।",
downloadClipboardFailed: "❌ डाउनलोड और क्लिपबोर्ड कॉपी विफल। कृपया मैन्युअल रूप से सहेजें।",
noPositionFound: "ℹ️ पढ़ने की स्थिति सेट करने के लिए स्क्रॉल करें।",
scriptError: "❌ स्क्रिप्ट लोड करने में त्रुटि।",
invalidPosition: "❌ अमान्य पढ़ने की स्थिति।",
fileSelectError: "❌ कृपया एक JSON फ़ाइल चुनें।",
fileReadError: "❌ फ़ाइल पढ़ने में त्रुटि।",
fileDialogError: "❌ फ़ाइल डायलॉग खोलने में त्रुटि।",
fileLoadSuccess: "✅ पढ़ने की स्थिति सफलतापूर्वक लोड की गई!",
buttonsError: "❌ बटनों को प्रदर्शित करने में त्रुटि।",
searchPopup: "🔍 खोज चल रही है स्थिति के लिए: @{authorHandler} - Tweet: {tweetId}... रद्द करने के लिए स्पेस दबाएं।",
searchNoPosition: "❌ कोई पढ़ने की स्थिति उपलब्ध नहीं है।",
searchScrollPrompt: "ℹ️ कृपया स्क्रॉल करें या मैग्नीफायर पर क्लिक करें।",
tweetIdNotFound: "❌ ट्वीट ID नहीं मिला, टाइमस्टैम्प खोज पर वापस जा रहा है।",
postDeletedFallback: "ℹ️ पोस्ट संभवतः हटा दी गई - टाइमस्टैम्प फॉलबैक का उपयोग कर रहा है।",
newPostsDetectionDelayed: "ℹ️ जाँच के बाद कोई नए पोस्ट नहीं पाए गए। कृपया पेज रिफ्रेश करें या स्क्रॉल करें ताकि उन्हें लोड किया जा सके।",
autoDownloadToggled: "ℹ️ स्वचालित डाउनलोड {status}।",
enabled: "सक्षम",
disabled: "अक्षम"
},
ar: {
noValidPosition: "❌ لا توجد مواضع قراءة صالحة للتحميل.",
alreadyDownloaded: "ℹ️ تم تحميل موضع القراءة هذا بالفعل.",
downloadSuccess: "✅ تم تحميل موضع القراءة باسم {fileName}.",
downloadFailed: "❌ فشل التحميل. تم نسخ موضع القراءة إلى الحافظة. يرجى لصقه يدويًا في ملف .json.",
downloadClipboardFailed: "❌ فشل التحميل والنسخ إلى الحافظة. يرجى الحفظ يدويًا.",
noPositionFound: "ℹ️ قم بالتمرير لتحديد موضع القراءة.",
scriptError: "❌ خطأ أثناء تحميل السكربت.",
invalidPosition: "❌ موضع قراءة غير صالح.",
fileSelectError: "❌ يرجى اختيار ملف JSON.",
fileReadError: "❌ خطأ أثناء قراءة الملف.",
fileDialogError: "❌ خطأ أثناء فتح حوار الملف.",
fileLoadSuccess: "✅ تم تحميل موضع القراءة بنجاح!",
buttonsError: "❌ خطأ أثناء عرض الأزرار.",
searchPopup: "🔍 جارٍ البحث عن الموقع: @{authorHandler} - Tweet: {tweetId}... اضغط على مفتاح المسافة للإلغاء.",
searchNoPosition: "❌ لا يوجد موضع قراءة متاح.",
searchScrollPrompt: "ℹ️ يرجى التمرير أو النقر على العدسة المكبرة.",
tweetIdNotFound: "❌ معرف التغريدة غير موجود، العودة إلى البحث باستخدام الطابع الزمني.",
postDeletedFallback: "ℹ️ المنشور ربما محذوف - استخدام الرجوع إلى الطابع الزمني.",
newPostsDetectionDelayed: "ℹ️ لم يتم الكشف عن مشاركات جديدة بعد التحقق. يرجى تحديث الصفحة أو التمرير لتحميلها.",
autoDownloadToggled: "ℹ️ التحميل التلقائي {status}.",
enabled: "مفعل",
disabled: "معطل"
},
it: {
noValidPosition: "❌ Nessuna posizione di lettura valida da scaricare.",
alreadyDownloaded: "ℹ️ Questa posizione di lettura è già stata scaricata.",
downloadSuccess: "✅ Posizione di lettura scaricata come {fileName}.",
downloadFailed: "❌ Download fallito. Posizione di lettura copiata negli appunti. Incollala manualmente in un file .json.",
downloadClipboardFailed: "❌ Download e copia negli appunti falliti. Salva manualmente.",
noPositionFound: "ℹ️ Scorri per impostare una posizione di lettura.",
scriptError: "❌ Errore durante il caricamento dello script.",
invalidPosition: "❌ Posizione di lettura non valida.",
fileSelectError: "❌ Seleziona un file JSON.",
fileReadError: "❌ Errore durante la lettura del file.",
fileDialogError: "❌ Errore durante l'apertura della finestra di dialogo.",
fileLoadSuccess: "✅ Posizione di lettura caricata con successo!",
buttonsError: "❌ Errore durante la visualizzazione dei pulsanti.",
searchPopup: "🔍 Ricerca in corso per posizione: @{authorHandler} - Tweet: {tweetId}... Premi SPAZIO per annullare.",
searchNoPosition: "❌ Nessuna posizione di lettura disponibile.",
searchScrollPrompt: "ℹ️ Scorri o fai clic sulla lente d'ingrandimento.",
tweetIdNotFound: "❌ ID del Tweet non trovato, ritorno alla ricerca per timestamp.",
postDeletedFallback: "ℹ️ Post possibilmente eliminato - usando fallback timestamp.",
newPostsDetectionDelayed: "ℹ️ Nessun nuovo post rilevato dopo il controllo. Per favore aggiorna o scorri per caricarli.",
autoDownloadToggled: "ℹ️ Download automatico {status}.",
enabled: "abilitato",
disabled: "disabilitato"
},
ko: {
noValidPosition: "❌ 다운로드할 유효한 읽기 위치가 없습니다.",
alreadyDownloaded: "ℹ️ 이 읽기 위치는 이미 다운로드되었습니다.",
downloadSuccess: "✅ 읽기 위치가 {fileName}으로 다운로드되었습니다.",
downloadFailed: "❌ 다운로드 실패. 읽기 위치가 클립보드에 복사되었습니다. .json 파일에 수동으로 붙여넣으세요.",
downloadClipboardFailed: "❌ 다운로드 및 클립보드 복사 실패. 수동으로 저장하세요.",
noPositionFound: "ℹ️ 읽기 위치를 설정하려면 스크롤하세요.",
scriptError: "❌ 스크립트 로드 중 오류가 발생했습니다.",
invalidPosition: "❌ 유효하지 않은 읽기 위치입니다.",
fileSelectError: "❌ JSON 파일을 선택하세요.",
fileReadError: "❌ 파일 읽기 중 오류가 발생했습니다.",
fileDialogError: "❌ 파일 대화 상자를 여는 중 오류가 발생했습니다.",
fileLoadSuccess: "✅ 읽기 위치가 성공적으로 로드되었습니다!",
buttonsError: "❌ 버튼 표시 중 오류가 발생했습니다。",
searchPopup: "🔍 위치 검색 중: @{authorHandler} - Tweet: {tweetId}... 취소하려면 스페이스바를 누르세요.",
searchNoPosition: "❌ 사용 가능한 읽기 위치가 없습니다.",
searchScrollPrompt: "ℹ️ 스크롤하거나 돋보기를 클릭하세요.",
tweetIdNotFound: "❌ 트윗 ID를 찾을 수 없습니다. 타임스탬프 검색으로 돌아갑니다.",
postDeletedFallback: "ℹ️ 게시물이 삭제되었을 수 있음 - 타임스탬프 폴백 사용.",
newPostsDetectionDelayed: "ℹ️ 확인 후 새로운 게시물이 감지되지 않았습니다. 페이지를 새로 고침하거나 스크롤하여 로드하세요.",
autoDownloadToggled: "ℹ️ 자동 다운로드 {status}.",
enabled: "활성화됨",
disabled: "비활성화됨"
}
};
// Funktion zur Erkennung der Benutzersprache
function getUserLanguage() {
const lang = navigator.language || navigator.languages[0] || 'en';
const langCode = lang.split('-')[0];
return translations[lang] || translations[langCode] ? lang : 'en';
}
// Funktion zum Abrufen der übersetzten Nachricht
function getTranslatedMessage(key, lang, params = {}) {
const translation = translations[lang] || translations['en'];
let message = translation[key] || translations['en'][key] || key;
Object.keys(params).forEach(param => {
message = message.replace(`{${param}}`, params[param]);
});
return message;
}
// Hilfsfunktion für robuste Selektoren
function getSelectorFallback(element, selectors) {
for (const selector of selectors) {
const found = element.querySelector(selector);
if (found) return found;
}
return null;
}
// Debounce-Funktion für Performance
function debounce(fn, delay) {
let timeout;
return (...args) => {
clearTimeout(timeout);
timeout = setTimeout(() => fn(...args), delay);
};
}
// Funktion zum Abrufen des aktuellen Benutzerhandles
function getCurrentUserHandle() {
return new Promise((resolve) => {
const tryFromNav = () => {
const navLink = getSelectorFallback(document, ['a[data-testid="AppTabBar_Profile_Link"][href^="/"]', 'a[href^="/"][role="link"]']);
if (navLink) {
const href = navLink.getAttribute("href");
const match = href.match(/^\/([^/]+)/);
if (match && match[1] && !['i', 'home', 'explore', 'messages', 'notifications'].includes(match[1])) {
console.log("🛠️ DEBUG: Handle aus Navigationsleiste gefunden:", match[1]);
return match[1];
}
console.log("⚠️ DEBUG: Navigationslink gefunden, aber ungültiger Handle:", match ? match[1] : 'kein Match');
}
console.log("⚠️ DEBUG: Kein Navigationslink gefunden.");
return null;
};
const tryFromProfile = () => {
const profileLink = getSelectorFallback(document, ['a[role="link"][href^="/"]:not([href*="/i/"]):not([href*="/home"]):not([href*="/explore"]):not([href*="/messages"]):not([href*="/notifications"])', 'div[data-testid="UserCell"] a[href^="/"]']);
if (profileLink) {
const href = profileLink.getAttribute("href");
const match = href.match(/^\/([^/]+)/);
if (match && match[1] && !['i', 'home', 'explore', 'messages', 'notifications'].includes(match[1])) {
console.log("🛠️ DEBUG: Handle aus Profil-Link gefunden:", match[1]);
return match[1];
}
console.log("⚠️ DEBUG: Profil-Link gefunden, aber ungültiger Handle:", match ? match[1] : 'kein Match');
}
console.log("⚠️ DEBUG: Kein Profil-Link gefunden.");
return null;
};
const tryFromSpan = () => {
const spanElements = document.querySelectorAll('span[class*="css-901oao"]');
for (const spanElement of spanElements) {
const text = spanElement.textContent || '';
if (text.startsWith('@') && text.length > 1) {
const handle = text.slice(1);
if (!['i', 'home', 'explore', 'messages', 'notifications'].includes(handle)) {
console.log("🛠️ DEBUG: Handle aus Span gefunden:", handle);
return handle;
}
}
}
console.log("⚠️ DEBUG: Kein Span mit gültigem Handle gefunden.");
return null;
};
const tryFromContainer = () => {
const container = getSelectorFallback(document, ['.css-175oi2r.r-172uzmj.r-1pi2tsx.r-13qz1uu.r-o7ynqc.r-6416eg.r-1ny4l3l', 'div[data-testid="UserCell"]']);
if (container) {
console.log("🛠️ DEBUG: Container gefunden:", container);
const handlerElement = container.querySelector('span, a[href^="/"]');
if (handlerElement) {
const text = handlerElement.textContent || '';
if (text.startsWith('@') && text.length > 1) {
const handle = text.slice(1);
if (!['i', 'home', 'explore', 'messages', 'notifications'].includes(handle)) {
console.log("🛠️ DEBUG: Handle aus Container-Text gefunden:", handle);
return handle;
}
}
if (handlerElement.tagName.toLowerCase() === 'a') {
const href = handlerElement.getAttribute("href");
const match = href.match(/^\/([^/]+)/);
if (match && match[1] && !['i', 'home', 'explore', 'messages', 'notifications'].includes(match[1])) {
console.log("🛠️ DEBUG: Handle aus Container-href gefunden:", match[1]);
return match[1];
}
}
console.log("⚠️ DEBUG: Kein gültiger Handle im Container gefunden.");
}
console.log("⚠️ DEBUG: Kein Handler-Element im Container gefunden.");
}
console.log("⚠️ DEBUG: Container nicht gefunden.");
return null;
};
const tryFromLocalStorage = () => {
const storedHandle = localStorage.getItem('currentUserHandle');
if (storedHandle && !['i', 'home', 'explore', 'messages', 'notifications'].includes(storedHandle)) {
console.log("🛠️ DEBUG: Handle aus localStorage geladen:", storedHandle);
return storedHandle;
}
console.log("⚠️ DEBUG: Kein gültiger Handle in localStorage.");
return "unknown";
};
const saveHandle = (handle) => {
if (handle && handle !== "unknown" && !['i', 'home', 'explore', 'messages', 'notifications'].includes(handle)) {
localStorage.setItem('currentUserHandle', handle);
console.log("💾 Handle in localStorage gespeichert:", handle);
}
};
let handle = tryFromNav() || tryFromProfile() || tryFromSpan() || tryFromContainer();
if (handle) {
saveHandle(handle);
resolve(handle);
return;
}
const observer = new MutationObserver(() => {
handle = tryFromNav() || tryFromProfile() || tryFromSpan() || tryFromContainer();
if (handle) {
saveHandle(handle);
observer.disconnect();
resolve(handle);
}
});
observer.observe(document.documentElement, { childList: true, subtree: true });
setTimeout(() => {
if (!handle) {
observer.disconnect();
handle = tryFromLocalStorage();
console.warn("⚠️ Benutzerhandle konnte nicht ermittelt werden, Fallback auf:", handle);
resolve(handle);
}
}, 10000);
});
}
let lastReadPost = null;
let isAutoScrolling = false;
let isSearching = false;
let isScriptActivated = false;
let currentPost = null;
let lastHighlightedPost = null;
const downloadedPosts = new Set();
const STORAGE_KEY = (account) => `lastReadPost_${account}`;
// Initialisierung des Auto-Download-Status
const AUTO_DOWNLOAD_KEY = 'autoDownloadEnabled';
let autoDownloadEnabled = GM_getValue(AUTO_DOWNLOAD_KEY, false);
// Funktion zum Umschalten des Auto-Downloads
function toggleAutoDownload() {
autoDownloadEnabled = !autoDownloadEnabled;
GM_setValue(AUTO_DOWNLOAD_KEY, autoDownloadEnabled);
const status = autoDownloadEnabled ? getTranslatedMessage('enabled', getUserLanguage()) : getTranslatedMessage('disabled', getUserLanguage());
showPopup('autoDownloadToggled', 3000, { status });
console.log(`🛠️ DEBUG: Auto-Download ${status}`);
}
// Tampermonkey-Menübefehl hinzufügen
GM_registerMenuCommand(`Auto-Download ${autoDownloadEnabled ? 'Disable' : 'Enable'}`, () => {
toggleAutoDownload();
// Aktualisiere Menübefehl dynamisch
GM_registerMenuCommand(`Auto-Download ${autoDownloadEnabled ? 'Disable' : 'Enable'}`, toggleAutoDownload);
});
async function loadLastReadPost(callback) {
try {
const account = await getCurrentUserHandle();
const storageKey = STORAGE_KEY(account);
const storedPost = GM_getValue(storageKey, null);
if (storedPost) {
const parsedPost = JSON.parse(storedPost);
console.log(`🛠️ DEBUG: Geladene Leseposition für Account ${account}:`, parsedPost);
callback(parsedPost);
} else {
console.log(`⏹️ Keine gespeicherte Leseposition für Account ${account} gefunden.`);
callback(null);
}
} catch (err) {
console.error("❌ Fehler beim Laden der Leseposition:", err);
callback(null);
}
}
async function saveLastReadPost(post) {
if (!post || !post.tweetId || !post.authorHandler) {
console.log("❌ Ungültige Leseposition, Speicherung abgebrochen:", post);
return;
}
const account = await getCurrentUserHandle();
const storageKey = STORAGE_KEY(account);
let attempts = 0;
const maxAttempts = 3;
function trySave() {
try {
const postData = JSON.stringify(post);
GM_setValue(storageKey, postData);
console.log(`💾 Leseposition für Account ${account} erfolgreich mit GM_setValue gespeichert:`, postData);
localStorage.setItem(storageKey, postData);
console.log(`💾 Fallback in localStorage für Account ${account} gespeichert:`, localStorage.getItem(storageKey));
} catch (err) {
attempts++;
console.error(`❌ Fehler beim Speichern der Leseposition für Account ${account} (Versuch ${attempts}/${maxAttempts}):`, err);
if (attempts < maxAttempts) {
console.log("🔄 Wiederhole Speicherversuch...");
setTimeout(trySave, 1000);
} else {
console.error(`❌ Maximale Speicherversuche für Account ${account} erreicht. Fallback auf localStorage.`);
localStorage.setItem(storageKey, JSON.stringify(post));
promptManualFallback(post);
}
}
}
trySave();
}
function promptManualFallback(data) {
const content = JSON.stringify(data);
const message = `📝 Neue Leseposition: ${content}\nBitte speichere dies manuell, da der Speichervorgang fehlschlug.`;
showPopup(message, 10000);
console.log("📝 Bitte manuell speichern:", content);
}
async function downloadLastReadPost() {
try {
if (!currentPost || !currentPost.tweetId || !currentPost.authorHandler) {
console.warn("⚠️ Keine gültige Leseposition zum Speichern:", currentPost);
showPopup("noValidPosition", 5000);
return;
}
const postKey = `${currentPost.tweetId}-${currentPost.authorHandler}`;
if (downloadedPosts.has(postKey)) {
console.log("⏹️ Leseposition bereits heruntergeladen:", postKey);
showPopup("alreadyDownloaded", 5000);
return;
}
console.log("🛠️ DEBUG: Starte Download-Prozess für Leseposition:", currentPost);
const account = await getCurrentUserHandle();
const date = new Date(currentPost.timestamp || Date.now());
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hour = String(date.getHours()).padStart(2, "0");
const minute = String(date.getMinutes()).padStart(2, "0");
const second = String(date.getSeconds()).padStart(2, "0");
const fileName = `${account}_${year}${month}${day}_${hour}${minute}${second}-${currentPost.authorHandler}.json`;
console.log("📄 Generierter Dateiname:", fileName);
const fileContent = JSON.stringify(currentPost, null, 2);
const blob = new Blob([fileContent], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = fileName;
a.style.display = "none";
document.body.appendChild(a);
console.log("🔗 Download-Element erstellt:", a);
try {
a.click();
console.log(`💾 Leseposition als Datei gespeichert: ${fileName}`);
showPopup("downloadSuccess", 5000, { fileName });
downloadedPosts.add(postKey);
} catch (clickErr) {
console.error("❌ Fehler beim Auslösen des Downloads:", clickErr);
if (!navigator.clipboard) {
console.error("❌ Clipboard-API nicht verfügbar.");
showPopup("downloadClipboardFailed", 10000);
promptManualFallback(currentPost);
return;
}
navigator.clipboard.writeText(fileContent).then(() => {
console.log("📋 Leseposition in Zwischenablage kopiert.");
showPopup("downloadFailed", 10000, { fileName });
downloadedPosts.add(postKey);
}).catch(clipErr => {
console.error("❌ Fehler beim Kopieren in die Zwischenablage:", clipErr);
showPopup("downloadClipboardFailed", 10000);
promptManualFallback(currentPost);
});
}
setTimeout(() => {
try {
document.body.removeChild(a);
URL.revokeObjectURL(url);
console.log("🧹 Download-Element entfernt und URL freigegeben.");
} catch (cleanupErr) {
console.error("❌ Fehler beim Aufräumen:", cleanupErr);
}
}, 3000);
} catch (err) {
console.error("❌ Fehler beim Speichern der Datei:", err);
showPopup("downloadClipboardFailed", 5000);
promptManualFallback(currentPost);
}
}
async function loadNewestLastReadPost() {
return new Promise(resolve => {
loadLastReadPost(async (storedPost) => {
const account = await getCurrentUserHandle();
const storageKey = STORAGE_KEY(account);
if (storedPost && storedPost.tweetId && storedPost.authorHandler) {
lastReadPost = storedPost;
console.log(`✅ Leseposition für Account ${account} geladen:`, lastReadPost);
} else {
const localPost = JSON.parse(localStorage.getItem(storageKey) || "{}");
if (localPost && localPost.tweetId && localPost.authorHandler) {
lastReadPost = localPost;
console.log(`✅ Leseposition aus localStorage für Account ${account}:`, lastReadPost);
} else {
console.warn(`⚠️ Keine Leseposition für Account ${account} gefunden.`);
showPopup("noPositionFound", 5000);
}
}
resolve();
});
});
}
async function initializeScript() {
console.log("🔧 Lade Leseposition...");
try {
await loadNewestLastReadPost();
console.log("✅ Initialisierung erfolgreich.");
window.addEventListener("scroll", debounce(() => {
if (!isScriptActivated) {
isScriptActivated = true;
console.log("🛠️ DEBUG: Skript durch Scrollen aktiviert.");
observeForNewPosts();
}
if (isAutoScrolling || isSearching) {
console.log("⏹️ Scroll-Ereignis ignoriert.");
return;
}
markTopVisiblePost(true);
}, 300), { passive: true });
// Automatischer Download bei Fokusverlust oder Schließen, wenn aktiviert
const debouncedDownload = debounce(() => {
if (autoDownloadEnabled && currentPost && isScriptActivated) {
const postKey = `${currentPost.tweetId}-${currentPost.authorHandler}`;
if (!downloadedPosts.has(postKey)) {
downloadLastReadPost();
}
}
}, 1000);
window.addEventListener('blur', debouncedDownload);
window.addEventListener('beforeunload', () => {
if (autoDownloadEnabled && currentPost && isScriptActivated) {
const postKey = `${currentPost.tweetId}-${currentPost.authorHandler}`;
if (!downloadedPosts.has(postKey)) {
downloadLastReadPost();
}
}
});
} catch (err) {
console.error("❌ Fehler bei der Initialisierung:", err);
showPopup("scriptError", 5000);
}
}
function initializeWhenDOMReady() {
if (!window.location.href.includes("/home")) {
console.log("🚫 Skript deaktiviert: Nicht auf der Home-Seite.");
return;
}
console.log("🚀 Initialisiere Skript...");
const observer = new MutationObserver((mutations, obs) => {
if (document.body) {
obs.disconnect();
initializeScript().then(() => {
createButtons();
}).catch(err => {
console.error("❌ Fehler bei der Initialisierung:", err);
showPopup("scriptError", 5000);
});
}
});
observer.observe(document.documentElement, { childList: true, subtree: true });
}
window.addEventListener("load", initializeWhenDOMReady);
function loadLastReadPostFromFile() {
try {
const input = document.createElement("input");
input.type = "file";
input.accept = ".json";
input.style.display = "none";
if (!document.body) {
console.error("❌ document.body nicht verfügbar.");
showPopup("fileDialogError", 5000);
return;
}
document.body.appendChild(input);
input.addEventListener("change", async (event) => {
const file = event.target.files[0];
if (!file) {
console.warn("⚠️ Keine Datei ausgewählt.");
showPopup("fileSelectError", 5000);
document.body.removeChild(input);
return;
}
const reader = new FileReader();
reader.onload = async (e) => {
try {
const data = JSON.parse(e.target.result);
if (!data || typeof data !== "object" || !data.tweetId || !data.authorHandler) {
console.warn("⚠️ Ungültige oder unvollständige Leseposition in der Datei:", data);
showPopup("invalidPosition", 5000);
document.body.removeChild(input);
return;
}
const account = await getCurrentUserHandle();
data.account = account;
lastReadPost = data;
await saveLastReadPost(data);
console.log(`✅ Leseposition für Account ${account} aus Datei geladen:`, lastReadPost);
showPopup("fileLoadSuccess", 3000);
if (!isScriptActivated) {
isScriptActivated = true;
console.log("🛠️ DEBUG: Skript durch Import aktiviert.");
observeForNewPosts();
}
startRefinedSearchForLastReadPost();
} catch (err) {
console.error("❌ Fehler beim Parsen der Datei:", err);
showPopup("fileReadError", 5000);
document.body.removeChild(input);
}
};
reader.readAsText(file);
});
input.click();
} catch (err) {
console.error("❌ Fehler beim Öffnen des Datei-Dialogs:", err);
showPopup("fileDialogError", 5000);
}
}
function waitForNewPosts(callback) {
const timelineContainer = getSelectorFallback(document, ['div[data-testid="primaryColumn"]', 'main[role="main"]']) || document.body;
let loadAttempts = 0;
const maxLoadAttempts = 15; // 15 Sekunden bei 1-Sekunden-Intervall
const initialPostCount = document.querySelectorAll('article').length;
const observer = new MutationObserver((mutations) => {
const currentPostCount = document.querySelectorAll('article').length;
if (currentPostCount > initialPostCount) {
console.log("🆕 Neue Beiträge im DOM erkannt, starte Suche.");
observer.disconnect();
callback();
}
});
observer.observe(timelineContainer, {
childList: true,
subtree: true,
});
const timeoutCheck = setInterval(() => {
loadAttempts++;
if (loadAttempts >= maxLoadAttempts) {
console.log("⚠️ Neue Beiträge wurden nach maximalen Versuchen nicht geladen.");
showPopup("newPostsDetectionDelayed", 5000);
observer.disconnect();
clearInterval(timeoutCheck);
}
}, 1000);
window.addEventListener("unload", () => {
observer.disconnect();
clearInterval(timeoutCheck);
});
}
function startNewPostsCheckInterval() {
let detectionAttempts = 0;
const maxDetectionAttempts = 120; // 1 Minute bei 0.5-Sekunden-Intervall
const interval = setInterval(() => {
if (!isScriptActivated || isSearching || isAutoScrolling) {
console.log("⏹️ Intervallprüfung übersprungen: Skript nicht aktiviert oder Suche aktiv.");
return;
}
const newPostsIndicator = getNewPostsIndicator();
if (newPostsIndicator) {
const rect = newPostsIndicator.getBoundingClientRect();
const isVisible = rect.bottom > 0 && rect.top < window.innerHeight;
console.log("🛠️ DEBUG: Neuer Posts-Button Sichtbarkeit - top:", rect.top, "bottom:", rect.bottom, "window.innerHeight:", window.innerHeight, "isVisible:", isVisible);
if (isVisible && !newPostsIndicator.dataset.processed) {
console.log("🆕 Neue Beiträge über Intervall erkannt und sichtbar.");
clickNewPostsIndicator(newPostsIndicator);
waitForNewPosts(() => {
startRefinedSearchForLastReadPost();
});
clearInterval(interval);
}
} else {
detectionAttempts++;
if (detectionAttempts >= maxDetectionAttempts) {
console.log("⚠️ Keine neuen Beiträge nach maximalen Versuchen erkannt.");
showPopup("newPostsDetectionDelayed", 5000);
clearInterval(interval);
}
}
}, 500); // Verkürztes Intervall auf 500 ms
window.addEventListener("unload", () => clearInterval(interval));
}
async function markTopVisiblePost(save = true) {
if (!window.location.href.includes("/home")) {
console.log("⏹️ Speicherung übersprungen: Nicht auf der Home-Seite.");
return;
}
const topPost = getTopVisiblePost();
if (!topPost) {
console.log("❌ Kein sichtbarer Beitrag.");
return;
}
const postTweetId = getPostTweetId(topPost);
const postTimestamp = getPostTimestamp(topPost);
const postAuthorHandler = getPostAuthorHandler(topPost);
const isRepost = isPostRepost(topPost);
if (postTweetId && postAuthorHandler) {
const account = await getCurrentUserHandle();
const newPost = { tweetId: postTweetId, timestamp: postTimestamp, authorHandler: postAuthorHandler, account: account, isRepost };
console.log("🛠️ DEBUG: Versuche, Leseposition zu speichern:", newPost);
if (lastHighlightedPost && lastHighlightedPost !== topPost) {
lastHighlightedPost.style.boxShadow = "none";
}
topPost.style.boxShadow = "0 0 20px 10px rgba(246, 146, 25, 0.9)";
lastHighlightedPost = topPost;
currentPost = newPost;
if (save && isScriptActivated) {
await loadLastReadPost(async (existingPost) => {
console.log("🛠️ DEBUG: markTopVisiblePost - newPost:", newPost, "existingPost:", existingPost);
const shouldSave = !existingPost ||
!existingPost.timestamp ||
(postTimestamp && new Date(postTimestamp) > new Date(existingPost.timestamp));
if (shouldSave) {
lastReadPost = newPost;
console.log("💾 Neue Leseposition gesetzt (jünger oder keine bestehende Position):", lastReadPost);
await saveLastReadPost(lastReadPost);
} else {
console.log("⏹️ Speicherung übersprungen: Neue Leseposition nicht jünger.");
}
});
}
}
}
function getTopVisiblePost() {
const posts = Array.from(document.querySelectorAll("article"));
return posts.find(post => {
const rect = post.getBoundingClientRect();
return rect.top >= 0 && rect.bottom > 0;
});
}
function getPostTweetId(post) {
const selectors = ['a[href*="/status/"]', 'div[data-testid="tweet"] a[href*="status"]'];
const linkElement = getSelectorFallback(post, selectors);
if (!linkElement) {
console.warn("⚠️ Tweet-ID-Link nicht gefunden für Beitrag:", post);
return null;
}
const href = linkElement.getAttribute("href");
const match = href.match(/\/status\/(\d+)/);
if (!match) {
console.warn("⚠️ Ungültige Tweet-ID in href:", href);
return null;
}
console.log("🛠️ DEBUG: Tweet-ID gefunden:", match[1]);
return match[1];
}
function getPostTimestamp(post) {
const selectors = ['time[datetime]', 'div[data-testid="tweet"] time'];
const timeElement = getSelectorFallback(post, selectors);
if (!timeElement) {
console.warn("⚠️ Zeitstempel-Element nicht gefunden für Beitrag:", post);
return null;
}
return timeElement.getAttribute("datetime");
}
function isPostRepost(post) {
const selectors = ['[data-testid="unretweet"]', 'svg[data-testid="icon-retweet"]'];
const repostIndicator = getSelectorFallback(post, selectors);
const isRepost = !!repostIndicator;
console.log("🛠️ DEBUG: Ist Repost:", isRepost);
return isRepost;
}
function getPostAuthorHandler(post) {
const isRepost = isPostRepost(post);
const selectors = ['a[role="link"][href*="/"]:not([href*="/status/"])', 'div[data-testid="tweet"] a[href^="/"]'];
const handlerElement = getSelectorFallback(post, selectors);
if (!handlerElement) {
console.warn("⚠️ Autoren-Handle-Element nicht gefunden für Beitrag (Repost: " + isRepost + "):", post);
return null;
}
const href = handlerElement.getAttribute("href");
const match = href.match(/^\/([^/]+)/);
if (!match) {
console.warn("⚠️ Ungültiger Autoren-Handle in href (Repost: " + isRepost + "):", href);
return null;
}
console.log("🛠️ DEBUG: Autoren-Handle gefunden (Repost: " + isRepost + "):", match[1]);
return match[1];
}
async function startRefinedSearchForLastReadPost() {
if (!isScriptActivated) {
console.log("⏹️ Suche abgebrochen: Skript nicht aktiviert.");
showPopup("searchScrollPrompt", 5000);
return;
}
await loadLastReadPost(async (storedData) => {
const account = await getCurrentUserHandle();
if (!storedData) {
const storageKey = STORAGE_KEY(account);
const localData = JSON.parse(localStorage.getItem(storageKey) || "{}");
if (localData && localData.tweetId && localData.authorHandler) {
lastReadPost = localData;
} else {
console.log(`❌ Keine Leseposition für Account ${account} gefunden.`);
showPopup("searchNoPosition", 5000);
return;
}
} else {
lastReadPost = storedData;
}
if (!lastReadPost.tweetId || !lastReadPost.authorHandler) {
console.log("❌ Ungültige Leseposition:", lastReadPost);
showPopup("invalidPosition", 5000);
return;
}
console.log(`🔍 Starte Suche für Account ${account}:`, lastReadPost);
const popup = createSearchPopup(lastReadPost);
let direction = 1;
let scrollAmount = 2000;
let previousScrollY = -1;
let searchAttempts = 0;
let stagnantScrollCount = 0;
const maxAttempts = 50;
let usingTimestampFallback = false;
function handleSpaceKey(event) {
if (event.code === "Space") {
console.log("⏹️ Suche gestoppt.");
isSearching = false;
popup.remove();
window.removeEventListener("keydown", handleSpaceKey);
}
}
window.addEventListener("keydown", handleSpaceKey);
const search = () => {
if (!isSearching || searchAttempts >= maxAttempts) {
console.log("⏹️ Suche beendet: Max Versuche oder abgebrochen.");
showPopup("postDeletedFallback", 5000);
isSearching = false;
popup.remove();
window.removeEventListener("keydown", handleSpaceKey);
return;
}
const visiblePosts = getVisiblePosts();
const comparison = compareVisiblePostsToLastReadPost(visiblePosts);
if (comparison === "match") {
const matchedPost = findPostByData(lastReadPost);
if (matchedPost) {
console.log("🎯 Beitrag gefunden:", lastReadPost);
isAutoScrolling = true;
scrollToPostWithHighlight(matchedPost);
isSearching = false;
popup.remove();
window.removeEventListener("keydown", handleSpaceKey);
return;
}
} else if (comparison === "noTweetIdMatch" && !usingTimestampFallback) {
console.log("⚠️ Tweet-ID nicht gefunden, Rückfall auf Timestamp-Suche.");
showPopup("tweetIdNotFound", 5000);
usingTimestampFallback = true;
} else if (comparison === "older") {
direction = -1;
} else if (comparison === "newer") {
direction = 1;
}
if (window.scrollY === previousScrollY) {
stagnantScrollCount++;
if (stagnantScrollCount >= 3) {
console.log("⏹️ Suche abgebrochen: Scroll-Position stagniert.");
isSearching = false;
popup.remove();
window.removeEventListener("keydown", handleSpaceKey);
return;
}
scrollAmount = Math.max(scrollAmount / 2, 500);
direction = -direction;
} else {
stagnantScrollCount = 0;
scrollAmount = Math.min(scrollAmount * 1.5, 3000);
}
previousScrollY = window.scrollY;
searchAttempts++;
requestAnimationFrame(() => {
window.scrollBy({
top: direction * scrollAmount,
behavior: "smooth"
});
setTimeout(search, 1000);
});
};
isSearching = true;
search();
});
}
function createSearchPopup(position) {
const lang = getUserLanguage();
const message = getTranslatedMessage('searchPopup', lang, { authorHandler: position.authorHandler, tweetId: position.tweetId });
const popup = document.createElement("div");
popup.style.position = "fixed";
popup.style.top = "20px";
popup.style.left = "50%";
popup.style.transform = "translateX(-50%)";
popup.style.backgroundColor = "rgba(0, 0, 0, 0.9)";
popup.style.color = "#ffffff";
popup.style.padding = "10px 20px";
popup.style.borderRadius = "8px";
popup.style.fontSize = "14px";
popup.style.boxShadow = "0 0 10px rgba(246, 146, 25, 0.8)";
popup.style.zIndex = "10000";
popup.style.transition = "opacity 0.3s ease";
popup.style.opacity = "0";
popup.textContent = message;
if (document.body) {
document.body.appendChild(popup);
setTimeout(() => { popup.style.opacity = "1"; }, 100);
} else {
console.error("❌ document.body nicht verfügbar für createSearchPopup.");
return null;
}
return popup;
}
function compareVisiblePostsToLastReadPost(posts, customPosition = lastReadPost) {
const validPosts = posts.filter(post => post.tweetId && post.authorHandler);
if (validPosts.length === 0) {
console.log("⚠️ Keine sichtbaren Beiträge.");
return null;
}
const hasTweetIdMatch = validPosts.some(post =>
post.tweetId === customPosition.tweetId && post.authorHandler === customPosition.authorHandler
);
if (hasTweetIdMatch) {
return "match";
}
if (customPosition.timestamp) {
const lastReadTime = new Date(customPosition.timestamp);
const allOlder = validPosts.every(post => post.timestamp && new Date(post.timestamp) < lastReadTime);
const allNewer = validPosts.every(post => post.timestamp && new Date(post.timestamp) > lastReadTime);
if (allOlder) {
return "older";
} else if (allNewer) {
return "newer";
} else {
return "mixed";
}
}
return "noTweetIdMatch";
}
function scrollToPostWithHighlight(post) {
if (!post) {
console.log("❌ Kein Beitrag zum Scrollen.");
return;
}
isAutoScrolling = true;
if (lastHighlightedPost && lastHighlightedPost !== post) {
lastHighlightedPost.style.boxShadow = "none";
}
post.style.boxShadow = "0 0 20px 10px rgba(246, 146, 25, 0.9)";
lastHighlightedPost = post;
post.scrollIntoView({ behavior: "smooth", block: "start" });
setTimeout(() => {
isAutoScrolling = false;
console.log("✅ Beitrag an der oberen Kante positioniert.");
}, 1000);
}
function getVisiblePosts() {
const posts = Array.from(document.querySelectorAll("article"));
return posts.map(post => ({
element: post,
tweetId: getPostTweetId(post),
timestamp: getPostTimestamp(post),
authorHandler: getPostAuthorHandler(post),
isRepost: isPostRepost(post)
}));
}
function observeForNewPosts() {
let isProcessingIndicator = false;
const timelineContainer = getSelectorFallback(document, ['div[data-testid="primaryColumn"]', 'main[role="main"]']) || document.body;
const observer = new MutationObserver(() => {
if (!isScriptActivated) {
console.log("⏹️ Beobachtung abgebrochen: Skript nicht aktiviert.");
observer.disconnect();
return;
}
if (!isSearching && !isProcessingIndicator && lastReadPost) {
const newPostsIndicator = getNewPostsIndicator();
if (newPostsIndicator) {
const rect = newPostsIndicator.getBoundingClientRect();
const isVisible = rect.bottom > 0 && rect.top < window.innerHeight;
console.log("🛠️ DEBUG: Neuer Posts-Button Sichtbarkeit - top:", rect.top, "bottom:", rect.bottom, "window.innerHeight:", window.innerHeight, "isVisible:", isVisible);
if (isVisible && !newPostsIndicator.dataset.processed) {
console.log("🆕 Neue Beiträge erkannt und sichtbar.");
isProcessingIndicator = true;
clickNewPostsIndicator(newPostsIndicator);
waitForNewPosts(() => {
startRefinedSearchForLastReadPost();
isProcessingIndicator = false;
});
}
}
}
});
observer.observe(timelineContainer, {
childList: true,
subtree: true,
});
window.addEventListener("unload", () => observer.disconnect());
}
function getNewPostsIndicator() {
const buttons = document.querySelectorAll('div[data-testid="cellInnerDiv"] button[role="button"]');
for (const button of buttons) {
const span = getSelectorFallback(button, ['span']);
if (span) {
const textContent = span.textContent || '';
const postIndicatorPattern = /\b(Post|Posts|Beitrag|Beiträge|Tweet|Tweets|Publicación|Publications|投稿|게시물|пост|постов|mensagem|mensagens|مشاركة|مشاركات)\b/i;
if (postIndicatorPattern.test(textContent)) {
console.log(`🆕 Indikator gefunden: ${textContent}`);
return button;
}
}
}
console.log("ℹ️ Kein Beitragsindikator gefunden.");
return null;
}
function clickNewPostsIndicator(indicator) {
if (!indicator) {
console.log("⚠️ Kein Indikator gefunden.");
return;
}
console.log("✅ Klicke auf Indikator...");
try {
indicator.dataset.processed = 'true';
indicator.click();
console.log("✅ Indikator geklickt.");
} catch (err) {
console.error("❌ Fehler beim Klicken:", err);
}
}
function findPostByData(data) {
const posts = Array.from(document.querySelectorAll("article"));
return posts.find(post => {
const postTweetId = getPostTweetId(post);
const postAuthorHandler = getPostAuthorHandler(post);
return postTweetId === data.tweetId && postAuthorHandler === data.authorHandler;
});
}
function createButtons() {
const observer = new MutationObserver(() => {
if (document.body) {
observer.disconnect();
try {
const buttonContainer = document.createElement("div");
buttonContainer.style.position = "fixed";
buttonContainer.style.top = "10px";
buttonContainer.style.left = "10px";
buttonContainer.style.zIndex = "10000";
buttonContainer.style.display = "flex";
buttonContainer.style.flexDirection = "column";
buttonContainer.style.alignItems = "flex-start";
buttonContainer.style.visibility = "visible";
const buttonsConfig = [
{
icon: "🔍",
title: "Start manual search",
onClick: () => {
console.log("🔍 Manuelle Suche gestartet.");
if (!isScriptActivated) {
isScriptActivated = true;
console.log("🛠️ DEBUG: Skript durch Lupen-Klick aktiviert.");
observeForNewPosts();
}
startRefinedSearchForLastReadPost();
},
},
{
icon: "📂",
title: "Load last read position from file",
onClick: () => {
console.log("📂 Lade Leseposition aus Datei...");
loadLastReadPostFromFile();
},
},
{
icon: "💾",
title: "Download current read position",
onClick: () => {
console.log("💾 Starte manuellen Download der Leseposition...");
downloadLastReadPost();
},
},
];
buttonsConfig.forEach(({ icon, title, onClick }) => {
const button = createButton(icon, title, onClick);
buttonContainer.appendChild(button);
});
if (document.body) {
document.body.appendChild(buttonContainer);
console.log("🛠️ DEBUG: Button-Container erstellt:", buttonContainer);
} else {
console.error("❌ document.body nicht verfügbar für createButtons.");
showPopup("buttonsError", 5000);
}
} catch (err) {
console.error("❌ Fehler beim Erstellen der Buttons:", err);
showPopup("buttonsError", 5000);
}
}
});
observer.observe(document.documentElement, { childList: true, subtree: true });
}
function createButton(icon, title, onClick) {
const button = document.createElement("div");
button.style.width = "27px";
button.style.height = "27px";
button.style.backgroundColor = "rgba(0, 0, 0, 0.9)";
button.style.color = "#ffffff";
button.style.borderRadius = "50%";
button.style.display = "flex";
button.style.justifyContent = "center";
button.style.alignItems = "center";
button.style.cursor = "pointer";
button.style.fontSize = "14px";
button.style.boxShadow = "0 0 8px rgba(255, 255, 255, 0.5)";
button.style.transition = "transform 0.2s, box-shadow 0.3s";
button.style.zIndex = "10001";
button.style.marginBottom = "8px";
button.textContent = icon;
button.title = title;
button.setAttribute('role', 'button');
button.setAttribute('aria-label', title);
button.addEventListener("click", () => {
button.style.boxShadow = "0 0 15px rgba(255, 255, 255, 0.8)";
button.style.transform = "scale(0.9)";
setTimeout(() => {
button.style.boxShadow = "0 0 8px rgba(255, 255, 255, 0.5)";
button.style.transform = "scale(1)";
}, 300);
onClick();
});
return button;
}
function showPopup(messageKey, duration = 3000, params = {}) {
const lang = getUserLanguage();
const message = getTranslatedMessage(messageKey, lang, params);
const popup = document.createElement("div");
popup.style.position = "fixed";
popup.style.top = "20px";
popup.style.left = "50%";
popup.style.transform = "translateX(-50%)";
popup.style.backgroundColor = "rgba(0, 0, 0, 0.9)";
popup.style.color = "#ffffff";
popup.style.padding = "10px 20px";
popup.style.borderRadius = "8px";
popup.style.fontSize = "14px";
popup.style.boxShadow = "0 0 10px rgba(246, 146, 25, 0.8)";
popup.style.zIndex = "10000";
popup.style.maxWidth = "500px";
popup.style.whiteSpace = "pre-wrap";
popup.style.transition = "opacity 0.3s ease";
popup.style.opacity = "0";
popup.textContent = message;
if (document.body) {
document.body.appendChild(popup);
setTimeout(() => { popup.style.opacity = "1"; }, 100);
} else {
console.error("❌ document.body nicht verfügbar für showPopup.");
return;
}
setTimeout(() => {
try {
popup.style.opacity = "0";
setTimeout(() => popup.remove(), 300);
} catch (err) {
console.error("❌ Fehler beim Entfernen des Popups:", err);
}
}, duration);
}
})();