| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725 |
- """本地存储服务 —— JSON/SQLite 文件持久化与数据备份."""
- from __future__ import annotations
- import json
- import logging
- import os
- import re
- import sqlite3
- from collections import defaultdict
- from contextlib import contextmanager, suppress
- from typing import Any
- from .author import Author
- from .paper import Paper
- from .paper_paths import LIBRARY_PDF_ROOT_DIR, category_slug_for_pdf_dir
- from ..settings import get_settings
- logger = logging.getLogger(__name__)
- class PaperDatabase:
- """SQLite 论文数据库 —— 论文 CRUD、全文搜索(FTS)、分类与标签管理."""
- def __init__(self, db_path: str | None = None) -> None:
- if db_path is None:
- db_path = os.path.join(os.path.abspath(get_settings().data_dir), "papers.db")
- self.db_path = db_path
- self._library_fts_ready = False
- self._ensure_directory()
- self._init_database()
- self._library_fts_ready = self._detect_fts_table()
- def _data_root(self) -> str:
- return os.path.dirname(os.path.abspath(self.db_path))
- def _abs_local_pdf(self, relpath: str | None) -> str | None:
- if not relpath or not str(relpath).strip():
- return None
- return os.path.normpath(os.path.join(self._data_root(), str(relpath).strip()))
- def _ensure_directory(self) -> None:
- os.makedirs(os.path.dirname(self.db_path), exist_ok=True)
- def _detect_fts_table(self) -> bool:
- try:
- return self._query(
- "SELECT 1 FROM sqlite_master WHERE type='table' AND name='papers_fts' LIMIT 1",
- fetch='one'
- ) is not None
- except Exception:
- return False
- @contextmanager
- def _get_connection(self):
- conn = sqlite3.connect(self.db_path)
- conn.row_factory = sqlite3.Row
- try:
- yield conn
- conn.commit()
- except Exception as e:
- conn.rollback()
- logger.error("Database transaction failed: %s", e)
- raise
- finally:
- conn.close()
- def _query(self, sql, params=(), fetch='all'):
- with self._get_connection() as conn:
- cur = conn.cursor()
- cur.execute(sql, params)
- if fetch == 'one':
- return cur.fetchone()
- if fetch == 'all':
- return cur.fetchall()
- return None
- def _ensure_column(self, conn: sqlite3.Connection, col_name: str, col_type: str = "TEXT") -> None:
- cur = conn.cursor()
- cur.execute("PRAGMA table_info(papers)")
- cols = [r[1] for r in cur.fetchall()]
- if col_name not in cols:
- cur.execute(f"ALTER TABLE papers ADD COLUMN {col_name} {col_type}")
- def _init_database(self) -> None:
- with self._get_connection() as conn:
- cursor = conn.cursor()
- cursor.execute("PRAGMA user_version")
- db_version = int(cursor.fetchone()[0])
- if db_version < 1:
- cursor.execute(
- """
- CREATE TABLE IF NOT EXISTS papers (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- title TEXT NOT NULL,
- abstract TEXT,
- doi TEXT UNIQUE,
- pmid TEXT UNIQUE,
- arxiv_id TEXT UNIQUE,
- pmc_id TEXT UNIQUE,
- journal TEXT,
- year INTEGER,
- volume TEXT,
- issue TEXT,
- pages TEXT,
- publisher TEXT,
- pdf_url TEXT,
- source_url TEXT,
- local_pdf_path TEXT,
- keywords TEXT,
- mesh_terms TEXT,
- "references" TEXT,
- citations INTEGER DEFAULT 0,
- source TEXT DEFAULT 'unknown',
- notes TEXT,
- tags TEXT,
- category TEXT,
- rating INTEGER,
- read_status TEXT DEFAULT 'unread',
- importance TEXT DEFAULT 'normal',
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
- updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
- )
- """
- )
- self._ensure_column(conn, "local_pdf_path")
- self._ensure_column(conn, "category")
- self._ensure_column(conn, "venue_type")
- cursor.execute(
- """
- CREATE TABLE IF NOT EXISTS authors (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- name TEXT NOT NULL,
- affiliation TEXT,
- email TEXT,
- orcid TEXT UNIQUE
- )
- """
- )
- cursor.execute(
- """
- CREATE TABLE IF NOT EXISTS paper_authors (
- paper_id INTEGER NOT NULL,
- author_id INTEGER NOT NULL,
- author_order INTEGER DEFAULT 0,
- PRIMARY KEY (paper_id, author_id),
- FOREIGN KEY (paper_id) REFERENCES papers(id) ON DELETE CASCADE,
- FOREIGN KEY (author_id) REFERENCES authors(id) ON DELETE CASCADE
- )
- """
- )
- cursor.execute("PRAGMA user_version = 1")
- db_version = 1
- if db_version < 2:
- cursor.executescript(
- """
- CREATE INDEX IF NOT EXISTS idx_papers_category ON papers(category);
- CREATE INDEX IF NOT EXISTS idx_papers_year ON papers(year);
- CREATE INDEX IF NOT EXISTS idx_papers_read_status ON papers(read_status);
- CREATE INDEX IF NOT EXISTS idx_papers_created_at ON papers(created_at);
- CREATE INDEX IF NOT EXISTS idx_category_year ON papers(category, year);
- """
- )
- try:
- cursor.executescript(
- """
- CREATE VIRTUAL TABLE IF NOT EXISTS papers_fts USING fts5(
- title, abstract,
- content='papers', content_rowid='id'
- );
- CREATE TRIGGER IF NOT EXISTS papers_ai AFTER INSERT ON papers BEGIN
- INSERT INTO papers_fts(rowid, title, abstract)
- VALUES (new.id, new.title, new.abstract);
- END;
- CREATE TRIGGER IF NOT EXISTS papers_ad AFTER DELETE ON papers BEGIN
- INSERT INTO papers_fts(papers_fts, rowid, title, abstract)
- VALUES ('delete', old.id, old.title, old.abstract);
- END;
- CREATE TRIGGER IF NOT EXISTS papers_au AFTER UPDATE ON papers BEGIN
- INSERT INTO papers_fts(papers_fts, rowid, title, abstract)
- VALUES ('delete', old.id, old.title, old.abstract);
- INSERT INTO papers_fts(rowid, title, abstract)
- VALUES (new.id, new.title, new.abstract);
- END;
- """
- )
- cursor.execute("INSERT INTO papers_fts(papers_fts) VALUES('rebuild')")
- except sqlite3.OperationalError as e:
- logger.warning("FTS5 不可用或未启用,跳过全文索引: %s", e)
- cursor.execute("PRAGMA user_version = 2")
- @staticmethod
- def _norm_id_field(val: str | None) -> str | None:
- s = (val or "").strip()
- return s if s else None
- def _sync_saved_meta(self, cursor: sqlite3.Cursor, paper_id: int, paper: Paper) -> None:
- cat = getattr(paper, "category", None)
- doi = self._norm_id_field(paper.doi)
- arxiv_id = self._norm_id_field(paper.arxiv_id)
- abs_new = (paper.abstract or "").strip() or None
- title_new = (paper.title or "").strip() or None
- cursor.execute(
- """UPDATE papers SET category = ?, tags = ?, pdf_url = ?, source_url = ?,
- doi = COALESCE(?, doi),
- arxiv_id = COALESCE(?, arxiv_id),
- abstract = COALESCE(?, abstract),
- title = COALESCE(?, title),
- venue_type = COALESCE(?, venue_type),
- updated_at = CURRENT_TIMESTAMP WHERE id = ?""",
- (
- cat,
- json.dumps(paper.tags or [], ensure_ascii=False),
- paper.pdf_url,
- paper.source_url,
- doi,
- arxiv_id,
- abs_new,
- title_new,
- getattr(paper, "venue_type", None),
- paper_id,
- ),
- )
- def _add_paper_internal(self, conn: sqlite3.Connection, paper: Paper) -> tuple[int, bool]:
- cursor = conn.cursor()
- doi = self._norm_id_field(paper.doi)
- arxiv_id = self._norm_id_field(paper.arxiv_id)
- pmid = self._norm_id_field(paper.pmid)
- pmc_id = self._norm_id_field(paper.pmc_id)
- for field, val in (("doi", doi), ("arxiv_id", arxiv_id), ("pmid", pmid), ("pmc_id", pmc_id)):
- if val:
- cursor.execute(f"SELECT id FROM papers WHERE {field} = ?", (val,))
- existing = cursor.fetchone()
- if existing:
- eid = int(existing[0])
- self._sync_saved_meta(cursor, eid, paper)
- return eid, False
- cursor.execute(
- """
- INSERT INTO papers (
- title, abstract, doi, pmid, arxiv_id, pmc_id,
- journal, year, volume, issue, pages, publisher,
- pdf_url, source_url, local_pdf_path, keywords, mesh_terms, "references",
- citations, source, notes, tags, category, venue_type, rating, read_status, importance
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
- """,
- (
- paper.title,
- paper.abstract,
- doi,
- pmid,
- arxiv_id,
- pmc_id,
- paper.journal,
- paper.year,
- paper.volume,
- paper.issue,
- paper.pages,
- paper.publisher,
- paper.pdf_url,
- paper.source_url,
- getattr(paper, "local_pdf_path", None),
- json.dumps(paper.keywords, ensure_ascii=False),
- json.dumps(paper.mesh_terms, ensure_ascii=False),
- json.dumps(paper.references, ensure_ascii=False),
- paper.citations,
- paper.source,
- paper.notes,
- json.dumps(paper.tags, ensure_ascii=False),
- getattr(paper, "category", None),
- getattr(paper, "venue_type", None),
- paper.rating,
- paper.read_status,
- paper.importance,
- ),
- )
- paper_id = cursor.lastrowid
- self._add_authors(conn, paper_id, paper.authors)
- return int(paper_id), True
- def add_paper(self, paper: Paper) -> tuple[int, bool]:
- with self._get_connection() as conn:
- return self._add_paper_internal(conn, paper)
- def add_papers(self, papers: list[Paper]) -> tuple[list[int], int, int]:
- ids: list[int] = []
- added = 0
- updated = 0
- with self._get_connection() as conn:
- for paper in papers:
- try:
- paper_id, is_new = self._add_paper_internal(conn, paper)
- ids.append(int(paper_id))
- if is_new:
- added += 1
- else:
- updated += 1
- except Exception as e:
- logger.error("批量添加文献时出错 '%s': %s", getattr(paper, "title", ""), e)
- ids.append(-1)
- return ids, added, updated
- def _add_authors(self, conn: sqlite3.Connection, paper_id: int, authors: list[Author]) -> None:
- cursor = conn.cursor()
- for order, author in enumerate(authors):
- if author.orcid:
- cursor.execute("SELECT id FROM authors WHERE orcid = ?", (author.orcid,))
- else:
- cursor.execute("SELECT id FROM authors WHERE name = ?", (author.name,))
- result = cursor.fetchone()
- if result:
- author_id = result[0]
- else:
- cursor.execute(
- "INSERT INTO authors (name, affiliation, email, orcid) VALUES (?, ?, ?, ?)",
- (author.name, author.affiliation, author.email, author.orcid),
- )
- author_id = cursor.lastrowid
- with suppress(sqlite3.IntegrityError):
- cursor.execute(
- "INSERT INTO paper_authors (paper_id, author_id, author_order) VALUES (?, ?, ?)",
- (paper_id, author_id, order),
- )
- def _fetch_authors_for_papers(
- self, conn: sqlite3.Connection, paper_ids: list[int]
- ) -> dict[int, list[Author]]:
- if not paper_ids:
- return {}
- cursor = conn.cursor()
- placeholders = ",".join("?" * len(paper_ids))
- cursor.execute(
- f"""
- SELECT pa.paper_id, a.* FROM authors a
- JOIN paper_authors pa ON a.id = pa.author_id
- WHERE pa.paper_id IN ({placeholders})
- ORDER BY pa.paper_id, pa.author_order
- """,
- paper_ids,
- )
- authors_by_paper: dict[int, list[Author]] = defaultdict(list)
- for row in cursor.fetchall():
- authors_by_paper[int(row["paper_id"])].append(
- Author(
- name=row["name"],
- affiliation=row["affiliation"],
- email=row["email"],
- orcid=row["orcid"],
- db_id=int(row["id"]) if row["id"] is not None else None,
- )
- )
- return dict(authors_by_paper)
- def _row_to_paper_fast(self, row: sqlite3.Row, authors: list[Author]) -> Paper:
- keys = row.keys()
- return Paper(
- id=row["id"],
- title=row["title"],
- authors=authors,
- abstract=row["abstract"],
- doi=row["doi"],
- pmid=row["pmid"],
- arxiv_id=row["arxiv_id"],
- pmc_id=row["pmc_id"],
- journal=row["journal"],
- year=row["year"],
- volume=row["volume"],
- issue=row["issue"],
- pages=row["pages"],
- publisher=row["publisher"],
- pdf_url=row["pdf_url"],
- source_url=row["source_url"],
- local_pdf_path=row["local_pdf_path"] if "local_pdf_path" in keys else None,
- keywords=json.loads(row["keywords"] or "[]"),
- mesh_terms=json.loads(row["mesh_terms"] or "[]"),
- references=json.loads(row["references"] or "[]"),
- citations=row["citations"] or 0,
- source=row["source"] or "unknown",
- notes=row["notes"],
- tags=json.loads(row["tags"] or "[]"),
- category=row["category"] if "category" in keys else None,
- venue_type=row["venue_type"] if "venue_type" in keys else None,
- rating=row["rating"],
- read_status=row["read_status"] or "unread",
- importance=row["importance"] or "normal",
- )
- def count_papers(self) -> int:
- return self._query("SELECT COUNT(*) FROM papers", fetch='one')[0]
- def get_all_papers(self, limit: int | None = None, offset: int = 0, order_by: str = "created_at DESC") -> list[Paper]:
- with self._get_connection() as conn:
- cursor = conn.cursor()
- query = f"SELECT * FROM papers ORDER BY {order_by}"
- if limit:
- query += f" LIMIT {int(limit)}"
- if offset:
- query += f" OFFSET {int(offset)}"
- cursor.execute(query)
- rows = cursor.fetchall()
- if not rows:
- return []
- paper_ids = [int(r["id"]) for r in rows]
- authors_map = self._fetch_authors_for_papers(conn, paper_ids)
- return [
- self._row_to_paper_fast(row, authors_map.get(int(row["id"]), []))
- for row in rows
- ]
- def get_paper_by_id(self, paper_id: int) -> Paper | None:
- row = self._query("SELECT * FROM papers WHERE id = ?", (paper_id,), fetch='one')
- if not row:
- return None
- with self._get_connection() as conn:
- authors_map = self._fetch_authors_for_papers(conn, [paper_id])
- return self._row_to_paper_fast(row, authors_map.get(paper_id, []))
- def search_library(
- self,
- query: str | None = None,
- tags: list[str] | None = None,
- year_from: int | None = None,
- year_to: int | None = None,
- read_status: str | None = None,
- category: str | None = None,
- limit: int = 100,
- offset: int = 0,
- ) -> list[Paper]:
- with self._get_connection() as conn:
- cursor = conn.cursor()
- clauses: list[str] = ["1=1"]
- params: list[Any] = []
- use_fts = False
- match_expr = ""
- clean_query = ""
- if query and str(query).strip():
- clean_query = re.sub(r'["\'*^]', " ", str(query)).strip()
- if clean_query and self._library_fts_ready:
- parts = [w for w in clean_query.split() if w.strip()]
- if parts:
- match_expr = " AND ".join(f'"{w}"' for w in parts)
- use_fts = True
- if use_fts:
- base_from = "papers p"
- clauses.append(
- "(p.id IN (SELECT rowid FROM papers_fts WHERE papers_fts MATCH ?)"
- " OR p.id IN (SELECT pa.paper_id FROM paper_authors pa JOIN authors a ON pa.author_id = a.id WHERE a.name LIKE ?))"
- )
- params.append(match_expr)
- like_author = f"%{clean_query}%"
- params.append(like_author)
- elif query and str(query).strip():
- clauses.append("(p.title LIKE ? OR p.abstract LIKE ? OR p.id IN (SELECT pa.paper_id FROM paper_authors pa JOIN authors a ON pa.author_id = a.id WHERE a.name LIKE ?))")
- like = f"%{str(query).strip()}%"
- params.extend([like, like, like])
- base_from = "papers p"
- else:
- base_from = "papers p"
- if category:
- cat = category.strip()
- if cat.endswith("/*"):
- prefix = cat[:-2].strip()
- if prefix == "未分类":
- clauses.append(
- "(p.category IS NULL OR TRIM(COALESCE(p.category, '')) IN ('', '未分类') "
- "OR TRIM(COALESCE(p.category, '')) LIKE '未分类/%')"
- )
- elif prefix:
- clauses.append(
- "(TRIM(COALESCE(p.category, '')) = ? OR TRIM(COALESCE(p.category, '')) LIKE ?)"
- )
- params.extend([prefix, prefix + "/%"])
- elif cat == "未分类":
- clauses.append(
- "(p.category IS NULL OR TRIM(COALESCE(p.category, '')) IN ('', '未分类'))"
- )
- else:
- clauses.append("TRIM(COALESCE(p.category, '')) = ?")
- params.append(cat)
- if year_from is not None:
- clauses.append("(p.year IS NOT NULL AND p.year >= ?)")
- params.append(year_from)
- if year_to is not None:
- clauses.append("(p.year IS NOT NULL AND p.year <= ?)")
- params.append(year_to)
- if read_status:
- clauses.append("p.read_status = ?")
- params.append(read_status)
- order_clause = "ORDER BY p.created_at DESC"
- sql = f"SELECT p.* FROM {base_from} WHERE {' AND '.join(clauses)} {order_clause} LIMIT ?"
- params.append(int(limit))
- if offset:
- sql += " OFFSET ?"
- params.append(int(offset))
- cursor.execute(sql, params)
- rows = cursor.fetchall()
- if not rows:
- return []
- paper_ids = [int(r["id"]) for r in rows]
- authors_map = self._fetch_authors_for_papers(conn, paper_ids)
- papers = [self._row_to_paper_fast(row, authors_map.get(int(row["id"]), [])) for row in rows]
- if tags:
- tag_set = set(tags)
- papers = [p for p in papers if tag_set.intersection(set(p.tags))]
- return papers
- def update_paper(self, paper_id: int, **fields) -> bool:
- allowed = {"notes", "tags", "rating", "read_status", "importance", "category", "abstract"}
- updates = {k: v for k, v in fields.items() if k in allowed and v is not None}
- if not updates:
- return False
- if "tags" in updates and isinstance(updates["tags"], list):
- updates["tags"] = json.dumps(updates["tags"], ensure_ascii=False)
- set_parts = [f"{k} = ?" for k in updates]
- values = list(updates.values()) + [paper_id]
- with self._get_connection() as conn:
- cursor = conn.cursor()
- cursor.execute(
- f"UPDATE papers SET {', '.join(set_parts)}, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
- values,
- )
- return cursor.rowcount > 0
- def set_local_pdf_path(self, paper_id: int, relative_path: str | None) -> bool:
- with self._get_connection() as conn:
- cursor = conn.cursor()
- cursor.execute(
- "UPDATE papers SET local_pdf_path = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
- (relative_path, paper_id),
- )
- return cursor.rowcount > 0
- def delete_paper(self, paper_id: int) -> bool:
- with self._get_connection() as conn:
- cursor = conn.cursor()
- cursor.execute("SELECT local_pdf_path FROM papers WHERE id = ?", (paper_id,))
- row = cursor.fetchone()
- if row and row[0]:
- abspath = self._abs_local_pdf(row[0])
- if abspath and os.path.isfile(abspath):
- with suppress(OSError):
- os.remove(abspath)
- cursor.execute("DELETE FROM paper_authors WHERE paper_id = ?", (paper_id,))
- cursor.execute("DELETE FROM papers WHERE id = ?", (paper_id,))
- return cursor.rowcount > 0
- def repair_library_local_pdf_paths_batch(self, paper_ids: list[int]) -> dict[int, str]:
- want = {int(x) for x in paper_ids if x is not None and int(x) >= 0}
- if not want:
- return {}
- data_root = self._data_root()
- lib_root = os.path.join(data_root, LIBRARY_PDF_ROOT_DIR)
- if not os.path.isdir(lib_root):
- return {}
- candidates: dict[int, list[tuple[float, str]]] = {k: [] for k in want}
- name_pat = re.compile(r"^(\d+)\.pdf$")
- for dirpath, _, filenames in os.walk(lib_root):
- for fn in filenames:
- m = name_pat.match(fn)
- if not m:
- continue
- pid = int(m.group(1))
- if pid not in want:
- continue
- full = os.path.join(dirpath, fn)
- try:
- mt = os.path.getmtime(full)
- except OSError:
- continue
- rel = os.path.relpath(full, data_root).replace("\\", "/")
- if rel.startswith(".."):
- continue
- candidates[pid].append((mt, rel))
- out: dict[int, str] = {}
- for pid, rows in candidates.items():
- if not rows:
- continue
- rows.sort(key=lambda x: -x[0])
- best_rel = rows[0][1]
- if self.set_local_pdf_path(pid, best_rel):
- out[pid] = best_rel
- return out
- def get_library_pdf_abspath(self, paper_id: int) -> str | None:
- p = self.get_paper_by_id(paper_id)
- if not p or not (getattr(p, "local_pdf_path", None) or "").strip():
- return None
- rel = (p.local_pdf_path or "").strip()
- candidates = [self._abs_local_pdf(rel)]
- if rel.startswith(f"{LIBRARY_PDF_ROOT_DIR}/"):
- candidates.append(
- self._abs_local_pdf("pdfs/" + rel[len(LIBRARY_PDF_ROOT_DIR) + 1 :])
- )
- elif rel.startswith("pdfs/"):
- candidates.append(
- self._abs_local_pdf(f"{LIBRARY_PDF_ROOT_DIR}/" + rel[len("pdfs/") :])
- )
- root = os.path.realpath(self._data_root())
- for abspath in candidates:
- if not abspath or not os.path.isfile(abspath):
- continue
- real_f = os.path.realpath(abspath)
- if real_f != root and not real_f.startswith(root + os.sep):
- continue
- return real_f
- return None
- def list_library_category_folders(self) -> list[dict[str, Any]]:
- rows = self._query(
- """
- SELECT COALESCE(NULLIF(TRIM(category), ''), '未分类') AS c, COUNT(*) AS n
- FROM papers
- GROUP BY c
- ORDER BY n DESC, c ASC
- """
- )
- standalone: dict[str, int] = {}
- by_parent: dict[str, list[dict[str, Any]]] = defaultdict(list)
- for row in rows:
- c = row["c"] or "未分类"
- n = int(row["n"])
- if "/" not in c:
- standalone[c] = standalone.get(c, 0) + n
- continue
- parts = [p.strip() for p in c.split("/") if p.strip()]
- if len(parts) < 2:
- standalone[c] = standalone.get(c, 0) + n
- continue
- parent = parts[0]
- label = "/".join(parts[1:])
- by_parent[parent].append(
- {
- "category": c,
- "label": label,
- "folder": category_slug_for_pdf_dir(c),
- "count": n,
- }
- )
- consumed_standalone: set[str] = set()
- out: list[dict[str, Any]] = []
- for parent in sorted(
- by_parent.keys(),
- key=lambda p: (-sum(x["count"] for x in by_parent[p]), p),
- ):
- ch = sorted(by_parent[parent], key=lambda x: (-x["count"], x["label"]))
- extra = standalone.get(parent, 0)
- total = sum(x["count"] for x in ch) + extra
- children: list[dict[str, Any]] = []
- if extra > 0:
- children.append(
- {
- "category": parent,
- "label": "未分子类",
- "folder": category_slug_for_pdf_dir(parent),
- "count": extra,
- }
- )
- consumed_standalone.add(parent)
- children.extend(ch)
- out.append(
- {
- "category": parent,
- "folder": category_slug_for_pdf_dir(parent),
- "count": total,
- "children": children,
- }
- )
- for cat, n in standalone.items():
- if cat in consumed_standalone:
- continue
- out.append(
- {
- "category": cat,
- "folder": category_slug_for_pdf_dir(cat),
- "count": n,
- "children": [],
- }
- )
- out.sort(key=lambda x: (-x["count"], x["category"]))
- return out
- def list_library_categories_by_count(self, limit: int = 80) -> list[str]:
- limit = int(limit or 0)
- if limit <= 0:
- limit = 80
- rows = self._query(
- """
- SELECT COALESCE(NULLIF(TRIM(category), ''), '未分类') AS c, COUNT(*) AS n
- FROM papers
- GROUP BY c
- ORDER BY n DESC, c ASC
- LIMIT ?
- """,
- (limit,),
- )
- out: list[str] = []
- for r in rows:
- c = (r["c"] or "").strip() or "未分类"
- if c not in out:
- out.append(c)
- return out
|