Greasy Fork

X Timeline Sync

跟踪并同步您在 Twitter/X 上的最后阅读位置,提供手动和自动选项。完美解决在查看新帖子时不丢失当前位置的问题。

// ==UserScript==
// @name              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.
// @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.
// @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.
// @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.
// @description:zh-CN 跟踪并同步您在 Twitter/X 上的最后阅读位置,提供手动和自动选项。完美解决在查看新帖子时不丢失当前位置的问题。
// @description:ru    Отслеживает и синхронизирует вашу последнюю позицию чтения на Twitter/X с ручными и автоматическими опциями. Идеально подходит для просмотра новых постов без потери текущей позиции.
// @description:ja    Twitter/X での最後の読み取り位置を追跡して同期します。手動および自動オプションを提供します。新しい投稿を見逃さずに現在の位置を維持するのに最適です。
// @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.
// @description:hi    Twitter/X पर आपकी अंतिम पठन स्थिति को ट्रैक और सिंक करता है, मैनुअल और स्वचालित विकल्पों के साथ। नई पोस्ट देखते समय अपनी वर्तमान स्थिति को खोए बिना इसे ट्रैक करें।
// @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.
// @description:ko    Twitter/X에서 마지막 읽기 위치를 추적하고 동기화합니다. 수동 및 자동 옵션 포함. 새로운 게시물을 확인하면서 현재 위치를 잃지 않도록 이상적입니다.
// @icon              https://x.com/favicon.ico
// @namespace         http://tampermonkey.net/
// @version           2025-03-02
// @author            Copiis
// @license           MIT
// @match             https://x.com/home
// @grant             GM_setValue
// @grant             GM_getValue
// @grant             GM_download
// ==/UserScript==
//                    If you find this script useful and would like to support my work, consider making a small donation!
//                    Bitcoin (BTC): bc1quc5mkudlwwkktzhvzw5u2nruxyepef957p68r7
//                    PayPal: https://www.paypal.com/paypalme/Coopiis?country.x=DE&locale.x=de_DE

