Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions scripts/fix-local-book-chapter-namespace.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
#!/usr/bin/env python3
"""修复本地书章节命名空间错位(BUGFIX: 本地书章节不存在)。

背景: v6.0.45 及更早版本多个章节写入路径未填充 book_chapters.user_namespace,
落库为 'default';而读取路径严格按用户命名空间过滤。secure 模式下非 default
账号导入的本地书因此读不到正文(TOC 能列出,正文报「本地书章节不存在」)。
本脚本修复**既有数据**——把 user_namespace='default' 但归属书属于非 default
命名空间的章节行,修正为书所属命名空间。

用法:
python3 scripts/fix-local-book-chapter-namespace.py [--db path/to/reader.db]
[--dry-run] [--yes]

环境: 需先停止 reader-dev 服务(SQLite 写锁/备份一致性),建议先备份库再执行。
输出: 打印修正的章节行数(--dry-run 只统计不修改,exit 0)。
注意: 幂等——已修正的行不会再次匹配;default 命名空间书、非 default 已正确
归属的章节均不受影响。
"""
import argparse
import sqlite3
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
DEFAULT_DB = ROOT / "storage" / "reader.db"

# 相关子查询消歧:共享 book_url 归属多个命名空间时,确定性取字典序最小 ns。
# EXISTS 限定只处理“归属书存在非 default 命名空间”的章节,default 书不受影响。
FIX_SQL = """
UPDATE book_chapters
SET user_namespace = (
SELECT user_namespace FROM books
WHERE books.book_url = book_chapters.book_url
ORDER BY user_namespace LIMIT 1
)
WHERE book_chapters.user_namespace = 'default'
AND EXISTS (
SELECT 1 FROM books
WHERE books.book_url = book_chapters.book_url
AND books.user_namespace <> 'default'
)
"""

COUNT_SQL = """
SELECT COUNT(*) FROM book_chapters bc
WHERE bc.user_namespace = 'default'
AND EXISTS (
SELECT 1 FROM books b
WHERE b.book_url = bc.book_url AND b.user_namespace <> 'default'
)
"""

# 受影响章节按归属命名空间分组(dry-run 明细)
GROUP_SQL = """
SELECT b.user_namespace, COUNT(*)
FROM book_chapters bc
JOIN books b ON b.book_url = bc.book_url
WHERE bc.user_namespace = 'default'
AND b.user_namespace <> 'default'
GROUP BY b.user_namespace
ORDER BY b.user_namespace
"""


def main() -> int:
parser = argparse.ArgumentParser(
description="修复本地书章节 user_namespace 错位(先备份库再执行)"
)
parser.add_argument("--db", type=Path, default=DEFAULT_DB,
help=f"reader.db 路径(默认 {DEFAULT_DB})")
parser.add_argument("--dry-run", action="store_true",
help="只统计待修复章节,不修改数据")
parser.add_argument("--yes", action="store_true",
help="跳过执行前确认(建议仍先手动备份)")
args = parser.parse_args()

db_path = args.db.expanduser()
if not db_path.exists():
print(f"错误: 数据库不存在 {db_path}", file=sys.stderr)
return 1

conn = sqlite3.connect(str(db_path), timeout=30)
try:
pending = conn.execute(COUNT_SQL).fetchone()[0]
print(f"待修复章节: {pending} 行(user_namespace='default' 但归属非 default 书)")
if pending == 0:
print("无需修复(数据库已正确或本 bug 不影响本库)")
return 0

if args.dry_run:
print("\n受影响章节按归属命名空间分组(dry-run,未修改):")
for ns, cnt in conn.execute(GROUP_SQL).fetchall():
print(f" {ns}: {cnt} 行")
print("dry-run 完成,未修改任何数据")
return 0

if not args.yes:
answer = input(
f"将修改 {db_path} 中 {pending} 行章节——"
"执行前请确认已备份该库。继续? [y/N] "
)
if answer.strip().lower() not in ("y", "yes"):
print("已取消,未修改任何数据")
return 0

rows = conn.execute(FIX_SQL).rowcount
conn.commit()
print(f"已修正 {rows} 行章节的 user_namespace")
print("提示: 重新启动 reader-dev 后,既有本地书正文即可直接读取,无需重新导入")
return 0
finally:
conn.close()


