JM Shelf - Storage

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

此腳本不應該直接安裝,它是一個供其他腳本使用的函式庫。欲使用本函式庫,請在腳本 metadata 寫上: // @require https://update.sleazyfork.org/scripts/581102/1842601/JM%20Shelf%20-%20Storage.js

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==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); },
  };