(function () {
    let lastReadPost = null;
    let isAutoScrolling = false;
    let isSearching = false;
    let isTabFocused = true;
    let downloadTriggered = false;
    let lastDownloadedPost = null;

    window.onload = async () => {
        if (!window.location.href.includes("/home")) {
            console.log("🚫 Skript deaktiviert: Nicht auf der Home-Seite.");
            return;
        }
        console.log("🚀 Seite vollständig geladen. Initialisiere Skript...");
        await loadNewestLastReadPost();
        await initializeScript();
        createButtons();
    };

    window.addEventListener("blur", async () => {
    isTabFocused = false;
    console.log("🌐 Tab nicht mehr fokussiert.");
    if (lastReadPost && !downloadTriggered) {
        // Prüfe, ob sich die aktuelle Leseposition von der zuletzt heruntergeladenen unterscheidet
        if (
            !lastDownloadedPost ||
            lastDownloadedPost.timestamp !== lastReadPost.timestamp ||
            lastDownloadedPost.authorHandler !== lastReadPost.authorHandler
        ) {
            downloadTriggered = true;
            console.log("📥 Speichere und lade aktuelle Leseposition herunter...");
            await saveLastReadPostToFile(); // Speichert lokal
            await downloadLastReadPost();  // Löst den Download aus
            lastDownloadedPost = { ...lastReadPost }; // Aktualisiere die zuletzt heruntergeladene Position
            downloadTriggered = false;
        } else {
            console.log("⏹️ Leseposition ist identisch mit der zuletzt heruntergeladenen. Download übersprungen.");
        }
    }
});

    window.addEventListener("focus", () => {
        isTabFocused = true;
        downloadTriggered = false;
        console.log("🟢 Tab wieder fokussiert.");
    });

    async function initializeScript() {
        console.log("🔧 Lade Leseposition...");
        await loadLastReadPostFromFile();
        observeForNewPosts();

        window.addEventListener("scroll", () => {
            if (isAutoScrolling || isSearching) {
                console.log("⏹️ Scroll-Ereignis ignoriert (automatischer Modus aktiv).");
                return;
            }
            markTopVisiblePost(true);
        });
    }

    async function downloadLastReadPost() {
        if (!lastReadPost || !lastReadPost.timestamp || !lastReadPost.authorHandler) {
            console.warn("⚠️ Keine gültige Leseposition zum Herunterladen.");
            return;
        }

        try {
            const data = JSON.stringify(lastReadPost, null, 2);
            const sanitizedHandler = lastReadPost.authorHandler.replace(/[^a-zA-Z0-9-_]/g, "");
            const timestamp = new Date(lastReadPost.timestamp).toISOString().replace(/[:.-]/g, "_");
            const fileName = `${sanitizedHandler}_${timestamp}.json`;

            GM_download({
                url: `data:application/json;charset=utf-8,${encodeURIComponent(data)}`,
                name: fileName,
                onload: () => console.log(`✅ Leseposition erfolgreich heruntergeladen: ${fileName}`),
                onerror: (err) => console.error("❌ Fehler beim Herunterladen der Leseposition:", err),
            });
        } catch (error) {
            console.error("❌ Fehler beim Herunterladen der Leseposition:", error);
        }
    }

    async function loadNewestLastReadPost() {
        try {
            const localData = GM_getValue("lastReadPost", null);
            if (localData) {
                lastReadPost = JSON.parse(localData);
                console.log("✅ Lokale Leseposition beim Start geladen:", lastReadPost);
            } else {
                console.warn("⚠️ Keine gespeicherte Leseposition gefunden.");
            }
        } catch (err) {
            console.error("❌ Fehler beim Laden der neuesten Leseposition:", err);
        }
    }

    async function loadLastReadPostFromFile() {
        try {
            const data = GM_getValue("lastReadPost", null);
            if (data) {
                lastReadPost = JSON.parse(data);
                console.log("✅ Leseposition erfolgreich geladen:", lastReadPost);
            } else {
                console.warn("⚠️ Keine gespeicherte Leseposition gefunden.");
            }
        } catch (err) {
            console.error("❌ Fehler beim Laden der Leseposition:", err);
        }
    }

    function markTopVisiblePost(save = true) {
    const topPost = getTopVisiblePost();
    if (!topPost) {
        console.log("❌ Kein oberster sichtbarer Beitrag gefunden.");
        return;
    }

    const postTimestamp = getPostTimestamp(topPost);
    const authorHandler = getPostAuthorHandler(topPost);

    if (postTimestamp && authorHandler) {
        const newPost = { timestamp: postTimestamp, authorHandler };
        const existingData = GM_getValue("lastReadPost", null);
        const existingPost = existingData ? JSON.parse(existingData) : null;

        if (save && (!existingPost || new Date(postTimestamp) > new Date(existingPost.timestamp))) {
            lastReadPost = newPost;
            console.log("💾 Neue Leseposition erkannt und aktualisiert:", lastReadPost);
            if (isTabFocused) {
                saveLastReadPostToFile(); // Speichert nur lokal, kein Download
            }
        }
    }
}

    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 getPostTimestamp(post) {
        const timeElement = post.querySelector("time");
        return timeElement ? timeElement.getAttribute("datetime") : null;
    }

    function getPostAuthorHandler(post) {
        const handlerElement = post.querySelector('[role="link"][href*="/"]');
        return handlerElement ? handlerElement.getAttribute("href").slice(1) : null;
    }

    function startRefinedSearchForLastReadPost() {
        const storedData = GM_getValue("lastReadPost", null);
        if (!storedData) {
            console.log("❌ Keine gespeicherte Leseposition gefunden.");
            showPopup("❌ Keine gespeicherte Leseposition vorhanden.");
            return;
        }

        try {
            lastReadPost = JSON.parse(storedData);
            if (!lastReadPost.timestamp || !lastReadPost.authorHandler) {
                console.log("❌ Gespeicherte Leseposition ist ungültig:", lastReadPost);
                showPopup("❌ Ungültige gespeicherte Leseposition.");
                return;
            }
        } catch (err) {
            console.error("❌ Fehler beim Parsen der gespeicherten Leseposition:", err);
            showPopup("❌ Fehler bei der gespeicherten Leseposition.");
            return;
        }

        console.log("🔍 Starte verfeinerte Suche mit gespeicherter Position:", lastReadPost);
        const popup = createSearchPopup();

        let direction = 1;
        let scrollAmount = 2000;
        let previousScrollY = -1;

        function handleSpaceKey(event) {
            if (event.code === "Space") {
                console.log("⏹️ Suche manuell gestoppt.");
                isSearching = false;
                popup.remove();
                window.removeEventListener("keydown", handleSpaceKey);
            }
        }

        window.addEventListener("keydown", handleSpaceKey);

        const search = () => {
            if (!isSearching) {
                popup.remove();
                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 === "older") {
                direction = -1;
            } else if (comparison === "newer") {
                direction = 1;
            }

            if (window.scrollY === previousScrollY) {
                scrollAmount = Math.max(scrollAmount / 2, 500);
                direction = -direction;
            } else {
                scrollAmount = Math.min(scrollAmount * 1.5, 3000);
            }

            previousScrollY = window.scrollY;

            window.scrollBy(0, direction * scrollAmount);

            setTimeout(search, 300);
        };

        isSearching = true;
        search();
    }

    function startRefinedSearchForLastReadPostWithPosition(position) {
        if (!position || !position.timestamp || !position.authorHandler) {
            console.log("❌ Ungültige Leseposition für Suche:", position);
            showPopup("❌ Ungültige Leseposition.");
            return;
        }

        console.log("🔍 Starte verfeinerte Suche mit temporärer Position:", position);
        const popup = createSearchPopup();

        let direction = 1;
        let scrollAmount = 2000;
        let previousScrollY = -1;

        function handleSpaceKey(event) {
            if (event.code === "Space") {
                console.log("⏹️ Suche manuell gestoppt.");
                isSearching = false;
                popup.remove();
                window.removeEventListener("keydown", handleSpaceKey);
            }
        }

        window.addEventListener("keydown", handleSpaceKey);

        const search = () => {
            if (!isSearching) {
                popup.remove();
                return;
            }

            const visiblePosts = getVisiblePosts();
            const comparison = compareVisiblePostsToLastReadPost(visiblePosts, position);

            if (comparison === "match") {
                const matchedPost = findPostByData(position);
                if (matchedPost) {
                    console.log("🎯 Beitrag gefunden:", position);
                    isAutoScrolling = true;
                    scrollToPostWithHighlight(matchedPost);
                    isSearching = false;
                    popup.remove();
                    window.removeEventListener("keydown", handleSpaceKey);
                    return;
                }
            } else if (comparison === "older") {
                direction = -1;
            } else if (comparison === "newer") {
                direction = 1;
            }

            if (window.scrollY === previousScrollY) {
                scrollAmount = Math.max(scrollAmount / 2, 500);
                direction = -direction;
            } else {
                scrollAmount = Math.min(scrollAmount * 1.5, 3000);
            }

            previousScrollY = window.scrollY;

            window.scrollBy(0, direction * scrollAmount);

            setTimeout(search, 300);
        };

        isSearching = true;
        search();
    }

    function createSearchPopup() {
        const popup = document.createElement("div");
        popup.style.position = "fixed";
        popup.style.bottom = "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(255, 255, 255, 0.8)";
        popup.style.zIndex = "10000";
        popup.textContent = "🔍 Searching... Press SPACE to cancel.";
        document.body.appendChild(popup);
        return popup;
    }

    function compareVisiblePostsToLastReadPost(posts, customPosition = lastReadPost) {
        const validPosts = posts.filter(post => post.timestamp && post.authorHandler);

        if (validPosts.length === 0) {
            console.log("⚠️ Keine sichtbaren Beiträge gefunden.");
            return null;
        }

        const lastReadTime = new Date(customPosition.timestamp);

        const allOlder = validPosts.every(post => new Date(post.timestamp) < lastReadTime);
        const allNewer = validPosts.every(post => new Date(post.timestamp) > lastReadTime);

        if (validPosts.some(post => post.timestamp === customPosition.timestamp && post.authorHandler === customPosition.authorHandler)) {
            return "match";
        } else if (allOlder) {
            return "older";
        } else if (allNewer) {
            return "newer";
        } else {
            return "mixed";
        }
    }

    function scrollToPostWithHighlight(post) {
        if (!post) {
            console.log("❌ Kein Beitrag zum Scrollen gefunden.");
            return;
        }

        isAutoScrolling = true;

        post.style.outline = "none";
        post.style.boxShadow = "0 0 20px 10px rgba(255, 215, 0, 0.9)";
        post.style.animation = "glow 2s infinite";

        if (!document.querySelector('#glowStyle')) {
            const style = document.createElement('style');
            style.id = 'glowStyle';
            style.textContent = `
                @keyframes glow {
                    0% { box-shadow: 0 0 20px 10px rgba(255, 215, 0, 0.9); }
                    50% { box-shadow: 0 0 5px 2px rgba(255, 215, 0, 0.6); }
                    100% { box-shadow: 0 0 20px 10px rgba(255, 215, 0, 0.9); }
                }
            `;
            document.head.appendChild(style);
        }

        post.scrollIntoView({ behavior: "smooth", block: "center" });

        const removeHighlightOnScroll = () => {
            if (!isAutoScrolling) {
                post.style.boxShadow = "none";
                post.style.animation = "none";
                console.log("✅ Highlight entfernt nach manuellem Scroll.");
                window.removeEventListener("scroll", removeHighlightOnScroll);
            }
        };

        setTimeout(() => {
            isAutoScrolling = false;
            window.addEventListener("scroll", removeHighlightOnScroll);
            console.log("✅ Beitrag zentriert, warte auf manuellen Scroll.");
        }, 1000);
    }

    function getVisiblePosts() {
        const posts = Array.from(document.querySelectorAll("article"));
        return posts.map(post => ({
            element: post,
            timestamp: getPostTimestamp(post),
            authorHandler: getPostAuthorHandler(post),
        }));
    }

    async function saveLastReadPostToFile() {
    try {
        if (!lastReadPost || !lastReadPost.timestamp || !lastReadPost.authorHandler) {
            console.warn("⚠️ Keine gültige Leseposition vorhanden. Speichern übersprungen.");
            return;
        }

        const existingData = GM_getValue("lastReadPost", null);
        if (existingData) {
            const existingPost = JSON.parse(existingData);
            if (
                existingPost.timestamp === lastReadPost.timestamp &&
                existingPost.authorHandler === lastReadPost.authorHandler
            ) {
                console.log("⏹️ Lesestelle ist identisch mit der gespeicherten. Speichern übersprungen.");
                return;
            }
        }

        GM_setValue("lastReadPost", JSON.stringify(lastReadPost));
        console.log("💾 Leseposition erfolgreich gespeichert:", lastReadPost);
        // Entferne den Download-Aufruf hier
        // await downloadLastReadPost();
    } catch (err) {
        console.error("❌ Fehler beim Speichern der Leseposition:", err);
    }
}

    async function deleteOldReadingPositions(handler) {
        console.log(`🗑️ Ältere Lesestellen für den Handler "${handler}" werden simuliert entfernt.`);
    }

    function observeForNewPosts() {
        const observer = new MutationObserver(() => {
            if (window.scrollY <= 3 && !isSearching) {
                const newPostsIndicator = getNewPostsIndicator();

                if (newPostsIndicator) {
                    console.log("🆕 Neue Beiträge erkannt. Starte automatische Suche...");
                    clickNewPostsIndicator(newPostsIndicator);
                    setTimeout(() => {
                        startRefinedSearchForLastReadPost();
                    }, 1500);
                }
            }
        });

        observer.observe(document.body, {
            childList: true,
            subtree: true,
        });
    }

    function getNewPostsIndicator() {
        const elements = document.querySelectorAll('[aria-label]');
        for (const element of elements) {
            const ariaLabel = element.getAttribute('aria-label');
            if (ariaLabel && ariaLabel.includes('undefined')) {
                console.log(`🆕 Neuer Beitrags-Indikator gefunden mit aria-label: "${ariaLabel}"`);
                const button = element.closest('button[role="button"]') || element;
                if (button) {
                    return button;
                }
            }
        }
        console.warn("⚠️ Kein neuer Beitragsindikator gefunden.");
        return null;
    }

    function clickNewPostsIndicator(indicator) {
        if (!indicator) {
            console.warn("⚠️ Kein neuer Beitragsindikator gefunden.");
            return;
        }

        console.log("✅ Klicke auf neuen Beitragsindikator...");
        indicator.click();
        console.log("✅ Neuer Beitragsindikator erfolgreich geklickt.");
    }

    function findPostByData(data) {
        const posts = Array.from(document.querySelectorAll("article"));
        return posts.find(post => {
            const postTimestamp = getPostTimestamp(post);
            const authorHandler = getPostAuthorHandler(post);
            return postTimestamp === data.timestamp && authorHandler === data.authorHandler;
        });
    }

    function createButtons() {
        const buttonContainer = document.createElement("div");
        buttonContainer.style.position = "fixed";
        buttonContainer.style.top = "50%";
        buttonContainer.style.left = "3px";
        buttonContainer.style.transform = "translateY(-50%)";
        buttonContainer.style.display = "flex";
        buttonContainer.style.flexDirection = "column";
        buttonContainer.style.gap = "3px";
        buttonContainer.style.zIndex = "10000";

        const buttonsConfig = [
            {
                icon: "📂",
                title: "Load saved position",
                onClick: async () => {
                    await importLastReadPost();
                },
            },
            {
                icon: "🔍",
                title: "Start manual search",
                onClick: () => {
                    console.log("🔍 Manuelle Suche gestartet.");
                    startRefinedSearchForLastReadPost();
                },
            },
        ];

        buttonsConfig.forEach(({ icon, title, onClick }) => {
            const button = createButton(icon, title, onClick);
            buttonContainer.appendChild(button);
        });

        document.body.appendChild(buttonContainer);
    }

    function createButton(icon, title, onClick) {
        const button = document.createElement("div");
        button.style.width = "36px";
        button.style.height = "36px";
        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 = "18px";
        button.style.boxShadow = "inset 0 0 10px rgba(255, 255, 255, 0.5)";
        button.style.transition = "transform 0.2s, box-shadow 0.3s";
        button.textContent = icon;
        button.title = title;

        button.addEventListener("click", () => {
            button.style.boxShadow = "inset 0 0 20px rgba(255, 255, 255, 0.8)";
            button.style.transform = "scale(0.9)";
            setTimeout(() => {
                button.style.boxShadow = "inset 0 0 10px rgba(255, 255, 255, 0.5)";
                button.style.transform = "scale(1)";
            }, 300);
            onClick();
        });

        button.addEventListener("mouseenter", () => {
            button.style.boxShadow = "inset 0 0 15px rgba(255, 255, 255, 0.7)";
            button.style.transform = "scale(1.1)";
        });

        button.addEventListener("mouseleave", () => {
            button.style.boxShadow = "inset 0 0 10px rgba(255, 255, 255, 0.5)";
            button.style.transform = "scale(1)";
        });

        return button;
    }

    async function importLastReadPost() {
        const input = document.createElement("input");
        input.type = "file";
        input.accept = "application/json";
        input.style.display = "none";

        input.addEventListener("change", async (event) => {
            const file = event.target.files[0];
            if (file) {
                const reader = new FileReader();
                reader.onload = async () => {
                    try {
                        const importedData = JSON.parse(reader.result);
                        if (importedData.timestamp && importedData.authorHandler) {
                            console.log("✅ Importierte Leseposition geladen (wird nicht intern gespeichert):", importedData);
                            showPopup("✅ Position geladen. Suche startet...");

                            const tempPosition = importedData;
                            const matchedPost = findPostByData(tempPosition);
                            if (matchedPost) {
                                scrollToPostWithHighlight(matchedPost);
                            } else {
                                startRefinedSearchForLastReadPostWithPosition(tempPosition);
                            }
                        } else {
                            throw new Error("Ungültige Position");
                        }
                    } catch (error) {
                        console.error("❌ Fehler beim Importieren der Leseposition:", error);
                        showPopup("❌ Fehler: Ungültige Position.");
                    }
                };
                reader.readAsText(file);
            }
        });

        document.body.appendChild(input);
        input.click();
        document.body.removeChild(input);
    }

    function showPopup(message) {
        const popup = document.createElement("div");
        popup.style.position = "fixed";
        popup.style.bottom = "20px";
        popup.style.right = "20px";
        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(255, 255, 255, 0.8)";
        popup.style.zIndex = "10000";
        popup.textContent = message;

        document.body.appendChild(popup);

        setTimeout(() => {
            popup.remove();
        }, 3000);
    }
})();