if __name__ == "__main__":
raise SystemExit(main())
21 changes: 18 additions & 3 deletions src/api/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4980,7 +4980,7 @@ async fn refresh_local_book(
.iter()
.map(|c| (c.title.clone(), c.content.clone()))
.collect();
if let Err(e) = state.storage.save_chapters(&url, &pairs).await {
if let Err(e) = state.storage.save_chapters(&namespace, &url, &pairs).await {
tracing::error!("refreshLocalBook 章节入库失败: {e}");
return Json(ReturnData::err("刷新失败"));
}
Expand Down Expand Up @@ -11427,6 +11427,7 @@ mod tests {
state
.storage
.save_chapters(
"default",
"https://book.com/a",
&[("第一章".to_string(), "正文A".to_string())],
)
Expand All @@ -11435,6 +11436,7 @@ mod tests {
state
.storage
.save_chapters(
"default",
"https://book.com/b",
&[("第一章".to_string(), "正文B".to_string())],
)
Expand Down Expand Up @@ -11497,6 +11499,7 @@ mod tests {
state
.storage
.save_chapters(
"default",
"https://book.com/a",
&[("第一章".to_string(), "正文A2".to_string())],
)
Expand Down Expand Up @@ -11535,6 +11538,7 @@ mod tests {
state
.storage
.save_chapters(
"default",
"https://book.com/b",
&[("第一章".to_string(), "正文B".to_string())],
)
Expand Down Expand Up @@ -11587,7 +11591,7 @@ mod tests {
let local_url = "storage/data/default/本地书.txt";
state
.storage
.save_chapters(local_url, &[("第一章".to_string(), "本地正文".to_string())])
.save_chapters("default", local_url, &[("第一章".to_string(), "本地正文".to_string())])
.await
.unwrap();
state
Expand Down Expand Up @@ -11645,6 +11649,7 @@ mod tests {
state
.storage
.save_chapters(
"default",
"https://book.com/a",
&[
("第一章".to_string(), "正文一二三".to_string()),
Expand Down Expand Up @@ -14864,6 +14869,7 @@ mod tests {
state
.storage
.save_chapters(
"default",
"local://book1",
&[
("第一章".to_string(), "正文一甲乙".to_string()),
Expand Down Expand Up @@ -15023,6 +15029,7 @@ mod tests {
state
.storage
.save_chapters(
"default",
"local://book1",
&[
(
Expand Down Expand Up @@ -16007,6 +16014,7 @@ mod tests {
state
.storage
.save_chapters(
"default",
"local://abc",
&[
("第一章".to_string(), "第一段内容".to_string()),
Expand Down Expand Up @@ -16575,6 +16583,7 @@ mod tests {
state
.storage
.save_chapters(
"default",
"local://exp1",
&[
("第一章".to_string(), "正文一 <甲> & 乙。".to_string()),
Expand Down Expand Up @@ -16754,6 +16763,7 @@ mod tests {
state
.storage
.save_chapters(
"default",
"local://expfont",
&[("第一章".to_string(), "正文一。".to_string())],
)
Expand Down Expand Up @@ -17119,6 +17129,7 @@ mod tests {
state
.storage
.save_chapters(
"default",
"local://gbk1",
&[("第一章".to_string(), "正文😀结束。".to_string())],
)
Expand Down Expand Up @@ -17188,6 +17199,7 @@ mod tests {
state
.storage
.save_chapters(
"default",
"local://gbk2",
&[("第一章".to_string(), "纯中文正文。".to_string())],
)
Expand Down Expand Up @@ -17531,6 +17543,7 @@ mod tests {
state
.storage
.save_chapters(
"default",
book_url,
&[
("第一章".to_string(), "正文一".to_string()),
Expand Down Expand Up @@ -17758,6 +17771,7 @@ mod tests {
state
.storage
.save_chapters(
"default",
book_url,
&[
("第一章".to_string(), "正文一".to_string()),
Expand Down Expand Up @@ -17875,6 +17889,7 @@ mod tests {
state
.storage
.save_chapters(
"default",
book_url,
&[
("第一章".to_string(), "正文一".to_string()),
Expand Down Expand Up @@ -18333,7 +18348,7 @@ mod tests {
}
state
.storage
.save_chapters("https://b.com/1", &[("第一章".into(), "正文".into())])
.save_chapters("default", "https://b.com/1", &[("第一章".into(), "正文".into())])
.await
.unwrap();

Expand Down
3 changes: 2 additions & 1 deletion src/service/local_sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -913,6 +913,7 @@ mod tests {
.unwrap();
storage
.save_chapters(
"default",
&book_url,
&[
("第一章".to_string(), "正文一。".to_string()),
Expand Down Expand Up @@ -984,7 +985,7 @@ mod tests {
.await
.unwrap();
storage
.save_chapters(&book_url, &[("第一章".to_string(), "正文。".to_string())])
.save_chapters("default", &book_url, &[("第一章".to_string(), "正文。".to_string())])
.await
.unwrap();
}
Expand Down
3 changes: 2 additions & 1 deletion src/storage/migrate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -487,12 +487,13 @@ async fn migrate_chapter_cache(
continue;
};
sqlx::query(
"INSERT OR REPLACE INTO book_chapters (book_url, chapter_index, title, content) VALUES (?1, ?2, ?3, ?4)",
"INSERT OR REPLACE INTO book_chapters (book_url, chapter_index, title, content, user_namespace) VALUES (?1, ?2, ?3, ?4, ?5)",
)
.bind(&book_url)
.bind(idx)
.bind(&title)
.bind(&content)
.bind(ns)
.execute(&mut *tx)
.await?;
inserted += 1;
Expand Down
Loading