BibexPy 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bibex_core/MergeDB.py +1189 -0
- bibex_core/__init__.py +13 -0
- bibex_core/modules/__init__.py +35 -0
- bibex_core/modules/api_utils.py +1172 -0
- bibex_core/modules/c1_utils.py +423 -0
- bibex_core/modules/file_utils.py +71 -0
- bibex_core/modules/ml_utils.py +332 -0
- bibex_core/modules/post_process.py +50 -0
- bibex_core/modules/stats_utils.py +216 -0
- bibex_core/scp2xlsx.py +235 -0
- bibex_core/wos2xlsx.py +159 -0
- bibex_core/xlsx2vos.py +185 -0
- bibexpy/__init__.py +9 -0
- bibexpy/__main__.py +6 -0
- bibexpy/_server/__init__.py +1 -0
- bibexpy/_server/config.py +92 -0
- bibexpy/_server/jobs/__init__.py +3 -0
- bibexpy/_server/jobs/runner.py +301 -0
- bibexpy/_server/main.py +211 -0
- bibexpy/_server/models/__init__.py +0 -0
- bibexpy/_server/models/project.py +25 -0
- bibexpy/_server/routers/__init__.py +0 -0
- bibexpy/_server/routers/audit.py +65 -0
- bibexpy/_server/routers/convert.py +101 -0
- bibexpy/_server/routers/disambiguate.py +177 -0
- bibexpy/_server/routers/downloads.py +26 -0
- bibexpy/_server/routers/enrich.py +42 -0
- bibexpy/_server/routers/export.py +116 -0
- bibexpy/_server/routers/export_folder.py +97 -0
- bibexpy/_server/routers/filter.py +131 -0
- bibexpy/_server/routers/jobs.py +72 -0
- bibexpy/_server/routers/merge.py +437 -0
- bibexpy/_server/routers/prepare.py +241 -0
- bibexpy/_server/routers/projects.py +49 -0
- bibexpy/_server/routers/quality.py +288 -0
- bibexpy/_server/routers/records.py +204 -0
- bibexpy/_server/routers/report.py +111 -0
- bibexpy/_server/routers/settings.py +383 -0
- bibexpy/_server/routers/system.py +209 -0
- bibexpy/_server/routers/tools.py +257 -0
- bibexpy/_server/routers/upload.py +110 -0
- bibexpy/_server/services/__init__.py +0 -0
- bibexpy/_server/services/analyses.py +364 -0
- bibexpy/_server/services/audit.py +340 -0
- bibexpy/_server/services/bibex_adapter.py +47 -0
- bibexpy/_server/services/bibtex_writer.py +104 -0
- bibexpy/_server/services/converter.py +205 -0
- bibexpy/_server/services/disambiguation/__init__.py +19 -0
- bibexpy/_server/services/disambiguation/blocking.py +581 -0
- bibexpy/_server/services/disambiguation/cache.py +47 -0
- bibexpy/_server/services/disambiguation/deepseek_client.py +203 -0
- bibexpy/_server/services/disambiguation/orcid.py +261 -0
- bibexpy/_server/services/disambiguation/pipeline.py +985 -0
- bibexpy/_server/services/disambiguation/similarity.py +91 -0
- bibexpy/_server/services/enricher.py +338 -0
- bibexpy/_server/services/exporter.py +112 -0
- bibexpy/_server/services/filter_engine.py +300 -0
- bibexpy/_server/services/merger.py +202 -0
- bibexpy/_server/services/methodology.py +102 -0
- bibexpy/_server/services/report_export.py +300 -0
- bibexpy/_server/services/ris_writer.py +62 -0
- bibexpy/_server/services/smart_merger.py +967 -0
- bibexpy/_server/services/storage.py +165 -0
- bibexpy/_web/404/index.html +1 -0
- bibexpy/_web/404.html +1 -0
- bibexpy/_web/_next/static/chunks/117-7ad5186126fa076d.js +2 -0
- bibexpy/_web/_next/static/chunks/156-75beffb8ae92a7f3.js +1 -0
- bibexpy/_web/_next/static/chunks/169-301c04fcec22686f.js +4 -0
- bibexpy/_web/_next/static/chunks/2-ecc1c36a5632ac61.js +1 -0
- bibexpy/_web/_next/static/chunks/244-be343e0f8aa00fb5.js +1 -0
- bibexpy/_web/_next/static/chunks/367-b7457c2b95f6932b.js +1 -0
- bibexpy/_web/_next/static/chunks/382-9cd163eed632e241.js +1 -0
- bibexpy/_web/_next/static/chunks/392-afa8bfa4c1a9893d.js +1 -0
- bibexpy/_web/_next/static/chunks/507-63607ded9f83c1aa.js +1 -0
- bibexpy/_web/_next/static/chunks/624-cae565632f47217d.js +1 -0
- bibexpy/_web/_next/static/chunks/739-a00c6b9c0f11813e.js +1 -0
- bibexpy/_web/_next/static/chunks/862-8e1bd5f93c082c02.js +1 -0
- bibexpy/_web/_next/static/chunks/app/_not-found/page-873ba59b5eb7c342.js +1 -0
- bibexpy/_web/_next/static/chunks/app/layout-0d5fc68ac8971a04.js +1 -0
- bibexpy/_web/_next/static/chunks/app/page-cf2bea5edc74459a.js +1 -0
- bibexpy/_web/_next/static/chunks/app/projects/[id]/convert/page-ab6dd645ae113b64.js +1 -0
- bibexpy/_web/_next/static/chunks/app/projects/[id]/disambiguate/page-dd98482cc47344e9.js +1 -0
- bibexpy/_web/_next/static/chunks/app/projects/[id]/enrich/page-4727e431468514e9.js +1 -0
- bibexpy/_web/_next/static/chunks/app/projects/[id]/export/page-f9005d164bb22cb0.js +1 -0
- bibexpy/_web/_next/static/chunks/app/projects/[id]/layout-07755fda55b3ae1f.js +1 -0
- bibexpy/_web/_next/static/chunks/app/projects/[id]/merge/page-45b95318ea7beccf.js +1 -0
- bibexpy/_web/_next/static/chunks/app/projects/[id]/records/page-bfad80457da9eb0b.js +1 -0
- bibexpy/_web/_next/static/chunks/app/projects/[id]/report/page-f05767e74158e995.js +1 -0
- bibexpy/_web/_next/static/chunks/app/projects/[id]/upload/page-8df78ca9cdecbcb8.js +1 -0
- bibexpy/_web/_next/static/chunks/app/projects/page-99960711ac6a13e9.js +1 -0
- bibexpy/_web/_next/static/chunks/app/settings/page-002df809d6c5a227.js +1 -0
- bibexpy/_web/_next/static/chunks/app/tools/page-27eee1c6a590859f.js +1 -0
- bibexpy/_web/_next/static/chunks/fd9d1056-f2718be92181f426.js +1 -0
- bibexpy/_web/_next/static/chunks/framework-f66176bb897dc684.js +1 -0
- bibexpy/_web/_next/static/chunks/main-app-d138a6e2f8c847f6.js +1 -0
- bibexpy/_web/_next/static/chunks/main-c4b9ddbf514503ef.js +1 -0
- bibexpy/_web/_next/static/chunks/pages/_app-72b849fbd24ac258.js +1 -0
- bibexpy/_web/_next/static/chunks/pages/_error-7ba65e1336b92748.js +1 -0
- bibexpy/_web/_next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
- bibexpy/_web/_next/static/chunks/webpack-c81f7fd28659d64f.js +1 -0
- bibexpy/_web/_next/static/css/1c4638f3e39cf127.css +3 -0
- bibexpy/_web/_next/static/ypx2IQAkZiSaFiKWnzZGB/_buildManifest.js +1 -0
- bibexpy/_web/_next/static/ypx2IQAkZiSaFiKWnzZGB/_ssgManifest.js +1 -0
- bibexpy/_web/images/PROMPTS.md +83 -0
- bibexpy/_web/images/about-illustration.png +0 -0
- bibexpy/_web/images/authors/alperen-sahin.jpg +0 -0
- bibexpy/_web/images/authors/burak-can-kara.jpg +0 -0
- bibexpy/_web/images/authors/taskin-dirsehan.jpg +0 -0
- bibexpy/_web/images/bibexpy-logo-full.png +0 -0
- bibexpy/_web/images/bibexpy-logo-header.png +0 -0
- bibexpy/_web/images/bibexpy-logo-in.png +0 -0
- bibexpy/_web/images/bibexpy-logo.webp +0 -0
- bibexpy/_web/images/hero-illustration.png +0 -0
- bibexpy/_web/images/loader.gif +0 -0
- bibexpy/_web/images/workflow-diagram.png +0 -0
- bibexpy/_web/index.html +1 -0
- bibexpy/_web/index.txt +11 -0
- bibexpy/_web/projects/_/convert/index.html +1 -0
- bibexpy/_web/projects/_/convert/index.txt +12 -0
- bibexpy/_web/projects/_/disambiguate/index.html +1 -0
- bibexpy/_web/projects/_/disambiguate/index.txt +12 -0
- bibexpy/_web/projects/_/enrich/index.html +1 -0
- bibexpy/_web/projects/_/enrich/index.txt +12 -0
- bibexpy/_web/projects/_/export/index.html +1 -0
- bibexpy/_web/projects/_/export/index.txt +12 -0
- bibexpy/_web/projects/_/merge/index.html +1 -0
- bibexpy/_web/projects/_/merge/index.txt +12 -0
- bibexpy/_web/projects/_/records/index.html +1 -0
- bibexpy/_web/projects/_/records/index.txt +12 -0
- bibexpy/_web/projects/_/report/index.html +1 -0
- bibexpy/_web/projects/_/report/index.txt +12 -0
- bibexpy/_web/projects/_/upload/index.html +1 -0
- bibexpy/_web/projects/_/upload/index.txt +12 -0
- bibexpy/_web/projects/index.html +1 -0
- bibexpy/_web/projects/index.txt +11 -0
- bibexpy/_web/settings/index.html +1 -0
- bibexpy/_web/settings/index.txt +11 -0
- bibexpy/_web/tools/README.md +65 -0
- bibexpy/_web/tools/bibexcel.svg +6 -0
- bibexpy/_web/tools/bibliometrix.png +0 -0
- bibexpy/_web/tools/biblioshiny.svg +12 -0
- bibexpy/_web/tools/citavi.svg +6 -0
- bibexpy/_web/tools/citespace.svg +7 -0
- bibexpy/_web/tools/citnetexplorer.svg +8 -0
- bibexpy/_web/tools/endnote.svg +6 -0
- bibexpy/_web/tools/excel.svg +1 -0
- bibexpy/_web/tools/gephi.svg +66 -0
- bibexpy/_web/tools/histcite.svg +7 -0
- bibexpy/_web/tools/index.html +1 -0
- bibexpy/_web/tools/index.txt +11 -0
- bibexpy/_web/tools/jabref.svg +122 -0
- bibexpy/_web/tools/latex.svg +1 -0
- bibexpy/_web/tools/mendeley.svg +1 -0
- bibexpy/_web/tools/openrefine.svg +15 -0
- bibexpy/_web/tools/overleaf.svg +1 -0
- bibexpy/_web/tools/papers.svg +7 -0
- bibexpy/_web/tools/powerbi.svg +1 -0
- bibexpy/_web/tools/python.svg +1 -0
- bibexpy/_web/tools/r.svg +1 -0
- bibexpy/_web/tools/refworks.svg +5 -0
- bibexpy/_web/tools/scite.svg +5 -0
- bibexpy/_web/tools/tableau.svg +1 -0
- bibexpy/_web/tools/vosviewer.png +0 -0
- bibexpy/_web/tools/zotero.svg +1 -0
- bibexpy/cli.py +242 -0
- bibexpy-2.0.0.dist-info/METADATA +105 -0
- bibexpy-2.0.0.dist-info/RECORD +171 -0
- bibexpy-2.0.0.dist-info/WHEEL +5 -0
- bibexpy-2.0.0.dist-info/entry_points.txt +2 -0
- bibexpy-2.0.0.dist-info/licenses/LICENSE +674 -0
- bibexpy-2.0.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
"""Ayarlar endpoint — .env dosyasını UI üzerinden oku/yaz.
|
|
2
|
+
|
|
3
|
+
Yalnız self-hosted senaryo için tasarlandı. Backend ve frontend aynı makinedeyse
|
|
4
|
+
kullanıcı API key ve email gibi alanları UI'dan girebilir.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from fastapi import APIRouter, HTTPException
|
|
14
|
+
from pydantic import BaseModel
|
|
15
|
+
|
|
16
|
+
from config import settings
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
router = APIRouter(prefix="/settings", tags=["settings"])
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# LLM sağlayıcı preset'leri — UI dropdown'larda kullanılır
|
|
23
|
+
LLM_PROVIDERS = {
|
|
24
|
+
"deepseek": {
|
|
25
|
+
"label": "DeepSeek",
|
|
26
|
+
"base_url": "https://api.deepseek.com",
|
|
27
|
+
"models": [
|
|
28
|
+
{"id": "deepseek-v4-flash", "label": "DeepSeek V4 Flash (fast, cheap)"},
|
|
29
|
+
{"id": "deepseek-v4-pro", "label": "DeepSeek V4 Pro (powerful)"},
|
|
30
|
+
],
|
|
31
|
+
"key_url": "https://platform.deepseek.com/api_keys",
|
|
32
|
+
},
|
|
33
|
+
"openai": {
|
|
34
|
+
"label": "OpenAI",
|
|
35
|
+
"base_url": "https://api.openai.com/v1",
|
|
36
|
+
"models": [
|
|
37
|
+
{"id": "gpt-5.5", "label": "GPT-5.5 (frontier)"},
|
|
38
|
+
{"id": "gpt-5.4", "label": "GPT-5.4 (powerful)"},
|
|
39
|
+
{"id": "gpt-5.4-mini", "label": "GPT-5.4 mini (cheap, fast)"},
|
|
40
|
+
{"id": "gpt-5.4-nano", "label": "GPT-5.4 nano (cheapest)"},
|
|
41
|
+
],
|
|
42
|
+
"key_url": "https://platform.openai.com/api-keys",
|
|
43
|
+
},
|
|
44
|
+
"openrouter": {
|
|
45
|
+
"label": "OpenRouter",
|
|
46
|
+
"base_url": "https://openrouter.ai/api/v1",
|
|
47
|
+
"models": [
|
|
48
|
+
{"id": "anthropic/claude-opus-4.8", "label": "Claude Opus 4.8"},
|
|
49
|
+
{"id": "anthropic/claude-sonnet-4.6", "label": "Claude Sonnet 4.6"},
|
|
50
|
+
{"id": "google/gemini-3.5-flash", "label": "Gemini 3.5 Flash"},
|
|
51
|
+
{"id": "deepseek/deepseek-v4-flash", "label": "DeepSeek V4 Flash"},
|
|
52
|
+
],
|
|
53
|
+
"key_url": "https://openrouter.ai/keys",
|
|
54
|
+
},
|
|
55
|
+
"custom": {
|
|
56
|
+
"label": "Özel (OpenAI-uyumlu)",
|
|
57
|
+
"base_url": "",
|
|
58
|
+
"models": [],
|
|
59
|
+
"key_url": "",
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# Hangi alanlar UI'dan yönetilebilir
|
|
65
|
+
EDITABLE_KEYS = {
|
|
66
|
+
# Depolama
|
|
67
|
+
"STORAGE_DIR": {"label": "Proje Depolama Klasörü", "secret": False, "group": "storage",
|
|
68
|
+
"type": "path",
|
|
69
|
+
"default": "./storage",
|
|
70
|
+
"hint": "Tüm projeler burada saklanır. Değiştirirseniz eski projeleri yeni klasöre manuel taşıyın. Backend yeniden başlatılmalı."},
|
|
71
|
+
# API anahtarları / e-postaları — zenginleştirme kaynakları.
|
|
72
|
+
# Önemli: Crossref, OpenAlex ve Unpaywall ÜCRETSİZdir; yalnızca Scopus anahtar ister.
|
|
73
|
+
"SCOPUS_API_KEY": {"label": "Scopus API Key", "secret": True, "group": "api",
|
|
74
|
+
"hint": "Yalnızca Scopus zenginleştirmesi için gerekli (Elsevier, kurumsal/ücretli). Diğer tüm kaynaklar ücretsizdir."},
|
|
75
|
+
"SEMANTIC_SCHOLAR_API_KEY": {"label": "Semantic Scholar API Key", "secret": True, "group": "api",
|
|
76
|
+
"hint": "Opsiyonel. Anahtarsız da çalışır; anahtar yalnızca hız limitini yükseltir (ücretsiz)."},
|
|
77
|
+
"UNPAYWALL_EMAIL": {"label": "Unpaywall Email", "secret": False, "group": "api",
|
|
78
|
+
"hint": "Opsiyonel. Açık erişim PDF bağlantıları için; Unpaywall yalnızca bir e-posta ister (ücretsiz)."},
|
|
79
|
+
"CROSSREF_EMAIL": {"label": "CrossRef Email (önerilir)", "secret": False, "group": "api",
|
|
80
|
+
"hint": "Opsiyonel ama önerilir. CrossRef 'polite pool' ile daha yüksek hız sağlar (ücretsiz)."},
|
|
81
|
+
# LLM (yeni birleşik alanlar)
|
|
82
|
+
"LLM_PROVIDER": {"label": "LLM Sağlayıcısı", "secret": False, "group": "llm",
|
|
83
|
+
"type": "provider",
|
|
84
|
+
"default": "deepseek",
|
|
85
|
+
"hint": "Disambiguation modülü için kullanılır. OpenAI-uyumlu herhangi bir API kullanılabilir."},
|
|
86
|
+
"LLM_API_KEY": {"label": "LLM API Key", "secret": True, "group": "llm"},
|
|
87
|
+
"LLM_BASE_URL": {"label": "LLM Base URL", "secret": False, "group": "llm",
|
|
88
|
+
"default": "https://api.deepseek.com",
|
|
89
|
+
"hint": "Sağlayıcı 'Özel' seçilirse buraya tam endpoint URL'i (ör. https://api.together.ai/v1)"},
|
|
90
|
+
"LLM_MODEL": {"label": "Model", "secret": False, "group": "llm",
|
|
91
|
+
"type": "model",
|
|
92
|
+
"default": "deepseek-v4-flash"},
|
|
93
|
+
# Disambiguation eşikleri
|
|
94
|
+
"DISAMBIGUATION_ENABLED": {"label": "Disambiguation Aktif", "secret": False, "group": "llm", "type": "bool"},
|
|
95
|
+
"DISAMBIGUATION_BLOCKING_THRESHOLD": {"label": "Blocking eşiği", "secret": False, "group": "llm", "type": "float"},
|
|
96
|
+
"DISAMBIGUATION_AUTO_APPROVE_THRESHOLD":{"label": "Auto-approve eşiği", "secret": False, "group": "llm", "type": "float"},
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
GROUP_LABELS = {
|
|
101
|
+
"storage": "Depolama",
|
|
102
|
+
"api": "Veri Zenginleştirme API'leri",
|
|
103
|
+
"llm": "LLM (Disambiguation)",
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _env_path() -> Path:
|
|
108
|
+
# `BIBEXPY_CONFIG_DIR` set ise (paket modu) oradaki .env'i oku/yaz —
|
|
109
|
+
# yazılabilir kullanıcı konumu (örn. ~/.bibexpy/.env). Yoksa geliştirme
|
|
110
|
+
# modunda config.py'nin yanındaki apps/api/.env.
|
|
111
|
+
import os
|
|
112
|
+
cfg_dir = os.environ.get("BIBEXPY_CONFIG_DIR")
|
|
113
|
+
if cfg_dir:
|
|
114
|
+
d = Path(cfg_dir).expanduser()
|
|
115
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
return d / ".env"
|
|
117
|
+
here = Path(__file__).resolve().parent.parent # apps/api (gelistirme)
|
|
118
|
+
dev_env = here / ".env"
|
|
119
|
+
if not dev_env.exists():
|
|
120
|
+
home_env = Path.home() / ".bibexpy" / ".env"
|
|
121
|
+
if home_env.exists():
|
|
122
|
+
return home_env
|
|
123
|
+
return dev_env
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _read_env() -> dict[str, str]:
|
|
127
|
+
p = _env_path()
|
|
128
|
+
out: dict[str, str] = {}
|
|
129
|
+
if not p.exists():
|
|
130
|
+
return out
|
|
131
|
+
for raw in p.read_text(encoding="utf-8").splitlines():
|
|
132
|
+
line = raw.strip()
|
|
133
|
+
if not line or line.startswith("#"):
|
|
134
|
+
continue
|
|
135
|
+
m = re.match(r'^([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$', line)
|
|
136
|
+
if not m:
|
|
137
|
+
continue
|
|
138
|
+
key, val = m.group(1), m.group(2).strip()
|
|
139
|
+
# Tırnakları temizle
|
|
140
|
+
if (val.startswith('"') and val.endswith('"')) or (val.startswith("'") and val.endswith("'")):
|
|
141
|
+
val = val[1:-1]
|
|
142
|
+
out[key] = val
|
|
143
|
+
return out
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _mask_secret(val: str) -> str:
|
|
147
|
+
"""API key vb. için sadece son 4 karakteri göster."""
|
|
148
|
+
if not val:
|
|
149
|
+
return ""
|
|
150
|
+
if len(val) <= 6:
|
|
151
|
+
return "•" * len(val)
|
|
152
|
+
return "•" * (len(val) - 4) + val[-4:]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _write_env(updates: dict[str, str]) -> None:
|
|
156
|
+
"""`.env`'i atomik güncelle.
|
|
157
|
+
|
|
158
|
+
Mevcut satırların sırasını/yorumlarını koru; sadece KEY=value satırlarını güncelle.
|
|
159
|
+
Yeni anahtarlar dosyanın sonuna eklenir.
|
|
160
|
+
"""
|
|
161
|
+
p = _env_path()
|
|
162
|
+
existing_lines: list[str] = []
|
|
163
|
+
if p.exists():
|
|
164
|
+
existing_lines = p.read_text(encoding="utf-8").splitlines()
|
|
165
|
+
|
|
166
|
+
seen: set[str] = set()
|
|
167
|
+
out_lines: list[str] = []
|
|
168
|
+
for line in existing_lines:
|
|
169
|
+
stripped = line.strip()
|
|
170
|
+
if not stripped or stripped.startswith("#"):
|
|
171
|
+
out_lines.append(line)
|
|
172
|
+
continue
|
|
173
|
+
m = re.match(r'^([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$', stripped)
|
|
174
|
+
if not m:
|
|
175
|
+
out_lines.append(line)
|
|
176
|
+
continue
|
|
177
|
+
key = m.group(1)
|
|
178
|
+
if key in updates:
|
|
179
|
+
new_val = str(updates[key])
|
|
180
|
+
# Boşluk veya özel karakter varsa tırnak içine al
|
|
181
|
+
if " " in new_val or '"' in new_val or "$" in new_val:
|
|
182
|
+
new_val_escaped = new_val.replace('"', '\\"')
|
|
183
|
+
out_lines.append(f'{key}="{new_val_escaped}"')
|
|
184
|
+
else:
|
|
185
|
+
out_lines.append(f"{key}={new_val}")
|
|
186
|
+
seen.add(key)
|
|
187
|
+
else:
|
|
188
|
+
out_lines.append(line)
|
|
189
|
+
|
|
190
|
+
# Yeni anahtarları sona ekle
|
|
191
|
+
for key, val in updates.items():
|
|
192
|
+
if key in seen:
|
|
193
|
+
continue
|
|
194
|
+
out_lines.append(f"{key}={val}")
|
|
195
|
+
|
|
196
|
+
# Atomik yaz (önce tmp, sonra replace)
|
|
197
|
+
tmp = p.with_suffix(".env.tmp")
|
|
198
|
+
tmp.write_text("\n".join(out_lines) + "\n", encoding="utf-8")
|
|
199
|
+
tmp.replace(p)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class SettingField(BaseModel):
|
|
203
|
+
key: str
|
|
204
|
+
label: str
|
|
205
|
+
value: str
|
|
206
|
+
is_set: bool
|
|
207
|
+
secret: bool
|
|
208
|
+
group: str
|
|
209
|
+
type: str = "string" # "string" | "bool" | "float" | "path"
|
|
210
|
+
default: str | None = None
|
|
211
|
+
hint: str | None = None
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class LLMProviderPreset(BaseModel):
|
|
215
|
+
id: str
|
|
216
|
+
label: str
|
|
217
|
+
base_url: str
|
|
218
|
+
models: list[dict[str, str]]
|
|
219
|
+
key_url: str = ""
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class SettingsResponse(BaseModel):
|
|
223
|
+
groups: dict[str, str]
|
|
224
|
+
fields: list[SettingField]
|
|
225
|
+
env_file: str
|
|
226
|
+
notes: list[str]
|
|
227
|
+
llm_providers: list[LLMProviderPreset] = []
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@router.get("", response_model=SettingsResponse)
|
|
231
|
+
def get_settings():
|
|
232
|
+
env = _read_env()
|
|
233
|
+
fields: list[SettingField] = []
|
|
234
|
+
for key, meta in EDITABLE_KEYS.items():
|
|
235
|
+
raw = env.get(key, "")
|
|
236
|
+
# LLM alanları için eski DEEPSEEK_* alanlarından fallback (geriye dönük)
|
|
237
|
+
if key == "LLM_API_KEY" and not raw:
|
|
238
|
+
raw = env.get("DEEPSEEK_API_KEY", "")
|
|
239
|
+
elif key == "LLM_BASE_URL" and not raw:
|
|
240
|
+
raw = env.get("DEEPSEEK_BASE_URL", "") or "https://api.deepseek.com"
|
|
241
|
+
elif key == "LLM_MODEL" and not raw:
|
|
242
|
+
old_model = env.get("DEEPSEEK_MODEL", "")
|
|
243
|
+
# Eski "deepseek-chat" → "deepseek-v4-flash" otomatik upgrade önerisi
|
|
244
|
+
if old_model in ("", "deepseek-chat"):
|
|
245
|
+
raw = "deepseek-v4-flash"
|
|
246
|
+
else:
|
|
247
|
+
raw = old_model
|
|
248
|
+
elif key == "LLM_PROVIDER" and not raw:
|
|
249
|
+
raw = "deepseek"
|
|
250
|
+
# STORAGE_DIR için runtime'da resolve edilmiş hali göster
|
|
251
|
+
elif key == "STORAGE_DIR" and not raw:
|
|
252
|
+
raw = str(settings.storage_path)
|
|
253
|
+
is_set = bool(raw and raw != "")
|
|
254
|
+
# Secret'leri maskeli döndür
|
|
255
|
+
display = _mask_secret(raw) if (meta.get("secret") and is_set) else raw
|
|
256
|
+
fields.append(SettingField(
|
|
257
|
+
key=key,
|
|
258
|
+
label=meta["label"],
|
|
259
|
+
value=display,
|
|
260
|
+
is_set=is_set,
|
|
261
|
+
secret=bool(meta.get("secret", False)),
|
|
262
|
+
group=meta["group"],
|
|
263
|
+
type=meta.get("type", "string"),
|
|
264
|
+
default=meta.get("default"),
|
|
265
|
+
hint=meta.get("hint"),
|
|
266
|
+
))
|
|
267
|
+
providers = [
|
|
268
|
+
LLMProviderPreset(id=pid, **pdata) for pid, pdata in LLM_PROVIDERS.items()
|
|
269
|
+
]
|
|
270
|
+
return SettingsResponse(
|
|
271
|
+
groups=GROUP_LABELS,
|
|
272
|
+
fields=fields,
|
|
273
|
+
env_file=str(_env_path()),
|
|
274
|
+
notes=[
|
|
275
|
+
"Ayarlar .env dosyasına yazılır.",
|
|
276
|
+
"Backend'in yeniden başlatılması bazı değişikliklerin uygulanması için gerekebilir.",
|
|
277
|
+
"API anahtarları maskelenmiş gösterilir; aynı değer kalsın istiyorsanız alanı dokunmayın.",
|
|
278
|
+
],
|
|
279
|
+
llm_providers=providers,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
class ValidatePathPayload(BaseModel):
|
|
284
|
+
path: str
|
|
285
|
+
create_if_missing: bool = False
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
@router.post("/validate-path")
|
|
289
|
+
def validate_path(payload: ValidatePathPayload):
|
|
290
|
+
"""Verilen path'i kontrol et — var mı, klasör mü, yazılabilir mi.
|
|
291
|
+
|
|
292
|
+
Frontend STORAGE_DIR gibi alanları girerken kullanır.
|
|
293
|
+
"""
|
|
294
|
+
raw = payload.path.strip()
|
|
295
|
+
if not raw:
|
|
296
|
+
return {"valid": False, "exists": False, "writable": False, "is_dir": False,
|
|
297
|
+
"absolute": False, "resolved": "", "message": "Path boş"}
|
|
298
|
+
try:
|
|
299
|
+
p = Path(raw).expanduser()
|
|
300
|
+
except Exception as e:
|
|
301
|
+
return {"valid": False, "message": f"Path okunamadı: {e}", "exists": False,
|
|
302
|
+
"writable": False, "is_dir": False, "absolute": False, "resolved": raw}
|
|
303
|
+
|
|
304
|
+
info: dict[str, Any] = {
|
|
305
|
+
"absolute": p.is_absolute(),
|
|
306
|
+
"resolved": str(p.resolve() if p.exists() else p),
|
|
307
|
+
}
|
|
308
|
+
if not p.exists():
|
|
309
|
+
if payload.create_if_missing:
|
|
310
|
+
try:
|
|
311
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
312
|
+
except Exception as e:
|
|
313
|
+
return {"valid": False, "exists": False, "writable": False, "is_dir": False,
|
|
314
|
+
**info, "message": f"folder_create_failed: {e}"}
|
|
315
|
+
else:
|
|
316
|
+
return {"valid": False, "exists": False, "writable": False, "is_dir": False,
|
|
317
|
+
**info, "message": "Klasör mevcut değil (create_if_missing=true gönderebilirsiniz)"}
|
|
318
|
+
|
|
319
|
+
if not p.is_dir():
|
|
320
|
+
return {"valid": False, "exists": True, "writable": False, "is_dir": False,
|
|
321
|
+
**info, "message": "Mevcut ama dizin değil (dosya?)"}
|
|
322
|
+
|
|
323
|
+
# Yazılabilirlik testi — küçük bir tmp dosya oluştur/sil
|
|
324
|
+
test = p / ".bibex_writable_check"
|
|
325
|
+
try:
|
|
326
|
+
test.write_text("ok", encoding="utf-8")
|
|
327
|
+
test.unlink()
|
|
328
|
+
writable = True
|
|
329
|
+
except Exception:
|
|
330
|
+
writable = False
|
|
331
|
+
|
|
332
|
+
# İçindeki mevcut proje sayısı (meta.json olan alt klasörler)
|
|
333
|
+
project_count = 0
|
|
334
|
+
try:
|
|
335
|
+
for child in p.iterdir():
|
|
336
|
+
if child.is_dir() and (child / "meta.json").exists():
|
|
337
|
+
project_count += 1
|
|
338
|
+
except Exception:
|
|
339
|
+
pass
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
"valid": writable,
|
|
343
|
+
"exists": True,
|
|
344
|
+
"writable": writable,
|
|
345
|
+
"is_dir": True,
|
|
346
|
+
**info,
|
|
347
|
+
"project_count": project_count,
|
|
348
|
+
"message": "Hazır" if writable else "Yazılamıyor — yetki sorunu olabilir",
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
class UpdatePayload(BaseModel):
|
|
353
|
+
updates: dict[str, Any]
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
@router.put("", response_model=SettingsResponse)
|
|
357
|
+
def update_settings(payload: UpdatePayload):
|
|
358
|
+
# Yalnız EDITABLE_KEYS güncellenebilir
|
|
359
|
+
clean: dict[str, str] = {}
|
|
360
|
+
for key, val in payload.updates.items():
|
|
361
|
+
key = key.strip().upper()
|
|
362
|
+
if key not in EDITABLE_KEYS:
|
|
363
|
+
raise HTTPException(400, f"unknown_key: {key}")
|
|
364
|
+
meta = EDITABLE_KEYS[key]
|
|
365
|
+
if val is None:
|
|
366
|
+
continue
|
|
367
|
+
# Mask karakterlerinden oluşan değeri "değişmedi" olarak yorumla
|
|
368
|
+
if isinstance(val, str) and meta.get("secret"):
|
|
369
|
+
if val.strip() == "" or set(val.strip()) <= {"•"}:
|
|
370
|
+
continue # değiştirme
|
|
371
|
+
# Bool → "true/false"
|
|
372
|
+
if meta.get("type") == "bool":
|
|
373
|
+
clean[key] = "true" if bool(val) else "false"
|
|
374
|
+
elif meta.get("type") == "float":
|
|
375
|
+
try:
|
|
376
|
+
clean[key] = str(float(val))
|
|
377
|
+
except Exception:
|
|
378
|
+
raise HTTPException(400, f"field_must_be_numeric: {key}")
|
|
379
|
+
else:
|
|
380
|
+
clean[key] = str(val).strip()
|
|
381
|
+
|
|
382
|
+
_write_env(clean)
|
|
383
|
+
return get_settings()
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Sistem dizini tarama — Settings'deki path picker için.
|
|
2
|
+
|
|
3
|
+
Backend ve frontend aynı makinede çalıştığı için, kullanıcı backend'in
|
|
4
|
+
gördüğü dizin ağacında gezinebilir. Path traversal kontrolü yok çünkü
|
|
5
|
+
self-hosted ve kullanıcı kendi dosya sistemine erişiyor.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import string
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from fastapi import APIRouter, HTTPException
|
|
17
|
+
from pydantic import BaseModel
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
router = APIRouter(prefix="/system", tags=["system"])
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _is_windows() -> bool:
|
|
24
|
+
return sys.platform.startswith("win")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _list_windows_drives() -> list[str]:
|
|
28
|
+
"""Mevcut Windows sürücülerini listele (C:, D:, ...)."""
|
|
29
|
+
drives = []
|
|
30
|
+
for letter in string.ascii_uppercase:
|
|
31
|
+
d = f"{letter}:\\"
|
|
32
|
+
if os.path.exists(d):
|
|
33
|
+
drives.append(d)
|
|
34
|
+
return drives
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@router.get("/find-by-name")
|
|
38
|
+
def find_by_name(name: str, limit: int = 8):
|
|
39
|
+
"""Verilen klasör adının olası tam yollarını bul.
|
|
40
|
+
|
|
41
|
+
Kullanım: tarayıcının native picker'ı sadece klasör adı veriyor (path güvenlik
|
|
42
|
+
nedeniyle gizli). Backend, kullanıcının home altında bu adı arar ve olası
|
|
43
|
+
eşleşmeleri döndürür. Kullanıcı UI'da hangisi olduğunu doğrular.
|
|
44
|
+
"""
|
|
45
|
+
name = name.strip()
|
|
46
|
+
if not name:
|
|
47
|
+
raise HTTPException(400, "name_empty")
|
|
48
|
+
|
|
49
|
+
candidates: list[dict[str, Any]] = []
|
|
50
|
+
home = Path.home()
|
|
51
|
+
|
|
52
|
+
# Önce shortcut'larda ara (hızlı)
|
|
53
|
+
search_roots: list[Path] = [
|
|
54
|
+
home,
|
|
55
|
+
home / "Documents",
|
|
56
|
+
home / "Desktop",
|
|
57
|
+
home / "Downloads",
|
|
58
|
+
]
|
|
59
|
+
if sys.platform == "darwin":
|
|
60
|
+
search_roots.append(Path("/Volumes"))
|
|
61
|
+
elif _is_windows():
|
|
62
|
+
# Drive root'larında da bak
|
|
63
|
+
for d in _list_windows_drives():
|
|
64
|
+
search_roots.append(Path(d))
|
|
65
|
+
|
|
66
|
+
seen: set[str] = set()
|
|
67
|
+
for root in search_roots:
|
|
68
|
+
if not root.exists():
|
|
69
|
+
continue
|
|
70
|
+
try:
|
|
71
|
+
for child in root.iterdir():
|
|
72
|
+
if not child.is_dir():
|
|
73
|
+
continue
|
|
74
|
+
if child.name == name:
|
|
75
|
+
p = str(child.resolve())
|
|
76
|
+
if p not in seen:
|
|
77
|
+
seen.add(p)
|
|
78
|
+
candidates.append({"path": p, "depth": 1, "parent": str(root)})
|
|
79
|
+
if len(candidates) >= limit:
|
|
80
|
+
return {"name": name, "matches": candidates}
|
|
81
|
+
except (PermissionError, OSError):
|
|
82
|
+
continue
|
|
83
|
+
|
|
84
|
+
# 2. seviye (sadece home altında — sığ recursive)
|
|
85
|
+
if len(candidates) < limit:
|
|
86
|
+
try:
|
|
87
|
+
for child in home.iterdir():
|
|
88
|
+
if not child.is_dir() or child.name.startswith("."):
|
|
89
|
+
continue
|
|
90
|
+
try:
|
|
91
|
+
target = child / name
|
|
92
|
+
if target.exists() and target.is_dir():
|
|
93
|
+
p = str(target.resolve())
|
|
94
|
+
if p not in seen:
|
|
95
|
+
seen.add(p)
|
|
96
|
+
candidates.append({"path": p, "depth": 2, "parent": str(child)})
|
|
97
|
+
except (PermissionError, OSError):
|
|
98
|
+
continue
|
|
99
|
+
if len(candidates) >= limit:
|
|
100
|
+
break
|
|
101
|
+
except (PermissionError, OSError):
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
return {"name": name, "matches": candidates}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@router.get("/browse")
|
|
108
|
+
def browse(path: str = ""):
|
|
109
|
+
"""Verilen dizinin alt-klasörlerini listele.
|
|
110
|
+
|
|
111
|
+
Path boş veya geçersizse:
|
|
112
|
+
- Windows: drive root'larını döndür (C:\\, D:\\, ...)
|
|
113
|
+
- Unix: / döndür
|
|
114
|
+
"""
|
|
115
|
+
home = Path.home()
|
|
116
|
+
|
|
117
|
+
# Boş path → drive listesi (Windows) veya root (Unix)
|
|
118
|
+
if not path:
|
|
119
|
+
if _is_windows():
|
|
120
|
+
drives = _list_windows_drives()
|
|
121
|
+
return {
|
|
122
|
+
"current": "",
|
|
123
|
+
"parent": None,
|
|
124
|
+
"is_root": True,
|
|
125
|
+
"entries": [
|
|
126
|
+
{"name": d, "path": d, "is_dir": True, "is_drive": True}
|
|
127
|
+
for d in drives
|
|
128
|
+
],
|
|
129
|
+
"shortcuts": _shortcuts(home),
|
|
130
|
+
"platform": "windows",
|
|
131
|
+
}
|
|
132
|
+
else:
|
|
133
|
+
# macOS / Linux için root
|
|
134
|
+
return browse("/")
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
p = Path(path).expanduser()
|
|
138
|
+
except Exception as e:
|
|
139
|
+
raise HTTPException(400, f"invalid_path: {e}")
|
|
140
|
+
|
|
141
|
+
if not p.exists():
|
|
142
|
+
raise HTTPException(404, "path_not_found")
|
|
143
|
+
if not p.is_dir():
|
|
144
|
+
raise HTTPException(400, "not_a_folder")
|
|
145
|
+
|
|
146
|
+
# Alt klasörleri listele (gizli olanları atla, dosyaları da atla)
|
|
147
|
+
entries: list[dict[str, Any]] = []
|
|
148
|
+
try:
|
|
149
|
+
for child in sorted(p.iterdir(), key=lambda x: x.name.lower()):
|
|
150
|
+
try:
|
|
151
|
+
if child.name.startswith(".") or child.name.startswith("$"):
|
|
152
|
+
continue
|
|
153
|
+
if not child.is_dir():
|
|
154
|
+
continue
|
|
155
|
+
entries.append({
|
|
156
|
+
"name": child.name,
|
|
157
|
+
"path": str(child),
|
|
158
|
+
"is_dir": True,
|
|
159
|
+
"is_drive": False,
|
|
160
|
+
})
|
|
161
|
+
except (PermissionError, OSError):
|
|
162
|
+
continue
|
|
163
|
+
except PermissionError:
|
|
164
|
+
raise HTTPException(403, "folder_access_denied")
|
|
165
|
+
except OSError as e:
|
|
166
|
+
raise HTTPException(500, f"folder_list_failed: {e}")
|
|
167
|
+
|
|
168
|
+
# Parent path
|
|
169
|
+
parent_path: str | None = None
|
|
170
|
+
p_abs = p.resolve()
|
|
171
|
+
if _is_windows() and len(p_abs.parts) <= 1:
|
|
172
|
+
# Drive root → parent yok (drive listesine dönülmeli)
|
|
173
|
+
parent_path = ""
|
|
174
|
+
elif p_abs.parent != p_abs:
|
|
175
|
+
parent_path = str(p_abs.parent)
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
"current": str(p_abs),
|
|
179
|
+
"parent": parent_path,
|
|
180
|
+
"is_root": False,
|
|
181
|
+
"entries": entries,
|
|
182
|
+
"shortcuts": _shortcuts(home),
|
|
183
|
+
"platform": "windows" if _is_windows() else "unix",
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _shortcuts(home: Path) -> list[dict[str, str]]:
|
|
188
|
+
"""Soldan hızlı erişim kısayolları — OS'a göre uyarlı."""
|
|
189
|
+
items: list[dict[str, str]] = [
|
|
190
|
+
{"label": "Ev", "path": str(home), "icon": "home"},
|
|
191
|
+
{"label": "Belgeler", "path": str(home / "Documents"), "icon": "documents"},
|
|
192
|
+
{"label": "İndirilenler", "path": str(home / "Downloads"), "icon": "downloads"},
|
|
193
|
+
{"label": "Masaüstü", "path": str(home / "Desktop"), "icon": "desktop"},
|
|
194
|
+
]
|
|
195
|
+
if _is_windows():
|
|
196
|
+
items.append({"label": "Bilgisayar", "path": "", "icon": "computer"})
|
|
197
|
+
elif sys.platform == "darwin":
|
|
198
|
+
# macOS — bağlı sürücüler /Volumes altında
|
|
199
|
+
items.append({"label": "Uygulamalar", "path": "/Applications", "icon": "computer"})
|
|
200
|
+
if Path("/Volumes").exists():
|
|
201
|
+
items.append({"label": "Sürücüler", "path": "/Volumes", "icon": "computer"})
|
|
202
|
+
else:
|
|
203
|
+
# Linux
|
|
204
|
+
if Path("/mnt").exists():
|
|
205
|
+
items.append({"label": "Mount", "path": "/mnt", "icon": "computer"})
|
|
206
|
+
if Path("/media").exists():
|
|
207
|
+
items.append({"label": "Media", "path": "/media", "icon": "computer"})
|
|
208
|
+
# Sadece gerçekten var olan dizinleri döndür
|
|
209
|
+
return [it for it in items if not it["path"] or Path(it["path"]).exists()]
|