JM Shelf - Storage

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

이 스크립트는 직접 설치하는 용도가 아닙니다. 다른 스크립트에서 메타 지시문 // @require https://update.sleazyfork.org/scripts/581102/1842601/JM%20Shelf%20-%20Storage.js을(를) 사용하여 포함하는 라이브러리입니다.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 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); },
  };