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.
Files changed (171) hide show
  1. bibex_core/MergeDB.py +1189 -0
  2. bibex_core/__init__.py +13 -0
  3. bibex_core/modules/__init__.py +35 -0
  4. bibex_core/modules/api_utils.py +1172 -0
  5. bibex_core/modules/c1_utils.py +423 -0
  6. bibex_core/modules/file_utils.py +71 -0
  7. bibex_core/modules/ml_utils.py +332 -0
  8. bibex_core/modules/post_process.py +50 -0
  9. bibex_core/modules/stats_utils.py +216 -0
  10. bibex_core/scp2xlsx.py +235 -0
  11. bibex_core/wos2xlsx.py +159 -0
  12. bibex_core/xlsx2vos.py +185 -0
  13. bibexpy/__init__.py +9 -0
  14. bibexpy/__main__.py +6 -0
  15. bibexpy/_server/__init__.py +1 -0
  16. bibexpy/_server/config.py +92 -0
  17. bibexpy/_server/jobs/__init__.py +3 -0
  18. bibexpy/_server/jobs/runner.py +301 -0
  19. bibexpy/_server/main.py +211 -0
  20. bibexpy/_server/models/__init__.py +0 -0
  21. bibexpy/_server/models/project.py +25 -0
  22. bibexpy/_server/routers/__init__.py +0 -0
  23. bibexpy/_server/routers/audit.py +65 -0
  24. bibexpy/_server/routers/convert.py +101 -0
  25. bibexpy/_server/routers/disambiguate.py +177 -0
  26. bibexpy/_server/routers/downloads.py +26 -0
  27. bibexpy/_server/routers/enrich.py +42 -0
  28. bibexpy/_server/routers/export.py +116 -0
  29. bibexpy/_server/routers/export_folder.py +97 -0
  30. bibexpy/_server/routers/filter.py +131 -0
  31. bibexpy/_server/routers/jobs.py +72 -0
  32. bibexpy/_server/routers/merge.py +437 -0
  33. bibexpy/_server/routers/prepare.py +241 -0
  34. bibexpy/_server/routers/projects.py +49 -0
  35. bibexpy/_server/routers/quality.py +288 -0
  36. bibexpy/_server/routers/records.py +204 -0
  37. bibexpy/_server/routers/report.py +111 -0
  38. bibexpy/_server/routers/settings.py +383 -0
  39. bibexpy/_server/routers/system.py +209 -0
  40. bibexpy/_server/routers/tools.py +257 -0
  41. bibexpy/_server/routers/upload.py +110 -0
  42. bibexpy/_server/services/__init__.py +0 -0
  43. bibexpy/_server/services/analyses.py +364 -0
  44. bibexpy/_server/services/audit.py +340 -0
  45. bibexpy/_server/services/bibex_adapter.py +47 -0
  46. bibexpy/_server/services/bibtex_writer.py +104 -0
  47. bibexpy/_server/services/converter.py +205 -0
  48. bibexpy/_server/services/disambiguation/__init__.py +19 -0
  49. bibexpy/_server/services/disambiguation/blocking.py +581 -0
  50. bibexpy/_server/services/disambiguation/cache.py +47 -0
  51. bibexpy/_server/services/disambiguation/deepseek_client.py +203 -0
  52. bibexpy/_server/services/disambiguation/orcid.py +261 -0
  53. bibexpy/_server/services/disambiguation/pipeline.py +985 -0
  54. bibexpy/_server/services/disambiguation/similarity.py +91 -0
  55. bibexpy/_server/services/enricher.py +338 -0
  56. bibexpy/_server/services/exporter.py +112 -0
  57. bibexpy/_server/services/filter_engine.py +300 -0
  58. bibexpy/_server/services/merger.py +202 -0
  59. bibexpy/_server/services/methodology.py +102 -0
  60. bibexpy/_server/services/report_export.py +300 -0
  61. bibexpy/_server/services/ris_writer.py +62 -0
  62. bibexpy/_server/services/smart_merger.py +967 -0
  63. bibexpy/_server/services/storage.py +165 -0
  64. bibexpy/_web/404/index.html +1 -0
  65. bibexpy/_web/404.html +1 -0
  66. bibexpy/_web/_next/static/chunks/117-7ad5186126fa076d.js +2 -0
  67. bibexpy/_web/_next/static/chunks/156-75beffb8ae92a7f3.js +1 -0
  68. bibexpy/_web/_next/static/chunks/169-301c04fcec22686f.js +4 -0
  69. bibexpy/_web/_next/static/chunks/2-ecc1c36a5632ac61.js +1 -0
  70. bibexpy/_web/_next/static/chunks/244-be343e0f8aa00fb5.js +1 -0
  71. bibexpy/_web/_next/static/chunks/367-b7457c2b95f6932b.js +1 -0
  72. bibexpy/_web/_next/static/chunks/382-9cd163eed632e241.js +1 -0
  73. bibexpy/_web/_next/static/chunks/392-afa8bfa4c1a9893d.js +1 -0
  74. bibexpy/_web/_next/static/chunks/507-63607ded9f83c1aa.js +1 -0
  75. bibexpy/_web/_next/static/chunks/624-cae565632f47217d.js +1 -0
  76. bibexpy/_web/_next/static/chunks/739-a00c6b9c0f11813e.js +1 -0
  77. bibexpy/_web/_next/static/chunks/862-8e1bd5f93c082c02.js +1 -0
  78. bibexpy/_web/_next/static/chunks/app/_not-found/page-873ba59b5eb7c342.js +1 -0
  79. bibexpy/_web/_next/static/chunks/app/layout-0d5fc68ac8971a04.js +1 -0
  80. bibexpy/_web/_next/static/chunks/app/page-cf2bea5edc74459a.js +1 -0
  81. bibexpy/_web/_next/static/chunks/app/projects/[id]/convert/page-ab6dd645ae113b64.js +1 -0
  82. bibexpy/_web/_next/static/chunks/app/projects/[id]/disambiguate/page-dd98482cc47344e9.js +1 -0
  83. bibexpy/_web/_next/static/chunks/app/projects/[id]/enrich/page-4727e431468514e9.js +1 -0
  84. bibexpy/_web/_next/static/chunks/app/projects/[id]/export/page-f9005d164bb22cb0.js +1 -0
  85. bibexpy/_web/_next/static/chunks/app/projects/[id]/layout-07755fda55b3ae1f.js +1 -0
  86. bibexpy/_web/_next/static/chunks/app/projects/[id]/merge/page-45b95318ea7beccf.js +1 -0
  87. bibexpy/_web/_next/static/chunks/app/projects/[id]/records/page-bfad80457da9eb0b.js +1 -0
  88. bibexpy/_web/_next/static/chunks/app/projects/[id]/report/page-f05767e74158e995.js +1 -0
  89. bibexpy/_web/_next/static/chunks/app/projects/[id]/upload/page-8df78ca9cdecbcb8.js +1 -0
  90. bibexpy/_web/_next/static/chunks/app/projects/page-99960711ac6a13e9.js +1 -0
  91. bibexpy/_web/_next/static/chunks/app/settings/page-002df809d6c5a227.js +1 -0
  92. bibexpy/_web/_next/static/chunks/app/tools/page-27eee1c6a590859f.js +1 -0
  93. bibexpy/_web/_next/static/chunks/fd9d1056-f2718be92181f426.js +1 -0
  94. bibexpy/_web/_next/static/chunks/framework-f66176bb897dc684.js +1 -0
  95. bibexpy/_web/_next/static/chunks/main-app-d138a6e2f8c847f6.js +1 -0
  96. bibexpy/_web/_next/static/chunks/main-c4b9ddbf514503ef.js +1 -0
  97. bibexpy/_web/_next/static/chunks/pages/_app-72b849fbd24ac258.js +1 -0
  98. bibexpy/_web/_next/static/chunks/pages/_error-7ba65e1336b92748.js +1 -0
  99. bibexpy/_web/_next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
  100. bibexpy/_web/_next/static/chunks/webpack-c81f7fd28659d64f.js +1 -0
  101. bibexpy/_web/_next/static/css/1c4638f3e39cf127.css +3 -0
  102. bibexpy/_web/_next/static/ypx2IQAkZiSaFiKWnzZGB/_buildManifest.js +1 -0
  103. bibexpy/_web/_next/static/ypx2IQAkZiSaFiKWnzZGB/_ssgManifest.js +1 -0
  104. bibexpy/_web/images/PROMPTS.md +83 -0
  105. bibexpy/_web/images/about-illustration.png +0 -0
  106. bibexpy/_web/images/authors/alperen-sahin.jpg +0 -0
  107. bibexpy/_web/images/authors/burak-can-kara.jpg +0 -0
  108. bibexpy/_web/images/authors/taskin-dirsehan.jpg +0 -0
  109. bibexpy/_web/images/bibexpy-logo-full.png +0 -0
  110. bibexpy/_web/images/bibexpy-logo-header.png +0 -0
  111. bibexpy/_web/images/bibexpy-logo-in.png +0 -0
  112. bibexpy/_web/images/bibexpy-logo.webp +0 -0
  113. bibexpy/_web/images/hero-illustration.png +0 -0
  114. bibexpy/_web/images/loader.gif +0 -0
  115. bibexpy/_web/images/workflow-diagram.png +0 -0
  116. bibexpy/_web/index.html +1 -0
  117. bibexpy/_web/index.txt +11 -0
  118. bibexpy/_web/projects/_/convert/index.html +1 -0
  119. bibexpy/_web/projects/_/convert/index.txt +12 -0
  120. bibexpy/_web/projects/_/disambiguate/index.html +1 -0
  121. bibexpy/_web/projects/_/disambiguate/index.txt +12 -0
  122. bibexpy/_web/projects/_/enrich/index.html +1 -0
  123. bibexpy/_web/projects/_/enrich/index.txt +12 -0
  124. bibexpy/_web/projects/_/export/index.html +1 -0
  125. bibexpy/_web/projects/_/export/index.txt +12 -0
  126. bibexpy/_web/projects/_/merge/index.html +1 -0
  127. bibexpy/_web/projects/_/merge/index.txt +12 -0
  128. bibexpy/_web/projects/_/records/index.html +1 -0
  129. bibexpy/_web/projects/_/records/index.txt +12 -0
  130. bibexpy/_web/projects/_/report/index.html +1 -0
  131. bibexpy/_web/projects/_/report/index.txt +12 -0
  132. bibexpy/_web/projects/_/upload/index.html +1 -0
  133. bibexpy/_web/projects/_/upload/index.txt +12 -0
  134. bibexpy/_web/projects/index.html +1 -0
  135. bibexpy/_web/projects/index.txt +11 -0
  136. bibexpy/_web/settings/index.html +1 -0
  137. bibexpy/_web/settings/index.txt +11 -0
  138. bibexpy/_web/tools/README.md +65 -0
  139. bibexpy/_web/tools/bibexcel.svg +6 -0
  140. bibexpy/_web/tools/bibliometrix.png +0 -0
  141. bibexpy/_web/tools/biblioshiny.svg +12 -0
  142. bibexpy/_web/tools/citavi.svg +6 -0
  143. bibexpy/_web/tools/citespace.svg +7 -0
  144. bibexpy/_web/tools/citnetexplorer.svg +8 -0
  145. bibexpy/_web/tools/endnote.svg +6 -0
  146. bibexpy/_web/tools/excel.svg +1 -0
  147. bibexpy/_web/tools/gephi.svg +66 -0
  148. bibexpy/_web/tools/histcite.svg +7 -0
  149. bibexpy/_web/tools/index.html +1 -0
  150. bibexpy/_web/tools/index.txt +11 -0
  151. bibexpy/_web/tools/jabref.svg +122 -0
  152. bibexpy/_web/tools/latex.svg +1 -0
  153. bibexpy/_web/tools/mendeley.svg +1 -0
  154. bibexpy/_web/tools/openrefine.svg +15 -0
  155. bibexpy/_web/tools/overleaf.svg +1 -0
  156. bibexpy/_web/tools/papers.svg +7 -0
  157. bibexpy/_web/tools/powerbi.svg +1 -0
  158. bibexpy/_web/tools/python.svg +1 -0
  159. bibexpy/_web/tools/r.svg +1 -0
  160. bibexpy/_web/tools/refworks.svg +5 -0
  161. bibexpy/_web/tools/scite.svg +5 -0
  162. bibexpy/_web/tools/tableau.svg +1 -0
  163. bibexpy/_web/tools/vosviewer.png +0 -0
  164. bibexpy/_web/tools/zotero.svg +1 -0
  165. bibexpy/cli.py +242 -0
  166. bibexpy-2.0.0.dist-info/METADATA +105 -0
  167. bibexpy-2.0.0.dist-info/RECORD +171 -0
  168. bibexpy-2.0.0.dist-info/WHEEL +5 -0
  169. bibexpy-2.0.0.dist-info/entry_points.txt +2 -0
  170. bibexpy-2.0.0.dist-info/licenses/LICENSE +674 -0
  171. 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()]