app.py 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200
  1. import uuid
  2. from werkzeug.utils import secure_filename
  3. import os
  4. from urllib.parse import urlparse
  5. import urllib.parse
  6. import requests
  7. from bs4 import BeautifulSoup
  8. from flask import Flask
  9. import os
  10. from flask import render_template, request, redirect, url_for, flash, jsonify, abort
  11. from flask_login import LoginManager, login_user, logout_user, login_required, current_user
  12. from werkzeug.utils import secure_filename
  13. from werkzeug.security import generate_password_hash, check_password_hash
  14. from datetime import datetime
  15. from flask import Flask
  16. import os
  17. from flask import render_template, request, redirect, url_for, flash, jsonify, abort, make_response, session
  18. from flask import send_from_directory
  19. from config import Config
  20. from models import db, User, Category, Site
  21. from forms import LoginForm, RegisterForm, SiteForm, CategoryForm, ImportBookmarksForm, SearchForm
  22. from forms import UserForm # 确保这行存在
  23. import tempfile
  24. import json
  25. import atexit
  26. import glob
  27. # 创建Flask应用
  28. app = Flask(__name__)
  29. app.config.from_object(Config)
  30. # Ensure instance folder exists
  31. instance_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'instance')
  32. if not os.path.exists(instance_path):
  33. os.makedirs(instance_path)
  34. # 确保instance文件夹存在
  35. try:
  36. os.makedirs(app.instance_path)
  37. except OSError:
  38. pass
  39. # 初始化数据库
  40. db.init_app(app)
  41. with app.app_context():
  42. print(f"Database URI: {app.config['SQLALCHEMY_DATABASE_URI']}")
  43. try:
  44. db.create_all()
  45. print("Database tables created successfully.")
  46. except Exception as e:
  47. print(f"Error creating database tables: {e}")
  48. # 配置登录管理
  49. login_manager = LoginManager()
  50. login_manager.init_app(app)
  51. login_manager.login_view = 'login'
  52. login_manager.login_message = '请先登录以访问此页面'
  53. @login_manager.user_loader
  54. def load_user(user_id):
  55. return db.session.get(User, int(user_id))
  56. # 工具函数
  57. def get_favicon_url(url):
  58. """获取网站 favicon,如果失败返回默认图标"""
  59. try:
  60. parsed = urllib.parse.urlparse(url)
  61. if not parsed.scheme:
  62. url = 'http://' + url
  63. parsed = urllib.parse.urlparse(url)
  64. domain = parsed.netloc
  65. if not domain:
  66. return url_for('static', filename='images/default-icon.png')
  67. # 尝试标准位置
  68. favicon_url = f"{parsed.scheme}://{domain}/favicon.ico"
  69. resp = requests.head(favicon_url, allow_redirects=True, timeout=3)
  70. if resp.status_code == 200:
  71. return favicon_url
  72. # 尝试网页中 link rel="icon"
  73. resp = requests.get(url, timeout=5)
  74. if resp.status_code == 200:
  75. soup = BeautifulSoup(resp.text, 'html.parser')
  76. icon_link = soup.find('link', rel=lambda x: x and 'icon' in x.lower())
  77. if icon_link and icon_link.get('href'):
  78. icon_href = icon_link['href']
  79. if icon_href.startswith('http'):
  80. return icon_href
  81. elif icon_href.startswith('/'):
  82. return f"{parsed.scheme}://{domain}{icon_href}"
  83. else:
  84. return f"{parsed.scheme}://{domain}/{icon_href}"
  85. return url_for('static', filename='images/default-icon.png')
  86. except:
  87. return url_for('static', filename='images/default-icon.png')
  88. def parse_bookmarks_html(file_path):
  89. """解析浏览器书签HTML文件"""
  90. with open(file_path, 'r', encoding='utf-8') as f:
  91. soup = BeautifulSoup(f.read(), 'html.parser')
  92. # 查找书签栏和文件夹
  93. bookmarks = []
  94. folders = {}
  95. # 查找所有h3标签(文件夹)和对应的dl(书签列表)
  96. for h3 in soup.find_all('h3'):
  97. folder_name = h3.string
  98. if not folder_name:
  99. continue
  100. dl = h3.find_next_sibling('dl')
  101. if not dl:
  102. continue
  103. folder_bookmarks = []
  104. for a in dl.find_all('a'):
  105. href = a.get('href')
  106. name = a.string
  107. if not href or not name:
  108. continue
  109. # 获取描述(通常是添加日期或其他属性)
  110. description = a.get('add_date', '') or a.get('icon', '') or ''
  111. folder_bookmarks.append({
  112. 'name': name.strip(),
  113. 'url': href.strip(),
  114. 'description': description.strip()
  115. })
  116. if folder_bookmarks:
  117. folders[folder_name.strip()] = folder_bookmarks
  118. return folders
  119. def create_default_admin():
  120. try:
  121. # 先检查是否已存在管理员账户
  122. existing_admin = User.query.filter_by(email='admin@example.com').first()
  123. if existing_admin:
  124. print("默认管理员账户已存在")
  125. return
  126. # 创建新管理员
  127. admin = User(
  128. username='admin',
  129. email='admin@example.com',
  130. is_admin=True
  131. )
  132. admin.set_password('admin123') # 设置默认密码
  133. db.session.add(admin)
  134. db.session.commit()
  135. print("成功创建默认管理员账户")
  136. except Exception as e:
  137. db.session.rollback()
  138. print(f"创建管理员账户时出错: {str(e)}")
  139. def create_default_categories():
  140. # 如果已经有任何分类存在,说明已经初始化过了,不用重复创建
  141. if Category.query.first() is not None:
  142. return
  143. # 找到默认管理员用户
  144. admin = User.query.filter_by(email='admin@example.com').first()
  145. if not admin:
  146. print("[警告] 未找到默认管理员用户,无法创建默认分类")
  147. return
  148. default_categories = [
  149. '搜索引擎', '社交媒体', '新闻资讯', '购物网站', '工具网站', '娱乐休闲'
  150. ]
  151. for cat_name in default_categories:
  152. category = Category(
  153. name=cat_name,
  154. user_id=admin.id, # ✅ 关键:指定管理员的 ID 作为分类拥有者
  155. is_public=True
  156. )
  157. db.session.add(category)
  158. db.session.commit()
  159. print(f"[INFO] 已成功创建 {len(default_categories)} 个默认分类,归属于管理员 {admin.username}")
  160. # API 路由定义
  161. @app.route('/api/category/<int:category_id>', methods=['GET'])
  162. def get_category(category_id):
  163. category = Category.query.get_or_404(category_id)
  164. return jsonify({
  165. 'id': category.id,
  166. 'name': category.name,
  167. 'is_public': category.is_public
  168. })
  169. @app.route('/api/category', methods=['POST'])
  170. def api_add_category():
  171. data = request.get_json()
  172. if not data or 'name' not in data:
  173. return jsonify({'success': False, 'message': '分类名称不能为空'}), 400
  174. category = Category(
  175. name=data['name'],
  176. user_id=current_user.id,
  177. is_public=data.get('is_public', False)
  178. )
  179. db.session.add(category)
  180. db.session.commit()
  181. return jsonify({'success': True, 'message': '分类添加成功'})
  182. @app.route('/api/category/<int:category_id>', methods=['PUT'])
  183. def api_update_category(category_id):
  184. category = Category.query.get_or_404(category_id)
  185. data = request.get_json()
  186. if not data or 'name' not in data:
  187. return jsonify({'success': False, 'message': '分类名称不能为空'}), 400
  188. category.name = data['name']
  189. category.is_public = data.get('is_public', False)
  190. db.session.commit()
  191. return jsonify({'success': True, 'message': '分类更新成功'})
  192. # 路由定义
  193. # 首页路由
  194. @app.route('/')
  195. def index():
  196. search_form = SearchForm()
  197. # 公共站点:is_public=True 的站点 + 分类也是公开的
  198. public_sites_query = Site.query.join(Category).filter(
  199. Site.is_public == True,
  200. Category.is_public == True
  201. )
  202. if search_form.q.data and search_form.validate():
  203. q = search_form.q.data.strip()
  204. public_sites_query = public_sites_query.filter(
  205. Site.name.contains(q) |
  206. Site.description.contains(q) |
  207. Site.url.contains(q)
  208. )
  209. public_sites = public_sites_query.order_by(Site.created_at.asc()).all()
  210. # 公共分类
  211. public_categories = Category.query.filter_by(is_public=True).order_by(Category.name).all()
  212. # 我的私有站点和分类(仅登录用户)
  213. my_sites = []
  214. my_categories = []
  215. if current_user.is_authenticated:
  216. my_sites = Site.query.filter_by(user_id=current_user.id).order_by(Site.created_at.asc()).all()
  217. my_categories = Category.query.filter_by(user_id=current_user.id).order_by(Category.name).all()
  218. saved_engine = request.cookies.get('search-engine', 'local')
  219. return render_template('index.html',
  220. public_sites=public_sites,
  221. public_categories=public_categories,
  222. my_sites=my_sites,
  223. my_categories=my_categories,
  224. search_form=search_form,
  225. saved_engine=saved_engine)
  226. @app.route('/register', methods=['GET', 'POST'])
  227. def register():
  228. if current_user.is_authenticated:
  229. return redirect(url_for('index'))
  230. form = RegisterForm()
  231. if form.validate_on_submit():
  232. # 检查用户名和邮箱是否已存在
  233. if User.query.filter_by(username=form.username.data).first():
  234. flash('用户名已存在', 'danger')
  235. return render_template('register.html', form=form)
  236. if User.query.filter_by(email=form.email.data).first():
  237. flash('邮箱已存在', 'danger')
  238. return render_template('register.html', form=form)
  239. # 创建新用户
  240. user = User(
  241. username=form.username.data,
  242. email=form.email.data
  243. )
  244. user.set_password(form.password.data)
  245. db.session.add(user)
  246. db.session.commit()
  247. flash('注册成功,请登录', 'success')
  248. return redirect(url_for('login'))
  249. return render_template('register.html', form=form)
  250. @app.route('/login', methods=['GET', 'POST'])
  251. def login():
  252. if current_user.is_authenticated:
  253. return redirect(url_for('index'))
  254. form = LoginForm()
  255. if form.validate_on_submit():
  256. user = User.query.filter_by(username=form.username.data).first()
  257. if user is None or not user.check_password(form.password.data):
  258. flash('用户名或密码错误', 'danger')
  259. return render_template('login.html', form=form)
  260. login_user(user, remember=True)
  261. next_page = request.args.get('next')
  262. if not next_page or not next_page.startswith('/'):
  263. next_page = url_for('index')
  264. return redirect(next_page)
  265. return render_template('login.html', form=form)
  266. # 添加成员路由
  267. @app.route('/admin/user/add', methods=['GET', 'POST'])
  268. @login_required
  269. def add_user():
  270. if not current_user.is_admin:
  271. abort(403)
  272. form = UserForm()
  273. if form.validate_on_submit():
  274. user = User(
  275. username=form.username.data,
  276. email=form.email.data,
  277. is_admin=form.is_admin.data
  278. )
  279. user.set_password(form.password.data)
  280. db.session.add(user)
  281. db.session.commit()
  282. flash('用户添加成功', 'success')
  283. return redirect(url_for('admin')) # 修改为admin
  284. return render_template('manage_user.html', form=form, action='add')
  285. # 同样修改其他相关视图函数中的重定向
  286. @app.route('/admin/user/<int:user_id>/edit', methods=['GET', 'POST'])
  287. @login_required
  288. def edit_user(user_id):
  289. if not current_user.is_admin:
  290. abort(403)
  291. user = User.query.get_or_404(user_id)
  292. form = UserForm(obj=user)
  293. if form.validate_on_submit():
  294. user.username = form.username.data
  295. user.email = form.email.data
  296. user.is_admin = form.is_admin.data
  297. if form.password.data:
  298. user.set_password(form.password.data)
  299. db.session.commit()
  300. flash('用户信息更新成功', 'success')
  301. return redirect(url_for('admin')) # 修改为admin
  302. return render_template('manage_user.html',
  303. form=form,
  304. action='edit',
  305. user=user,
  306. title=f'编辑用户 - {user.username}')
  307. # 删除成员路由
  308. @app.route('/admin/user/<int:user_id>/delete', methods=['POST'])
  309. @login_required
  310. def delete_user(user_id):
  311. if not current_user.is_admin:
  312. abort(403)
  313. user = User.query.get_or_404(user_id)
  314. if user.id == current_user.id:
  315. flash('不能删除当前登录用户', 'danger')
  316. return redirect(url_for('admin'))
  317. db.session.delete(user)
  318. db.session.commit()
  319. flash('用户删除成功', 'success')
  320. return redirect(url_for('admin'))
  321. @app.route('/logout')
  322. @login_required
  323. def logout():
  324. logout_user()
  325. return redirect(url_for('index'))
  326. @app.route('/dashboard')
  327. @login_required
  328. def dashboard():
  329. # 获取用户的分类和站点
  330. categories = Category.query.filter_by(user_id=current_user.id).order_by(Category.name).all()
  331. sites = Site.query.filter_by(user_id=current_user.id).order_by(Site.created_at.desc()).all()
  332. # 获取用户收藏的公共分类和站点
  333. favorite_categories = current_user.favorite_categories
  334. favorite_sites = current_user.favorite_sites
  335. return render_template('dashboard.html',
  336. categories=categories,
  337. sites=sites,
  338. favorite_categories=favorite_categories,
  339. favorite_sites=favorite_sites)
  340. # 在admin路由中修改查询条件
  341. @app.route('/admin')
  342. @login_required
  343. def admin():
  344. if not current_user.is_admin:
  345. abort(403)
  346. users = User.query.all()
  347. # 确保查询的是公共分类
  348. public_categories = Category.query.filter_by(is_public=True).order_by(Category.name).all()
  349. # 确保查询的是公共站点
  350. public_sites = Site.query.filter_by(is_public=True).order_by(Site.created_at.desc()).all()
  351. return render_template('admin_dashboard.html',
  352. users=users,
  353. public_categories=public_categories,
  354. public_sites=public_sites)
  355. @app.route('/category/add', methods=['GET', 'POST'])
  356. @login_required
  357. def add_category():
  358. form = CategoryForm()
  359. if form.validate_on_submit():
  360. category = Category(
  361. name=form.name.data,
  362. user_id=current_user.id,
  363. is_public=form.is_public.data
  364. )
  365. db.session.add(category)
  366. db.session.commit()
  367. flash('分类添加成功', 'success')
  368. return redirect(url_for('dashboard'))
  369. return render_template('manage_categories.html', form=form, action='add')
  370. @app.route('/site/add', methods=['GET', 'POST'])
  371. @login_required
  372. def add_site():
  373. form = SiteForm()
  374. # 使用单个查询获取用户的分类以及公共分类,避免重复
  375. from sqlalchemy import or_
  376. all_categories = Category.query.filter(
  377. or_(
  378. Category.user_id == current_user.id,
  379. Category.is_public == True
  380. )
  381. ).all()
  382. form.category_id.choices = [(c.id, c.name) for c in all_categories]
  383. if form.validate_on_submit():
  384. site = Site(
  385. name=form.name.data,
  386. url=form.url.data,
  387. description=form.description.data,
  388. category_id=form.category_id.data,
  389. user_id=current_user.id,
  390. is_public=form.is_public.data
  391. )
  392. # 处理自定义图标
  393. if form.custom_icon.data:
  394. upload_folder = os.path.join(app.root_path, 'static', 'uploads', 'icons')
  395. os.makedirs(upload_folder, exist_ok=True)
  396. file_ext = os.path.splitext(form.custom_icon.data.filename)[1]
  397. filename = f"icon_{current_user.id}_{form.name.data}_{int(datetime.now().timestamp())}{file_ext}"
  398. filename = secure_filename(filename)
  399. file_path = os.path.join(upload_folder, filename)
  400. form.custom_icon.data.save(file_path)
  401. site.custom_icon = url_for('static', filename=f'uploads/icons/{filename}')
  402. site.icon = None
  403. else:
  404. # 自动抓取 favicon
  405. site.icon = get_favicon_url(site.url)
  406. site.custom_icon = None
  407. db.session.add(site)
  408. db.session.commit()
  409. flash('站点添加成功', 'success')
  410. return redirect(url_for('dashboard'))
  411. return render_template('manage_sites.html', form=form, action='add')
  412. @app.route('/site/<int:site_id>/edit', methods=['GET', 'POST'])
  413. @login_required
  414. def edit_site(site_id):
  415. site = Site.query.get_or_404(site_id)
  416. # 权限检查:站点所有者或管理员
  417. if site.user_id != current_user.id and not current_user.is_admin:
  418. abort(403)
  419. form = SiteForm()
  420. # 获取用户分类 + 公共分类
  421. user_categories = Category.query.filter_by(user_id=current_user.id).all()
  422. public_categories = Category.query.filter_by(is_public=True).all()
  423. all_categories = user_categories + public_categories
  424. form.category_id.choices = [(c.id, c.name) for c in all_categories]
  425. if form.validate_on_submit():
  426. custom_icon_url = site.custom_icon # 默认保持原图标
  427. # 如果上传了新自定义图标
  428. if form.custom_icon.data:
  429. upload_folder = os.path.join(app.root_path, 'static', 'uploads', 'icons')
  430. os.makedirs(upload_folder, exist_ok=True)
  431. file_ext = os.path.splitext(form.custom_icon.data.filename)[1]
  432. filename = f"icon_{current_user.id}_{form.name.data}_{int(datetime.now().timestamp())}{file_ext}"
  433. filename = secure_filename(filename)
  434. file_path = os.path.join(upload_folder, filename)
  435. form.custom_icon.data.save(file_path)
  436. custom_icon_url = url_for('static', filename=f'uploads/icons/{filename}')
  437. # 更新站点信息
  438. site.name = form.name.data
  439. site.url = form.url.data
  440. site.description = form.description.data
  441. site.category_id = form.category_id.data
  442. site.is_public = form.is_public.data
  443. if custom_icon_url:
  444. # 优先使用自定义图标
  445. site.custom_icon = custom_icon_url
  446. site.icon = None
  447. else:
  448. # 如果没有自定义图标,则抓取网站 favicon
  449. site.icon = get_favicon_url(site.url)
  450. site.custom_icon = None
  451. db.session.commit()
  452. flash('站点更新成功', 'success')
  453. return redirect(url_for('dashboard'))
  454. # GET 请求时预填充表单
  455. if request.method == 'GET':
  456. form.name.data = site.name
  457. form.url.data = site.url
  458. form.description.data = site.description
  459. form.category_id.data = site.category_id
  460. form.is_public.data = site.is_public
  461. return render_template('manage_sites.html', form=form, action='edit', site=site)
  462. @app.route('/site/<int:site_id>/delete', methods=['POST'])
  463. @login_required
  464. def delete_site(site_id):
  465. site = Site.query.get_or_404(site_id)
  466. # 只允许站点所有者或管理员删除
  467. if site.user_id != current_user.id and not current_user.is_admin:
  468. abort(403)
  469. db.session.delete(site)
  470. db.session.commit()
  471. flash('站点删除成功', 'success')
  472. return redirect(url_for('dashboard'))
  473. @app.route('/category/<int:category_id>/delete', methods=['POST'])
  474. @login_required
  475. def delete_category(category_id):
  476. category = Category.query.get_or_404(category_id)
  477. # 只允许分类所有者或管理员删除
  478. if category.user_id != current_user.id and not current_user.is_admin:
  479. abort(403)
  480. # 确保不删除公共分类(除非是管理员)
  481. if category.is_public and not current_user.is_admin:
  482. abort(403)
  483. db.session.delete(category)
  484. db.session.commit()
  485. flash('分类删除成功', 'success')
  486. return redirect(url_for('dashboard'))
  487. import json
  488. from flask import jsonify, request
  489. from sqlalchemy import or_
  490. @app.route('/import-bookmarks', methods=['GET', 'POST'])
  491. @login_required
  492. def import_bookmarks():
  493. form = ImportBookmarksForm()
  494. if form.validate_on_submit():
  495. # 保存上传的文件
  496. f = form.bookmark_file.data
  497. filename = secure_filename(f.filename)
  498. # 使用临时文件
  499. import uuid
  500. unique_filename = f"{uuid.uuid4().hex}_{filename}"
  501. file_path = os.path.join(tempfile.gettempdir(), unique_filename)
  502. # 确保目录存在
  503. os.makedirs(os.path.dirname(file_path), exist_ok=True)
  504. f.save(file_path)
  505. try:
  506. # 解析书签文件
  507. bookmarks = parse_bookmarks_html(file_path)
  508. # 准备预览数据
  509. preview_data = {
  510. 'file_token': unique_filename,
  511. 'total_count': 0,
  512. 'folders': [],
  513. 'import_as_public': form.import_as_public.data and current_user.is_admin
  514. }
  515. # 分析重复项
  516. existing_urls = {site.url for site in Site.query.filter_by(user_id=current_user.id).all()}
  517. for folder_name, folder_bookmarks in bookmarks.items():
  518. folder_data = {
  519. 'name': folder_name,
  520. 'bookmarks': [],
  521. 'new_count': 0,
  522. 'duplicate_count': 0
  523. }
  524. for bm in folder_bookmarks:
  525. is_duplicate = bm['url'] in existing_urls
  526. bookmark_data = {
  527. 'name': bm['name'],
  528. 'url': bm['url'],
  529. 'description': bm.get('description', ''),
  530. 'is_duplicate': is_duplicate,
  531. 'selected': not is_duplicate
  532. }
  533. folder_data['bookmarks'].append(bookmark_data)
  534. if not is_duplicate:
  535. folder_data['new_count'] += 1
  536. else:
  537. folder_data['duplicate_count'] += 1
  538. preview_data['total_count'] += 1
  539. preview_data['folders'].append(folder_data)
  540. # 将预览数据保存到临时文件,而不是session
  541. preview_file_path = os.path.join(tempfile.gettempdir(), f"preview_{unique_filename}.json")
  542. with open(preview_file_path, 'w', encoding='utf-8') as f:
  543. json.dump(preview_data, f, ensure_ascii=False, indent=2)
  544. # 只在session中存储文件路径
  545. session['preview_file'] = preview_file_path
  546. # 重定向到预览页面
  547. return redirect(url_for('preview_bookmarks'))
  548. except Exception as e:
  549. app.logger.error(f"书签解析失败: {str(e)}")
  550. flash(f'文件解析失败: {str(e)}', 'danger')
  551. finally:
  552. # 保留文件用于后续导入
  553. pass
  554. return render_template('import_bookmarks.html', form=form)
  555. def parse_bookmarks_html(file_path):
  556. """解析浏览器书签HTML文件"""
  557. with open(file_path, 'r', encoding='utf-8') as f:
  558. soup = BeautifulSoup(f.read(), 'html.parser')
  559. bookmarks = {}
  560. # 查找所有书签链接
  561. for a in soup.find_all('a'):
  562. href = a.get('href')
  563. name = a.text.strip()
  564. if href and name:
  565. # 获取父级文件夹名称
  566. folder_name = '导入的书签' # 默认文件夹名
  567. parent = a.find_parent('dl')
  568. if parent:
  569. h3 = parent.find_previous_sibling('h3')
  570. if h3:
  571. folder_name = h3.text.strip()
  572. if folder_name not in bookmarks:
  573. bookmarks[folder_name] = []
  574. bookmarks[folder_name].append({
  575. 'name': name,
  576. 'url': href,
  577. 'description': a.get('add_date', '') or a.get('last_modified', '')
  578. })
  579. return bookmarks
  580. @app.route('/preview-bookmarks')
  581. @login_required
  582. def preview_bookmarks():
  583. """书签导入预览页面"""
  584. preview_file_path = session.get('preview_file')
  585. if not preview_file_path or not os.path.exists(preview_file_path):
  586. flash('请先上传书签文件', 'warning')
  587. return redirect(url_for('import_bookmarks'))
  588. try:
  589. # 从文件加载预览数据
  590. with open(preview_file_path, 'r', encoding='utf-8') as f:
  591. preview_data = json.load(f)
  592. except Exception as e:
  593. app.logger.error(f"加载预览数据失败: {str(e)}")
  594. flash('预览数据加载失败,请重新上传', 'warning')
  595. return redirect(url_for('import_bookmarks'))
  596. # 获取用户现有分类,用于编辑时选择
  597. user_categories = Category.query.filter_by(user_id=current_user.id).all()
  598. return render_template('preview_bookmarks.html',
  599. preview_data=preview_data,
  600. user_categories=user_categories)
  601. @app.route('/api/import-bookmarks', methods=['POST'])
  602. @login_required
  603. def api_import_bookmarks():
  604. """API接口:执行书签导入(支持进度查询)"""
  605. print("收到导入请求")
  606. if request.method == 'POST':
  607. try:
  608. data = request.get_json()
  609. print(f"请求数据: {data}")
  610. selected_bookmarks = data.get('selected_bookmarks', [])
  611. import_as_public = data.get('import_as_public', False)
  612. print(f"要导入的书签数量: {len(selected_bookmarks)}")
  613. print(f"导入为公开: {import_as_public}")
  614. # 创建导入任务ID
  615. import_id = str(uuid.uuid4())
  616. print(f"创建任务ID: {import_id}")
  617. # 将导入任务信息存储在临时文件中,而不是session
  618. import_task = {
  619. 'id': import_id,
  620. 'total': len(selected_bookmarks),
  621. 'processed': 0,
  622. 'status': 'processing',
  623. 'results': {
  624. 'success': 0,
  625. 'failed': 0,
  626. 'errors': []
  627. }
  628. }
  629. # 保存导入任务到临时文件
  630. task_file_path = os.path.join(tempfile.gettempdir(), f"task_{import_id}.json")
  631. with open(task_file_path, 'w', encoding='utf-8') as f:
  632. json.dump(import_task, f, ensure_ascii=False, indent=2)
  633. # 在session中只存储任务ID
  634. session['import_task_id'] = import_id
  635. # 异步执行导入(这里简化为同步)
  636. print("开始执行导入...")
  637. execute_import(import_id, selected_bookmarks, import_as_public, current_user.id)
  638. print("导入执行完成")
  639. return jsonify({
  640. 'import_id': import_id,
  641. 'status': 'started',
  642. 'total': len(selected_bookmarks)
  643. })
  644. except Exception as e:
  645. print(f"API处理错误: {e}")
  646. return jsonify({'error': str(e)}), 500
  647. @app.route('/api/import-progress/<import_id>')
  648. @login_required
  649. def api_import_progress(import_id):
  650. """查询导入进度"""
  651. print(f"查询进度: {import_id}")
  652. # 从临时文件加载任务信息
  653. task_file_path = os.path.join(tempfile.gettempdir(), f"task_{import_id}.json")
  654. print(f"任务文件路径: {task_file_path}")
  655. if not os.path.exists(task_file_path):
  656. print("任务文件不存在")
  657. return jsonify({'error': '任务不存在'}), 404
  658. try:
  659. with open(task_file_path, 'r', encoding='utf-8') as f:
  660. task = json.load(f)
  661. print(f"任务状态: {task}")
  662. return jsonify({
  663. 'status': task['status'],
  664. 'processed': task['processed'],
  665. 'total': task['total'],
  666. 'results': task['results']
  667. })
  668. except Exception as e:
  669. print(f"读取任务文件错误: {e}")
  670. return jsonify({'error': '任务数据损坏'}), 500
  671. def execute_import(import_id, selected_bookmarks, import_as_public, user_id):
  672. """执行实际的书签导入"""
  673. # 从临时文件加载任务信息
  674. task_file_path = os.path.join(tempfile.gettempdir(), f"task_{import_id}.json")
  675. try:
  676. with open(task_file_path, 'r', encoding='utf-8') as f:
  677. task = json.load(f)
  678. except Exception as e:
  679. print(f"无法加载任务文件: {e}")
  680. return
  681. try:
  682. categories_cache = {} # 缓存分类对象
  683. for i, bookmark in enumerate(selected_bookmarks):
  684. # 更新进度
  685. task['processed'] = i + 1
  686. # 保存进度到文件
  687. with open(task_file_path, 'w', encoding='utf-8') as f:
  688. json.dump(task, f, ensure_ascii=False, indent=2)
  689. try:
  690. folder_name = bookmark.get('folder', '导入的书签')
  691. custom_category = bookmark.get('custom_category')
  692. # 确定分类
  693. if custom_category:
  694. folder_name = custom_category
  695. # 获取或创建分类
  696. if folder_name not in categories_cache:
  697. category = Category.query.filter_by(
  698. name=folder_name,
  699. user_id=user_id
  700. ).first()
  701. if not category:
  702. category = Category(
  703. name=folder_name,
  704. user_id=user_id,
  705. is_public=import_as_public
  706. )
  707. db.session.add(category)
  708. db.session.flush() # 获取ID但不提交事务
  709. categories_cache[folder_name] = category
  710. category = categories_cache[folder_name]
  711. # 检查是否已存在
  712. existing_site = Site.query.filter_by(
  713. url=bookmark['url'],
  714. user_id=user_id
  715. ).first()
  716. if existing_site:
  717. task['results']['failed'] += 1
  718. task['results']['errors'].append(f"书签已存在: {bookmark['name']}")
  719. continue
  720. # 创建新站点
  721. site = Site(
  722. name=bookmark['name'][:100],
  723. url=bookmark['url'][:500],
  724. description=bookmark.get('description', '')[:200],
  725. category_id=category.id,
  726. user_id=user_id,
  727. is_public=import_as_public
  728. )
  729. # 获取favicon
  730. site.icon = get_favicon_url(bookmark['url'])
  731. db.session.add(site)
  732. task['results']['success'] += 1
  733. except Exception as e:
  734. task['results']['failed'] += 1
  735. task['results']['errors'].append(f"导入失败 {bookmark['name']}: {str(e)}")
  736. db.session.commit()
  737. task['status'] = 'completed'
  738. # 保存最终状态
  739. with open(task_file_path, 'w', encoding='utf-8') as f:
  740. json.dump(task, f, ensure_ascii=False, indent=2)
  741. print(f"导入完成: 成功 {task['results']['success']}, 失败 {task['results']['failed']}")
  742. except Exception as e:
  743. db.session.rollback()
  744. task['status'] = 'failed'
  745. task['results']['errors'].append(f"导入过程出错: {str(e)}")
  746. # 保存错误状态
  747. with open(task_file_path, 'w', encoding='utf-8') as f:
  748. json.dump(task, f, ensure_ascii=False, indent=2)
  749. print(f"导入失败: {e}")
  750. @app.route('/site/<int:site_id>')
  751. def site_detail(site_id):
  752. site = Site.query.get_or_404(site_id)
  753. return render_template('site_detail.html', site=site)
  754. def cleanup_temp_files():
  755. """清理临时文件"""
  756. temp_dir = tempfile.gettempdir()
  757. # 清理预览文件
  758. for file in glob.glob(os.path.join(temp_dir, "preview_*.json")):
  759. try:
  760. os.remove(file)
  761. except:
  762. pass
  763. # 清理任务文件
  764. for file in glob.glob(os.path.join(temp_dir, "task_*.json")):
  765. try:
  766. os.remove(file)
  767. except:
  768. pass
  769. # 注册退出时的清理函数
  770. atexit.register(cleanup_temp_files)
  771. # 搜索路由
  772. @app.route('/search')
  773. def search():
  774. query_text = request.args.get('q', '').strip()
  775. search_engine = request.args.get('search-engine', 'local')
  776. resp = make_response()
  777. # 保存用户选择的搜索引擎到 cookie (30 天)
  778. resp.set_cookie('search-engine', search_engine, max_age=30*24*3600)
  779. if search_engine == 'local':
  780. if not query_text:
  781. flash('请输入搜索关键词', 'warning')
  782. return redirect(url_for('index'))
  783. sites = Site.query.filter(
  784. Site.is_public == True
  785. ).join(Category).filter_by(is_public=True).filter(
  786. Site.name.contains(query_text) |
  787. Site.description.contains(query_text) |
  788. Site.url.contains(query_text)
  789. ).all()
  790. categories = Category.query.filter_by(is_public=True).order_by(Category.name).all()
  791. resp.response = render_template('index.html',
  792. sites=sites,
  793. categories=categories,
  794. search_query=query_text,
  795. saved_engine=search_engine)
  796. return resp
  797. else:
  798. # 外部搜索跳转
  799. engines = {
  800. 'baidu': f'https://www.baidu.com/s?wd={query_text}',
  801. 'bing': f'https://www.bing.com/search?q={query_text}',
  802. 'google': f'https://www.google.com/search?q={query_text}'
  803. }
  804. if search_engine in engines:
  805. return redirect(engines[search_engine])
  806. else:
  807. flash('不支持的搜索引擎', 'warning')
  808. return redirect(url_for('index'))
  809. # 错误处理
  810. @app.errorhandler(404)
  811. def page_not_found(e):
  812. return render_template('404.html'), 404
  813. @app.errorhandler(403)
  814. def forbidden(e):
  815. return render_template('404.html', message='您没有权限访问此页面'), 403
  816. # 创建数据库表和初始数据
  817. @app.before_first_request
  818. def create_tables():
  819. db.create_all()
  820. create_default_admin()
  821. create_default_categories()
  822. # 编辑用户角色(提升/降级管理员)
  823. @app.route('/admin/user/<int:user_id>/toggle_admin', methods=['POST'])
  824. @login_required
  825. def toggle_admin(user_id):
  826. if not current_user.is_admin:
  827. abort(403)
  828. user = User.query.get_or_404(user_id)
  829. if user.id == current_user.id:
  830. flash('您不能更改自己的管理员状态', 'warning')
  831. else:
  832. user.is_admin = not user.is_admin
  833. db.session.commit()
  834. status = '管理员' if user.is_admin else '普通用户'
  835. flash(f'已将用户 "{user.username}" 设置为 {status}', 'success')
  836. return redirect(url_for('admin'))
  837. @app.route('/admin/category/<int:category_id>/edit', methods=['GET', 'POST'])
  838. @login_required
  839. def edit_public_category(category_id):
  840. if not current_user.is_admin:
  841. flash('您没有管理员权限', 'danger')
  842. return redirect(url_for('admin'))
  843. category = Category.query.get_or_404(category_id)
  844. # 修改权限检查:管理员可以编辑所有公共分类
  845. if not category.is_public:
  846. flash('只能编辑公共分类', 'danger')
  847. return redirect(url_for('admin'))
  848. form = CategoryForm(obj=category)
  849. if form.validate_on_submit():
  850. category.name = form.name.data
  851. category.is_public = True # 保持为公共分类
  852. db.session.commit()
  853. flash('分类更新成功', 'success')
  854. return redirect(url_for('admin'))
  855. return render_template('edit_public_category.html', form=form, category=category)
  856. # 修改删除公共分类路由
  857. @app.route('/admin/category/<int:category_id>/delete', methods=['POST'])
  858. @login_required
  859. def delete_public_category(category_id):
  860. if not current_user.is_admin:
  861. abort(403)
  862. category = Category.query.get_or_404(category_id)
  863. # 修改权限检查
  864. if not category.is_public:
  865. flash('只能删除公共分类', 'danger')
  866. return redirect(url_for('admin'))
  867. db.session.delete(category)
  868. db.session.commit()
  869. flash('分类删除成功', 'success')
  870. return redirect(url_for('admin'))
  871. # 编辑公共站点
  872. # 编辑公共站点路由
  873. @app.route('/admin/site/<int:site_id>/edit', methods=['GET', 'POST'])
  874. @login_required
  875. def edit_public_site(site_id):
  876. if not current_user.is_admin:
  877. abort(403)
  878. site = Site.query.get_or_404(site_id)
  879. # 修改权限检查:管理员可以编辑所有公共站点
  880. if not site.is_public:
  881. flash('只能编辑公共站点', 'danger')
  882. return redirect(url_for('admin'))
  883. form = SiteForm(obj=site)
  884. form.category_id.choices = [(c.id, c.name) for c in Category.query.filter_by(is_public=True).all()]
  885. if form.validate_on_submit():
  886. site.name = form.name.data
  887. site.url = form.url.data
  888. site.description = form.description.data
  889. site.category_id = form.category_id.data
  890. site.is_public = True
  891. db.session.commit()
  892. flash('站点更新成功', 'success')
  893. return redirect(url_for('admin'))
  894. return render_template('edit_public_site.html', form=form, site=site)
  895. @app.route('/edit_category/<int:category_id>', methods=['GET', 'POST'])
  896. @login_required
  897. def edit_category(category_id):
  898. category = Category.query.get_or_404(category_id)
  899. # 确保当前用户有权编辑此分类
  900. if category.user_id != current_user.id and not current_user.is_admin:
  901. abort(403)
  902. form = CategoryForm(obj=category)
  903. if form.validate_on_submit():
  904. # 记录原始公共状态
  905. original_is_public = category.is_public
  906. # 更新分类信息
  907. category.name = form.name.data
  908. category.is_public = form.is_public.data
  909. # 如果公共属性发生变化,更新该分类下所有站点的公共属性
  910. if form.is_public.data != original_is_public:
  911. # 获取该分类下的所有站点
  912. sites = Site.query.filter_by(category_id=category.id).all()
  913. # 更新每个站点的公共属性
  914. for site in sites:
  915. site.is_public = category.is_public
  916. flash(f'已更新该分类下 {len(sites)} 个站点的公共属性', 'info')
  917. db.session.commit()
  918. flash('分类更新成功', 'success')
  919. return redirect(url_for('dashboard'))
  920. return render_template('edit_category.html', form=form, category=category, action='edit')
  921. # 修改删除公共站点路由
  922. @app.route('/admin/site/<int:site_id>/delete', methods=['POST'])
  923. @login_required
  924. def delete_public_site(site_id):
  925. if not current_user.is_admin:
  926. abort(403)
  927. site = Site.query.get_or_404(site_id)
  928. # 修改权限检查
  929. if not site.is_public:
  930. flash('只能删除公共站点', 'danger')
  931. return redirect(url_for('admin'))
  932. db.session.delete(site)
  933. db.session.commit()
  934. flash('站点删除成功', 'success')
  935. return redirect(url_for('admin'))
  936. @app.route('/favicon.ico')
  937. def favicon():
  938. return send_from_directory(os.path.join(app.root_path, 'static'),
  939. 'favicon.ico', mimetype='image/vnd.microsoft.icon')
  940. if __name__ == '__main__':
  941. app.run(debug=False,port=6565)