storage.py 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725
  1. """本地存储服务 —— JSON/SQLite 文件持久化与数据备份."""
  2. from __future__ import annotations
  3. import json
  4. import logging
  5. import os
  6. import re
  7. import sqlite3
  8. from collections import defaultdict
  9. from contextlib import contextmanager, suppress
  10. from typing import Any
  11. from .author import Author
  12. from .paper import Paper
  13. from .paper_paths import LIBRARY_PDF_ROOT_DIR, category_slug_for_pdf_dir
  14. from ..settings import get_settings
  15. logger = logging.getLogger(__name__)
  16. class PaperDatabase:
  17. """SQLite 论文数据库 —— 论文 CRUD、全文搜索(FTS)、分类与标签管理."""
  18. def __init__(self, db_path: str | None = None) -> None:
  19. if db_path is None:
  20. db_path = os.path.join(os.path.abspath(get_settings().data_dir), "papers.db")
  21. self.db_path = db_path
  22. self._library_fts_ready = False
  23. self._ensure_directory()
  24. self._init_database()
  25. self._library_fts_ready = self._detect_fts_table()
  26. def _data_root(self) -> str:
  27. return os.path.dirname(os.path.abspath(self.db_path))
  28. def _abs_local_pdf(self, relpath: str | None) -> str | None:
  29. if not relpath or not str(relpath).strip():
  30. return None
  31. return os.path.normpath(os.path.join(self._data_root(), str(relpath).strip()))
  32. def _ensure_directory(self) -> None:
  33. os.makedirs(os.path.dirname(self.db_path), exist_ok=True)
  34. def _detect_fts_table(self) -> bool:
  35. try:
  36. return self._query(
  37. "SELECT 1 FROM sqlite_master WHERE type='table' AND name='papers_fts' LIMIT 1",
  38. fetch='one'
  39. ) is not None
  40. except Exception:
  41. return False
  42. @contextmanager
  43. def _get_connection(self):
  44. conn = sqlite3.connect(self.db_path)
  45. conn.row_factory = sqlite3.Row
  46. try:
  47. yield conn
  48. conn.commit()
  49. except Exception as e:
  50. conn.rollback()
  51. logger.error("Database transaction failed: %s", e)
  52. raise
  53. finally:
  54. conn.close()
  55. def _query(self, sql, params=(), fetch='all'):
  56. with self._get_connection() as conn:
  57. cur = conn.cursor()
  58. cur.execute(sql, params)
  59. if fetch == 'one':
  60. return cur.fetchone()
  61. if fetch == 'all':
  62. return cur.fetchall()
  63. return None
  64. def _ensure_column(self, conn: sqlite3.Connection, col_name: str, col_type: str = "TEXT") -> None:
  65. cur = conn.cursor()
  66. cur.execute("PRAGMA table_info(papers)")
  67. cols = [r[1] for r in cur.fetchall()]
  68. if col_name not in cols:
  69. cur.execute(f"ALTER TABLE papers ADD COLUMN {col_name} {col_type}")
  70. def _init_database(self) -> None:
  71. with self._get_connection() as conn:
  72. cursor = conn.cursor()
  73. cursor.execute("PRAGMA user_version")
  74. db_version = int(cursor.fetchone()[0])
  75. if db_version < 1:
  76. cursor.execute(
  77. """
  78. CREATE TABLE IF NOT EXISTS papers (
  79. id INTEGER PRIMARY KEY AUTOINCREMENT,
  80. title TEXT NOT NULL,
  81. abstract TEXT,
  82. doi TEXT UNIQUE,
  83. pmid TEXT UNIQUE,
  84. arxiv_id TEXT UNIQUE,
  85. pmc_id TEXT UNIQUE,
  86. journal TEXT,
  87. year INTEGER,
  88. volume TEXT,
  89. issue TEXT,
  90. pages TEXT,
  91. publisher TEXT,
  92. pdf_url TEXT,
  93. source_url TEXT,
  94. local_pdf_path TEXT,
  95. keywords TEXT,
  96. mesh_terms TEXT,
  97. "references" TEXT,
  98. citations INTEGER DEFAULT 0,
  99. source TEXT DEFAULT 'unknown',
  100. notes TEXT,
  101. tags TEXT,
  102. category TEXT,
  103. rating INTEGER,
  104. read_status TEXT DEFAULT 'unread',
  105. importance TEXT DEFAULT 'normal',
  106. created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  107. updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
  108. )
  109. """
  110. )
  111. self._ensure_column(conn, "local_pdf_path")
  112. self._ensure_column(conn, "category")
  113. self._ensure_column(conn, "venue_type")
  114. cursor.execute(
  115. """
  116. CREATE TABLE IF NOT EXISTS authors (
  117. id INTEGER PRIMARY KEY AUTOINCREMENT,
  118. name TEXT NOT NULL,
  119. affiliation TEXT,
  120. email TEXT,
  121. orcid TEXT UNIQUE
  122. )
  123. """
  124. )
  125. cursor.execute(
  126. """
  127. CREATE TABLE IF NOT EXISTS paper_authors (
  128. paper_id INTEGER NOT NULL,
  129. author_id INTEGER NOT NULL,
  130. author_order INTEGER DEFAULT 0,
  131. PRIMARY KEY (paper_id, author_id),
  132. FOREIGN KEY (paper_id) REFERENCES papers(id) ON DELETE CASCADE,
  133. FOREIGN KEY (author_id) REFERENCES authors(id) ON DELETE CASCADE
  134. )
  135. """
  136. )
  137. cursor.execute("PRAGMA user_version = 1")
  138. db_version = 1
  139. if db_version < 2:
  140. cursor.executescript(
  141. """
  142. CREATE INDEX IF NOT EXISTS idx_papers_category ON papers(category);
  143. CREATE INDEX IF NOT EXISTS idx_papers_year ON papers(year);
  144. CREATE INDEX IF NOT EXISTS idx_papers_read_status ON papers(read_status);
  145. CREATE INDEX IF NOT EXISTS idx_papers_created_at ON papers(created_at);
  146. CREATE INDEX IF NOT EXISTS idx_category_year ON papers(category, year);
  147. """
  148. )
  149. try:
  150. cursor.executescript(
  151. """
  152. CREATE VIRTUAL TABLE IF NOT EXISTS papers_fts USING fts5(
  153. title, abstract,
  154. content='papers', content_rowid='id'
  155. );
  156. CREATE TRIGGER IF NOT EXISTS papers_ai AFTER INSERT ON papers BEGIN
  157. INSERT INTO papers_fts(rowid, title, abstract)
  158. VALUES (new.id, new.title, new.abstract);
  159. END;
  160. CREATE TRIGGER IF NOT EXISTS papers_ad AFTER DELETE ON papers BEGIN
  161. INSERT INTO papers_fts(papers_fts, rowid, title, abstract)
  162. VALUES ('delete', old.id, old.title, old.abstract);
  163. END;
  164. CREATE TRIGGER IF NOT EXISTS papers_au AFTER UPDATE ON papers BEGIN
  165. INSERT INTO papers_fts(papers_fts, rowid, title, abstract)
  166. VALUES ('delete', old.id, old.title, old.abstract);
  167. INSERT INTO papers_fts(rowid, title, abstract)
  168. VALUES (new.id, new.title, new.abstract);
  169. END;
  170. """
  171. )
  172. cursor.execute("INSERT INTO papers_fts(papers_fts) VALUES('rebuild')")
  173. except sqlite3.OperationalError as e:
  174. logger.warning("FTS5 不可用或未启用,跳过全文索引: %s", e)
  175. cursor.execute("PRAGMA user_version = 2")
  176. @staticmethod
  177. def _norm_id_field(val: str | None) -> str | None:
  178. s = (val or "").strip()
  179. return s if s else None
  180. def _sync_saved_meta(self, cursor: sqlite3.Cursor, paper_id: int, paper: Paper) -> None:
  181. cat = getattr(paper, "category", None)
  182. doi = self._norm_id_field(paper.doi)
  183. arxiv_id = self._norm_id_field(paper.arxiv_id)
  184. abs_new = (paper.abstract or "").strip() or None
  185. title_new = (paper.title or "").strip() or None
  186. cursor.execute(
  187. """UPDATE papers SET category = ?, tags = ?, pdf_url = ?, source_url = ?,
  188. doi = COALESCE(?, doi),
  189. arxiv_id = COALESCE(?, arxiv_id),
  190. abstract = COALESCE(?, abstract),
  191. title = COALESCE(?, title),
  192. venue_type = COALESCE(?, venue_type),
  193. updated_at = CURRENT_TIMESTAMP WHERE id = ?""",
  194. (
  195. cat,
  196. json.dumps(paper.tags or [], ensure_ascii=False),
  197. paper.pdf_url,
  198. paper.source_url,
  199. doi,
  200. arxiv_id,
  201. abs_new,
  202. title_new,
  203. getattr(paper, "venue_type", None),
  204. paper_id,
  205. ),
  206. )
  207. def _add_paper_internal(self, conn: sqlite3.Connection, paper: Paper) -> tuple[int, bool]:
  208. cursor = conn.cursor()
  209. doi = self._norm_id_field(paper.doi)
  210. arxiv_id = self._norm_id_field(paper.arxiv_id)
  211. pmid = self._norm_id_field(paper.pmid)
  212. pmc_id = self._norm_id_field(paper.pmc_id)
  213. for field, val in (("doi", doi), ("arxiv_id", arxiv_id), ("pmid", pmid), ("pmc_id", pmc_id)):
  214. if val:
  215. cursor.execute(f"SELECT id FROM papers WHERE {field} = ?", (val,))
  216. existing = cursor.fetchone()
  217. if existing:
  218. eid = int(existing[0])
  219. self._sync_saved_meta(cursor, eid, paper)
  220. return eid, False
  221. cursor.execute(
  222. """
  223. INSERT INTO papers (
  224. title, abstract, doi, pmid, arxiv_id, pmc_id,
  225. journal, year, volume, issue, pages, publisher,
  226. pdf_url, source_url, local_pdf_path, keywords, mesh_terms, "references",
  227. citations, source, notes, tags, category, venue_type, rating, read_status, importance
  228. ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
  229. """,
  230. (
  231. paper.title,
  232. paper.abstract,
  233. doi,
  234. pmid,
  235. arxiv_id,
  236. pmc_id,
  237. paper.journal,
  238. paper.year,
  239. paper.volume,
  240. paper.issue,
  241. paper.pages,
  242. paper.publisher,
  243. paper.pdf_url,
  244. paper.source_url,
  245. getattr(paper, "local_pdf_path", None),
  246. json.dumps(paper.keywords, ensure_ascii=False),
  247. json.dumps(paper.mesh_terms, ensure_ascii=False),
  248. json.dumps(paper.references, ensure_ascii=False),
  249. paper.citations,
  250. paper.source,
  251. paper.notes,
  252. json.dumps(paper.tags, ensure_ascii=False),
  253. getattr(paper, "category", None),
  254. getattr(paper, "venue_type", None),
  255. paper.rating,
  256. paper.read_status,
  257. paper.importance,
  258. ),
  259. )
  260. paper_id = cursor.lastrowid
  261. self._add_authors(conn, paper_id, paper.authors)
  262. return int(paper_id), True
  263. def add_paper(self, paper: Paper) -> tuple[int, bool]:
  264. with self._get_connection() as conn:
  265. return self._add_paper_internal(conn, paper)
  266. def add_papers(self, papers: list[Paper]) -> tuple[list[int], int, int]:
  267. ids: list[int] = []
  268. added = 0
  269. updated = 0
  270. with self._get_connection() as conn:
  271. for paper in papers:
  272. try:
  273. paper_id, is_new = self._add_paper_internal(conn, paper)
  274. ids.append(int(paper_id))
  275. if is_new:
  276. added += 1
  277. else:
  278. updated += 1
  279. except Exception as e:
  280. logger.error("批量添加文献时出错 '%s': %s", getattr(paper, "title", ""), e)
  281. ids.append(-1)
  282. return ids, added, updated
  283. def _add_authors(self, conn: sqlite3.Connection, paper_id: int, authors: list[Author]) -> None:
  284. cursor = conn.cursor()
  285. for order, author in enumerate(authors):
  286. if author.orcid:
  287. cursor.execute("SELECT id FROM authors WHERE orcid = ?", (author.orcid,))
  288. else:
  289. cursor.execute("SELECT id FROM authors WHERE name = ?", (author.name,))
  290. result = cursor.fetchone()
  291. if result:
  292. author_id = result[0]
  293. else:
  294. cursor.execute(
  295. "INSERT INTO authors (name, affiliation, email, orcid) VALUES (?, ?, ?, ?)",
  296. (author.name, author.affiliation, author.email, author.orcid),
  297. )
  298. author_id = cursor.lastrowid
  299. with suppress(sqlite3.IntegrityError):
  300. cursor.execute(
  301. "INSERT INTO paper_authors (paper_id, author_id, author_order) VALUES (?, ?, ?)",
  302. (paper_id, author_id, order),
  303. )
  304. def _fetch_authors_for_papers(
  305. self, conn: sqlite3.Connection, paper_ids: list[int]
  306. ) -> dict[int, list[Author]]:
  307. if not paper_ids:
  308. return {}
  309. cursor = conn.cursor()
  310. placeholders = ",".join("?" * len(paper_ids))
  311. cursor.execute(
  312. f"""
  313. SELECT pa.paper_id, a.* FROM authors a
  314. JOIN paper_authors pa ON a.id = pa.author_id
  315. WHERE pa.paper_id IN ({placeholders})
  316. ORDER BY pa.paper_id, pa.author_order
  317. """,
  318. paper_ids,
  319. )
  320. authors_by_paper: dict[int, list[Author]] = defaultdict(list)
  321. for row in cursor.fetchall():
  322. authors_by_paper[int(row["paper_id"])].append(
  323. Author(
  324. name=row["name"],
  325. affiliation=row["affiliation"],
  326. email=row["email"],
  327. orcid=row["orcid"],
  328. db_id=int(row["id"]) if row["id"] is not None else None,
  329. )
  330. )
  331. return dict(authors_by_paper)
  332. def _row_to_paper_fast(self, row: sqlite3.Row, authors: list[Author]) -> Paper:
  333. keys = row.keys()
  334. return Paper(
  335. id=row["id"],
  336. title=row["title"],
  337. authors=authors,
  338. abstract=row["abstract"],
  339. doi=row["doi"],
  340. pmid=row["pmid"],
  341. arxiv_id=row["arxiv_id"],
  342. pmc_id=row["pmc_id"],
  343. journal=row["journal"],
  344. year=row["year"],
  345. volume=row["volume"],
  346. issue=row["issue"],
  347. pages=row["pages"],
  348. publisher=row["publisher"],
  349. pdf_url=row["pdf_url"],
  350. source_url=row["source_url"],
  351. local_pdf_path=row["local_pdf_path"] if "local_pdf_path" in keys else None,
  352. keywords=json.loads(row["keywords"] or "[]"),
  353. mesh_terms=json.loads(row["mesh_terms"] or "[]"),
  354. references=json.loads(row["references"] or "[]"),
  355. citations=row["citations"] or 0,
  356. source=row["source"] or "unknown",
  357. notes=row["notes"],
  358. tags=json.loads(row["tags"] or "[]"),
  359. category=row["category"] if "category" in keys else None,
  360. venue_type=row["venue_type"] if "venue_type" in keys else None,
  361. rating=row["rating"],
  362. read_status=row["read_status"] or "unread",
  363. importance=row["importance"] or "normal",
  364. )
  365. def count_papers(self) -> int:
  366. return self._query("SELECT COUNT(*) FROM papers", fetch='one')[0]
  367. def get_all_papers(self, limit: int | None = None, offset: int = 0, order_by: str = "created_at DESC") -> list[Paper]:
  368. with self._get_connection() as conn:
  369. cursor = conn.cursor()
  370. query = f"SELECT * FROM papers ORDER BY {order_by}"
  371. if limit:
  372. query += f" LIMIT {int(limit)}"
  373. if offset:
  374. query += f" OFFSET {int(offset)}"
  375. cursor.execute(query)
  376. rows = cursor.fetchall()
  377. if not rows:
  378. return []
  379. paper_ids = [int(r["id"]) for r in rows]
  380. authors_map = self._fetch_authors_for_papers(conn, paper_ids)
  381. return [
  382. self._row_to_paper_fast(row, authors_map.get(int(row["id"]), []))
  383. for row in rows
  384. ]
  385. def get_paper_by_id(self, paper_id: int) -> Paper | None:
  386. row = self._query("SELECT * FROM papers WHERE id = ?", (paper_id,), fetch='one')
  387. if not row:
  388. return None
  389. with self._get_connection() as conn:
  390. authors_map = self._fetch_authors_for_papers(conn, [paper_id])
  391. return self._row_to_paper_fast(row, authors_map.get(paper_id, []))
  392. def search_library(
  393. self,
  394. query: str | None = None,
  395. tags: list[str] | None = None,
  396. year_from: int | None = None,
  397. year_to: int | None = None,
  398. read_status: str | None = None,
  399. category: str | None = None,
  400. limit: int = 100,
  401. offset: int = 0,
  402. ) -> list[Paper]:
  403. with self._get_connection() as conn:
  404. cursor = conn.cursor()
  405. clauses: list[str] = ["1=1"]
  406. params: list[Any] = []
  407. use_fts = False
  408. match_expr = ""
  409. clean_query = ""
  410. if query and str(query).strip():
  411. clean_query = re.sub(r'["\'*^]', " ", str(query)).strip()
  412. if clean_query and self._library_fts_ready:
  413. parts = [w for w in clean_query.split() if w.strip()]
  414. if parts:
  415. match_expr = " AND ".join(f'"{w}"' for w in parts)
  416. use_fts = True
  417. if use_fts:
  418. base_from = "papers p"
  419. clauses.append(
  420. "(p.id IN (SELECT rowid FROM papers_fts WHERE papers_fts MATCH ?)"
  421. " 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 ?))"
  422. )
  423. params.append(match_expr)
  424. like_author = f"%{clean_query}%"
  425. params.append(like_author)
  426. elif query and str(query).strip():
  427. 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 ?))")
  428. like = f"%{str(query).strip()}%"
  429. params.extend([like, like, like])
  430. base_from = "papers p"
  431. else:
  432. base_from = "papers p"
  433. if category:
  434. cat = category.strip()
  435. if cat.endswith("/*"):
  436. prefix = cat[:-2].strip()
  437. if prefix == "未分类":
  438. clauses.append(
  439. "(p.category IS NULL OR TRIM(COALESCE(p.category, '')) IN ('', '未分类') "
  440. "OR TRIM(COALESCE(p.category, '')) LIKE '未分类/%')"
  441. )
  442. elif prefix:
  443. clauses.append(
  444. "(TRIM(COALESCE(p.category, '')) = ? OR TRIM(COALESCE(p.category, '')) LIKE ?)"
  445. )
  446. params.extend([prefix, prefix + "/%"])
  447. elif cat == "未分类":
  448. clauses.append(
  449. "(p.category IS NULL OR TRIM(COALESCE(p.category, '')) IN ('', '未分类'))"
  450. )
  451. else:
  452. clauses.append("TRIM(COALESCE(p.category, '')) = ?")
  453. params.append(cat)
  454. if year_from is not None:
  455. clauses.append("(p.year IS NOT NULL AND p.year >= ?)")
  456. params.append(year_from)
  457. if year_to is not None:
  458. clauses.append("(p.year IS NOT NULL AND p.year <= ?)")
  459. params.append(year_to)
  460. if read_status:
  461. clauses.append("p.read_status = ?")
  462. params.append(read_status)
  463. order_clause = "ORDER BY p.created_at DESC"
  464. sql = f"SELECT p.* FROM {base_from} WHERE {' AND '.join(clauses)} {order_clause} LIMIT ?"
  465. params.append(int(limit))
  466. if offset:
  467. sql += " OFFSET ?"
  468. params.append(int(offset))
  469. cursor.execute(sql, params)
  470. rows = cursor.fetchall()
  471. if not rows:
  472. return []
  473. paper_ids = [int(r["id"]) for r in rows]
  474. authors_map = self._fetch_authors_for_papers(conn, paper_ids)
  475. papers = [self._row_to_paper_fast(row, authors_map.get(int(row["id"]), [])) for row in rows]
  476. if tags:
  477. tag_set = set(tags)
  478. papers = [p for p in papers if tag_set.intersection(set(p.tags))]
  479. return papers
  480. def update_paper(self, paper_id: int, **fields) -> bool:
  481. allowed = {"notes", "tags", "rating", "read_status", "importance", "category", "abstract"}
  482. updates = {k: v for k, v in fields.items() if k in allowed and v is not None}
  483. if not updates:
  484. return False
  485. if "tags" in updates and isinstance(updates["tags"], list):
  486. updates["tags"] = json.dumps(updates["tags"], ensure_ascii=False)
  487. set_parts = [f"{k} = ?" for k in updates]
  488. values = list(updates.values()) + [paper_id]
  489. with self._get_connection() as conn:
  490. cursor = conn.cursor()
  491. cursor.execute(
  492. f"UPDATE papers SET {', '.join(set_parts)}, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
  493. values,
  494. )
  495. return cursor.rowcount > 0
  496. def set_local_pdf_path(self, paper_id: int, relative_path: str | None) -> bool:
  497. with self._get_connection() as conn:
  498. cursor = conn.cursor()
  499. cursor.execute(
  500. "UPDATE papers SET local_pdf_path = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
  501. (relative_path, paper_id),
  502. )
  503. return cursor.rowcount > 0
  504. def delete_paper(self, paper_id: int) -> bool:
  505. with self._get_connection() as conn:
  506. cursor = conn.cursor()
  507. cursor.execute("SELECT local_pdf_path FROM papers WHERE id = ?", (paper_id,))
  508. row = cursor.fetchone()
  509. if row and row[0]:
  510. abspath = self._abs_local_pdf(row[0])
  511. if abspath and os.path.isfile(abspath):
  512. with suppress(OSError):
  513. os.remove(abspath)
  514. cursor.execute("DELETE FROM paper_authors WHERE paper_id = ?", (paper_id,))
  515. cursor.execute("DELETE FROM papers WHERE id = ?", (paper_id,))
  516. return cursor.rowcount > 0
  517. def repair_library_local_pdf_paths_batch(self, paper_ids: list[int]) -> dict[int, str]:
  518. want = {int(x) for x in paper_ids if x is not None and int(x) >= 0}
  519. if not want:
  520. return {}
  521. data_root = self._data_root()
  522. lib_root = os.path.join(data_root, LIBRARY_PDF_ROOT_DIR)
  523. if not os.path.isdir(lib_root):
  524. return {}
  525. candidates: dict[int, list[tuple[float, str]]] = {k: [] for k in want}
  526. name_pat = re.compile(r"^(\d+)\.pdf$")
  527. for dirpath, _, filenames in os.walk(lib_root):
  528. for fn in filenames:
  529. m = name_pat.match(fn)
  530. if not m:
  531. continue
  532. pid = int(m.group(1))
  533. if pid not in want:
  534. continue
  535. full = os.path.join(dirpath, fn)
  536. try:
  537. mt = os.path.getmtime(full)
  538. except OSError:
  539. continue
  540. rel = os.path.relpath(full, data_root).replace("\\", "/")
  541. if rel.startswith(".."):
  542. continue
  543. candidates[pid].append((mt, rel))
  544. out: dict[int, str] = {}
  545. for pid, rows in candidates.items():
  546. if not rows:
  547. continue
  548. rows.sort(key=lambda x: -x[0])
  549. best_rel = rows[0][1]
  550. if self.set_local_pdf_path(pid, best_rel):
  551. out[pid] = best_rel
  552. return out
  553. def get_library_pdf_abspath(self, paper_id: int) -> str | None:
  554. p = self.get_paper_by_id(paper_id)
  555. if not p or not (getattr(p, "local_pdf_path", None) or "").strip():
  556. return None
  557. rel = (p.local_pdf_path or "").strip()
  558. candidates = [self._abs_local_pdf(rel)]
  559. if rel.startswith(f"{LIBRARY_PDF_ROOT_DIR}/"):
  560. candidates.append(
  561. self._abs_local_pdf("pdfs/" + rel[len(LIBRARY_PDF_ROOT_DIR) + 1 :])
  562. )
  563. elif rel.startswith("pdfs/"):
  564. candidates.append(
  565. self._abs_local_pdf(f"{LIBRARY_PDF_ROOT_DIR}/" + rel[len("pdfs/") :])
  566. )
  567. root = os.path.realpath(self._data_root())
  568. for abspath in candidates:
  569. if not abspath or not os.path.isfile(abspath):
  570. continue
  571. real_f = os.path.realpath(abspath)
  572. if real_f != root and not real_f.startswith(root + os.sep):
  573. continue
  574. return real_f
  575. return None
  576. def list_library_category_folders(self) -> list[dict[str, Any]]:
  577. rows = self._query(
  578. """
  579. SELECT COALESCE(NULLIF(TRIM(category), ''), '未分类') AS c, COUNT(*) AS n
  580. FROM papers
  581. GROUP BY c
  582. ORDER BY n DESC, c ASC
  583. """
  584. )
  585. standalone: dict[str, int] = {}
  586. by_parent: dict[str, list[dict[str, Any]]] = defaultdict(list)
  587. for row in rows:
  588. c = row["c"] or "未分类"
  589. n = int(row["n"])
  590. if "/" not in c:
  591. standalone[c] = standalone.get(c, 0) + n
  592. continue
  593. parts = [p.strip() for p in c.split("/") if p.strip()]
  594. if len(parts) < 2:
  595. standalone[c] = standalone.get(c, 0) + n
  596. continue
  597. parent = parts[0]
  598. label = "/".join(parts[1:])
  599. by_parent[parent].append(
  600. {
  601. "category": c,
  602. "label": label,
  603. "folder": category_slug_for_pdf_dir(c),
  604. "count": n,
  605. }
  606. )
  607. consumed_standalone: set[str] = set()
  608. out: list[dict[str, Any]] = []
  609. for parent in sorted(
  610. by_parent.keys(),
  611. key=lambda p: (-sum(x["count"] for x in by_parent[p]), p),
  612. ):
  613. ch = sorted(by_parent[parent], key=lambda x: (-x["count"], x["label"]))
  614. extra = standalone.get(parent, 0)
  615. total = sum(x["count"] for x in ch) + extra
  616. children: list[dict[str, Any]] = []
  617. if extra > 0:
  618. children.append(
  619. {
  620. "category": parent,
  621. "label": "未分子类",
  622. "folder": category_slug_for_pdf_dir(parent),
  623. "count": extra,
  624. }
  625. )
  626. consumed_standalone.add(parent)
  627. children.extend(ch)
  628. out.append(
  629. {
  630. "category": parent,
  631. "folder": category_slug_for_pdf_dir(parent),
  632. "count": total,
  633. "children": children,
  634. }
  635. )
  636. for cat, n in standalone.items():
  637. if cat in consumed_standalone:
  638. continue
  639. out.append(
  640. {
  641. "category": cat,
  642. "folder": category_slug_for_pdf_dir(cat),
  643. "count": n,
  644. "children": [],
  645. }
  646. )
  647. out.sort(key=lambda x: (-x["count"], x["category"]))
  648. return out
  649. def list_library_categories_by_count(self, limit: int = 80) -> list[str]:
  650. limit = int(limit or 0)
  651. if limit <= 0:
  652. limit = 80
  653. rows = self._query(
  654. """
  655. SELECT COALESCE(NULLIF(TRIM(category), ''), '未分类') AS c, COUNT(*) AS n
  656. FROM papers
  657. GROUP BY c
  658. ORDER BY n DESC, c ASC
  659. LIMIT ?
  660. """,
  661. (limit,),
  662. )
  663. out: list[str] = []
  664. for r in rows:
  665. c = (r["c"] or "").strip() or "未分类"
  666. if c not in out:
  667. out.append(c)
  668. return out