JM Shelf - Storage

localStorage + GM storage + 多账号隔离 — JM Shelf 推荐脚本的模块库,通过 @require 被主脚本引用。

Ce script ne devrait pas être installé directement. C'est une librairie créée pour d'autres scripts. Elle doit être inclus avec la commande // @require https://update.sleazyfork.org/scripts/581102/1842601/JM%20Shelf%20-%20Storage.js

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

// ==UserScript==
// @name         JM Shelf - Storage
// @namespace    jmshelf-lib
// @version      1.0.0
// @author       Kesdi
// @description  localStorage + GM storage + 多账号隔离 — JM Shelf 推荐脚本的模块库,通过 @require 被主脚本引用。
// @license      MIT
// ==/UserScript==
// 
// 此文件是 GreasyFork 库(library),不直接安装。
// 请安装主脚本: JM Shelf 给杂鱼的个性化推荐
//

// ═══ [5] STORAGE ═══ (localStorage + GM storage hybrid, 多账号隔离)
  // ============================================================
  const PREFIX = 'jms_';
  let _accountKey = ''; // 动态: jms_Kiaelen_

  function setAccount(username) {
    if (!username) { _accountKey = ''; return; }
    const newKey = PREFIX + username + '_';
    if (newKey === _accountKey) return;
    // 迁移: 如果旧数据存在(无账号前缀)且新账号数据为空, 则迁移
    if (_accountKey === '' && username) {
      const oldKeys = Object.keys(localStorage).filter(k => k.startsWith(PREFIX) && !k.includes('_' + username + '_'));
      const hasNewData = Object.keys(localStorage).some(k => k.startsWith(newKey));
      if (!hasNewData && oldKeys.length > 1) {
        LOG.info(`📦 迁移 ${oldKeys.length} 条数据到账号 ${username}`);
        for (const ok of oldKeys) {
          const shortKey = ok.substring(PREFIX.length);
          if (shortKey.startsWith(username + '_')) continue; // 跳过已迁移的
          const val = localStorage.getItem(ok);
          if (val) localStorage.setItem(newKey + shortKey, val);
        }
        // 不删旧数据, 留作备份
      }
    }
    _accountKey = newKey;
  }

  const Storage = {
    get(key) {
      try {
        // 优先读账号隔离数据
        if (_accountKey) {
          const raw = localStorage.getItem(_accountKey + key);
          if (raw !== null && raw !== undefined) return JSON.parse(raw);
        }
        // 回退到全局数据
        const raw = localStorage.getItem(PREFIX + key);
        return raw ? JSON.parse(raw) : null;
      } catch (e) { return null; }
    },
    set(key, val) {
      try {
        const target = _accountKey || PREFIX;
        localStorage.setItem(target + key, JSON.stringify(val));
      } catch (e) {}
    },
    remove(key) {
      try {
        if (_accountKey) localStorage.removeItem(_accountKey + key);
        localStorage.removeItem(PREFIX + key);
      } catch (e) {}
    },
  };

  // Atomic GM storage for small, critical state (avoids tab race conditions)
  const GMStore = {
    get(key, fallback) {
      try { const v = GM_getValue(PREFIX + key); return v !== undefined ? v : fallback; } catch (e) { return fallback; }
    },
    set(key, val) {
      try { GM_setValue(PREFIX + key, val); } catch (e) {}
    },
  };

  // ============================================================
  //  STATE (loaded once at init)
  //  NOTE: Using plain functions instead of getters/setters to avoid
  //  infinite recursion bugs. Each function reads/writes directly.
  // ============================================================
  const State = {
    // Profile
    getProfile() { return Storage.get('profile') || { tags: {}, authors: {}, types: {}, lastUpdate: 0, version: 0 }; },
    saveProfile(v) {
      const old = this.getProfile();
      v.lastUpdate = Date.now();
      v.version = (old.version || 0) + 1;
      Storage.set('profile', v);
    },

    // Favorites
    getFavorites() { return Storage.get('favorites') || []; },
    saveFavorites(v) { 
      const seen = new Set();
      v = (v||[]).filter(f => { const sid = String(f.id); if (seen.has(sid)) return false; seen.add(sid); return true; });
      Storage.set('favorites', v); 
    },

    // Liked (❤️点击爱心, 独立于收藏)
    getLikedAlbums() { 
      const liked = Storage.get('liked') || [];
      // 补时间戳: 旧记录没有likedAt的, 按现在算
      let changed = false;
      const now = Date.now();
      for (const f of liked) {
        if (!f.likedAt) { f.likedAt = now; changed = true; }
      }
      if (changed) this.saveLikedAlbums(liked);
      return liked;
    },
    saveLikedAlbums(v) { 
      const seen = new Set();
      v = (v||[]).filter(f => { const sid = String(f.id); if (seen.has(sid)) return false; seen.add(sid); return true; });
      Storage.set('liked', v); 
    },
    addLikedAlbum(id) {
      const liked = this.getLikedAlbums();
      const sid = String(id);
      if (!liked.find(f => String(f.id) === sid)) {
        liked.push({ id: sid, likedAt: Date.now() });
        this.saveLikedAlbums(liked);
      }
    },

    // History
    getHistory() { return Storage.get('history') || []; },
    saveHistory(v) { Storage.set('history', v); },

    // Album cache
    getAlbumCache() { return Storage.get('album_cache') || {}; },
    saveAlbumCache(v) { Storage.set('album_cache', v); },

    // Candidate pool
    getCandidates() { return Storage.get('candidates') || []; },
    saveCandidates(v) { Storage.set('candidates', v); },
    getPoolSizes() { return Storage.get('poolSizes', {}); },
    savePoolSizes(v) { Storage.set('poolSizes', v); },

    // Recommendations
    getRecommendations() { const r = Storage.get('recommendations'); return Array.isArray(r) ? r : []; },
    saveRecommendations(v) { Storage.set('recommendations', v); },

    // Scan state
    getScanState() { return GMStore.get('scan_state', null); },
    saveScanState(v) { GMStore.set('scan_state', v); },

    getLastFullScan() { return GMStore.get('last_full_scan', 0); },
    saveLastFullScan(v) { GMStore.set('last_full_scan', v); },

    getLastRecalc() { return GMStore.get('last_recalc', 0); },
    saveLastRecalc(v) { GMStore.set('last_recalc', v); },

    getUsername() { return GMStore.get('username', ''); },
    saveUsername(v) { GMStore.set('username', v); },

    getFavoritesUrl() { return GMStore.get('fav_url', ''); },
    saveFavoritesUrl(v) { GMStore.set('fav_url', v); },

    getHistoryUrl() { return GMStore.get('hist_url', ''); },
    saveHistoryUrl(v) { GMStore.set('hist_url', v); },

    // Seen tracking: 记录本次/上次推荐过的ID, 用于降权
    getSeenAlbums() { return Storage.get('seen') || []; },
    saveSeenAlbums(v) { Storage.set('seen', v.slice(-CONFIG.MAX_SEEN_TRACK)); },
    addSeenAlbum(id) {
      const seen = this.getSeenAlbums();
      if (!seen.includes(id)) { seen.push(id); this.saveSeenAlbums(seen); }
    },

    // Viewed albums: 持久记录所有浏览过的本子 (不限于推荐, 用户浏览历史之外)
    // 注意: 这是个大型数组, 上限5000
    getViewedAlbums() { 
      const v = Storage.get('viewed') || [];
      // 补时间戳: 旧记录是纯ID字符串
      let changed = false; const now = Date.now();
      for (let i = 0; i < v.length; i++) {
        if (typeof v[i] === 'string') { v[i] = { id: v[i], viewedAt: now }; changed = true; }
        else if (!v[i].viewedAt) { v[i].viewedAt = now; changed = true; }
      }
      if (changed) this.saveViewedAlbums(v);
      return v;
    },
    saveViewedAlbums(v) { Storage.set('viewed', v.slice(-5000)); },
    addViewedAlbum(id) {
      const viewed = this.getViewedAlbums();
      const sid = String(id);
      if (!viewed.find(v => String(v.id || v) === sid)) {
        viewed.push({ id: sid, viewedAt: Date.now() });
        this.saveViewedAlbums(viewed);
        // 异步fetch标题 (不阻塞, 不在cache中就补)
        const cache = this.getAlbumCache();
        if (!cache[sid] || !cache[sid].title) {
          (async () => {
            try {
              const html = await fetcher.enqueue('https://18comic.vip/album/'+sid+'/', null, 1);
              if (html) { const d = Parser.parseDetail(html); if (d.title) { cache[sid] = { ...(cache[sid]||{}), id:sid, title:d.title, tags:d.tags||[], authors:d.authors||[], typeTags:d.typeTags||[] }; this.saveAlbumCache(cache); } }
            } catch(e) {}
          })();
        }
      }
    },
    clearViewedAlbums() { Storage.set('viewed', []); },
    getTagFreq() { return Storage.get('tagfreq') || {}; },
    saveTagFreq(v) { Storage.set('tagfreq', v); },
  };