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,364 @@
|
|
|
1
|
+
"""Birleştirme analizleri (analysis runs) yönetimi.
|
|
2
|
+
|
|
3
|
+
Her birleştirme bağımsız bir analiz klasörü olarak saklanır. Bu sayede:
|
|
4
|
+
- Eski birleştirmeler kaybolmaz
|
|
5
|
+
- Farklı algoritmaların sonuçları yan yana saklanır
|
|
6
|
+
- Aktif analiz değiştirilebilir (Records/Filter/Quality otomatik yansır)
|
|
7
|
+
- Snapshot/rollback işlemleri daha güvenli
|
|
8
|
+
|
|
9
|
+
Disk yapısı:
|
|
10
|
+
storage/<project_id>/
|
|
11
|
+
analyses/
|
|
12
|
+
active.json # {"id": "analysis_20260526_071505_smart"}
|
|
13
|
+
analysis_20260526_071505_smart/
|
|
14
|
+
meta.json # {id, created_at, method, label, file_count}
|
|
15
|
+
merged.xlsx # ana dataset
|
|
16
|
+
Statistic.xlsx
|
|
17
|
+
match_audit.xlsx # smart only
|
|
18
|
+
conflict_log.xlsx # smart only
|
|
19
|
+
borderline_queue.xlsx # smart only
|
|
20
|
+
borderline_state.json # smart only
|
|
21
|
+
Lost_Wos.xlsx
|
|
22
|
+
Lost_Scopus.xlsx
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import shutil
|
|
29
|
+
import time
|
|
30
|
+
from datetime import datetime
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any, Optional
|
|
33
|
+
|
|
34
|
+
from services import storage
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ─────────────────────────────────────────────────────────────
|
|
38
|
+
# Yol helper'ları
|
|
39
|
+
# ─────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
def analyses_dir(project_id: str) -> Path:
|
|
42
|
+
p = storage.project_dir(project_id) / "analyses"
|
|
43
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
return p
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def active_pointer_path(project_id: str) -> Path:
|
|
48
|
+
return analyses_dir(project_id) / "active.json"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def analysis_dir(project_id: str, analysis_id: str) -> Path:
|
|
52
|
+
return analyses_dir(project_id) / analysis_id
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ─────────────────────────────────────────────────────────────
|
|
56
|
+
# CRUD
|
|
57
|
+
# ─────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
def _write_meta(adir: Path, meta: dict[str, Any]) -> None:
|
|
60
|
+
(adir / "meta.json").write_text(json.dumps(meta, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _read_meta(adir: Path) -> dict[str, Any]:
|
|
64
|
+
p = adir / "meta.json"
|
|
65
|
+
if not p.exists():
|
|
66
|
+
return {}
|
|
67
|
+
try:
|
|
68
|
+
return json.loads(p.read_text(encoding="utf-8"))
|
|
69
|
+
except Exception:
|
|
70
|
+
return {}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def create_analysis(project_id: str, method: str, label: Optional[str] = None) -> tuple[str, Path]:
|
|
74
|
+
"""Yeni analiz oluştur — timestamp'li klasör + meta.json. Aktif yapılmaz.
|
|
75
|
+
|
|
76
|
+
Dönüş: (analysis_id, path)
|
|
77
|
+
"""
|
|
78
|
+
now = time.time()
|
|
79
|
+
stamp = datetime.fromtimestamp(now).strftime("%Y%m%d_%H%M%S")
|
|
80
|
+
analysis_id = f"analysis_{stamp}_{method}"
|
|
81
|
+
|
|
82
|
+
# Çakışma durumunda sonek ekle
|
|
83
|
+
base = analyses_dir(project_id)
|
|
84
|
+
i = 1
|
|
85
|
+
while (base / analysis_id).exists():
|
|
86
|
+
analysis_id = f"analysis_{stamp}_{method}_{i}"
|
|
87
|
+
i += 1
|
|
88
|
+
|
|
89
|
+
target = analysis_dir(project_id, analysis_id)
|
|
90
|
+
target.mkdir(parents=True, exist_ok=False)
|
|
91
|
+
|
|
92
|
+
if not label:
|
|
93
|
+
label = f"{method.title()} — {datetime.fromtimestamp(now).strftime('%d.%m.%Y %H:%M')}"
|
|
94
|
+
|
|
95
|
+
meta = {
|
|
96
|
+
"id": analysis_id,
|
|
97
|
+
"created_at": now,
|
|
98
|
+
"method": method,
|
|
99
|
+
"label": label,
|
|
100
|
+
"file_count": 0,
|
|
101
|
+
"source": "user_run",
|
|
102
|
+
}
|
|
103
|
+
_write_meta(target, meta)
|
|
104
|
+
return analysis_id, target
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def finalize_analysis(project_id: str, analysis_id: str) -> None:
|
|
108
|
+
"""Analiz tamamlandığında file_count'u güncelle + aktif yap."""
|
|
109
|
+
adir = analysis_dir(project_id, analysis_id)
|
|
110
|
+
if not adir.exists():
|
|
111
|
+
return
|
|
112
|
+
meta = _read_meta(adir)
|
|
113
|
+
meta["file_count"] = sum(1 for p in adir.iterdir() if p.is_file() and p.name != "meta.json")
|
|
114
|
+
meta["completed_at"] = time.time()
|
|
115
|
+
_write_meta(adir, meta)
|
|
116
|
+
set_active_analysis(project_id, analysis_id)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def list_analyses(project_id: str) -> list[dict[str, Any]]:
|
|
120
|
+
"""Tüm analizleri en yeniden eskiye sırala."""
|
|
121
|
+
out: list[dict[str, Any]] = []
|
|
122
|
+
active_id = get_active_analysis_id(project_id)
|
|
123
|
+
base = analyses_dir(project_id)
|
|
124
|
+
if not base.exists():
|
|
125
|
+
return out
|
|
126
|
+
|
|
127
|
+
for p in base.iterdir():
|
|
128
|
+
if not p.is_dir():
|
|
129
|
+
continue
|
|
130
|
+
# Trash klasörlerini (silinmiş ama henüz fiziksel temizlenmemiş) atla
|
|
131
|
+
if p.name.startswith("_deleted_"):
|
|
132
|
+
continue
|
|
133
|
+
meta = _read_meta(p)
|
|
134
|
+
# Soft-delete edilmiş analizleri atla (meta.json'da deleted: true)
|
|
135
|
+
if meta.get("deleted"):
|
|
136
|
+
continue
|
|
137
|
+
if not meta:
|
|
138
|
+
# meta.json yoksa basic info üret
|
|
139
|
+
meta = {
|
|
140
|
+
"id": p.name,
|
|
141
|
+
"created_at": p.stat().st_mtime,
|
|
142
|
+
"method": "unknown",
|
|
143
|
+
"label": p.name,
|
|
144
|
+
"file_count": sum(1 for f in p.iterdir() if f.is_file()),
|
|
145
|
+
}
|
|
146
|
+
meta["is_active"] = (meta.get("id") == active_id)
|
|
147
|
+
# Boyut bilgisi
|
|
148
|
+
try:
|
|
149
|
+
meta["total_size"] = sum(f.stat().st_size for f in p.iterdir() if f.is_file())
|
|
150
|
+
except Exception:
|
|
151
|
+
meta["total_size"] = 0
|
|
152
|
+
out.append(meta)
|
|
153
|
+
|
|
154
|
+
out.sort(key=lambda m: m.get("created_at", 0), reverse=True)
|
|
155
|
+
return out
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def get_analysis_meta(project_id: str, analysis_id: str) -> Optional[dict[str, Any]]:
|
|
159
|
+
adir = analysis_dir(project_id, analysis_id)
|
|
160
|
+
if not adir.exists():
|
|
161
|
+
return None
|
|
162
|
+
meta = _read_meta(adir)
|
|
163
|
+
# Soft-deleted analizler "yok" gibi davranılır — tekrar silinemez
|
|
164
|
+
if meta.get("deleted"):
|
|
165
|
+
return None
|
|
166
|
+
meta["is_active"] = (meta.get("id") == get_active_analysis_id(project_id))
|
|
167
|
+
return meta or None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def delete_analysis(project_id: str, analysis_id: str) -> bool:
|
|
171
|
+
"""Bir analizi sil — Windows-friendly **soft delete** stratejisi.
|
|
172
|
+
|
|
173
|
+
Windows'ta açık file handle'lar (pandas read_excel cache, openpyxl iç
|
|
174
|
+
state, vb.) dosyayı `unlink`/`rename` için kilitliyor. Bunu aşmak için:
|
|
175
|
+
|
|
176
|
+
1. Aktif analiz siliniyorsa aktifliği başka analize taşı
|
|
177
|
+
2. filter_engine DataFrame cache'i temizle + gc.collect()
|
|
178
|
+
3. **Önce hızlı yol**: shutil.rmtree dene (1 retry).
|
|
179
|
+
4. **Fallback (soft delete)**: meta.json'a `"deleted": true` flag'i yaz.
|
|
180
|
+
list_analyses() bunu filtreler; UI'da görünmez. Disk alanı sonraki
|
|
181
|
+
backend restart'ında `purge_soft_deleted()` ile temizlenir.
|
|
182
|
+
|
|
183
|
+
Soft-delete sayesinde DELETE hiçbir zaman 500 fırlatmaz; kullanıcı
|
|
184
|
+
deneyimi tutarlı kalır.
|
|
185
|
+
"""
|
|
186
|
+
import gc
|
|
187
|
+
import time as _time
|
|
188
|
+
|
|
189
|
+
adir = analysis_dir(project_id, analysis_id)
|
|
190
|
+
if not adir.exists():
|
|
191
|
+
return False
|
|
192
|
+
|
|
193
|
+
active_id = get_active_analysis_id(project_id)
|
|
194
|
+
|
|
195
|
+
# 1) Aktif analiz siliniyorsa önce aktifliği taşı
|
|
196
|
+
if active_id == analysis_id:
|
|
197
|
+
remaining = [a for a in list_analyses(project_id) if a["id"] != analysis_id]
|
|
198
|
+
if remaining:
|
|
199
|
+
set_active_analysis(project_id, remaining[0]["id"])
|
|
200
|
+
else:
|
|
201
|
+
clear_active_analysis(project_id)
|
|
202
|
+
|
|
203
|
+
# 2) Filter cache'i temizle
|
|
204
|
+
try:
|
|
205
|
+
from services import filter_engine
|
|
206
|
+
filter_engine._DF_CACHE.clear()
|
|
207
|
+
except Exception:
|
|
208
|
+
pass
|
|
209
|
+
gc.collect()
|
|
210
|
+
|
|
211
|
+
# 3) Hızlı yol: gerçek silme dene (Windows'ta sıklıkla fail eder)
|
|
212
|
+
def _on_rmtree_error(func, path, exc_info):
|
|
213
|
+
try:
|
|
214
|
+
import os, stat
|
|
215
|
+
os.chmod(path, stat.S_IWRITE)
|
|
216
|
+
func(path)
|
|
217
|
+
except Exception:
|
|
218
|
+
raise
|
|
219
|
+
|
|
220
|
+
for _ in range(2):
|
|
221
|
+
try:
|
|
222
|
+
shutil.rmtree(adir, onerror=_on_rmtree_error)
|
|
223
|
+
return True
|
|
224
|
+
except OSError:
|
|
225
|
+
gc.collect()
|
|
226
|
+
_time.sleep(0.3)
|
|
227
|
+
|
|
228
|
+
# 4) Soft delete fallback — meta.json'a flag yaz
|
|
229
|
+
meta_path = adir / "meta.json"
|
|
230
|
+
try:
|
|
231
|
+
meta = _read_meta(adir)
|
|
232
|
+
except Exception:
|
|
233
|
+
meta = {}
|
|
234
|
+
meta["deleted"] = True
|
|
235
|
+
meta["deleted_at"] = _time.time()
|
|
236
|
+
try:
|
|
237
|
+
meta_path.write_text(json.dumps(meta, indent=2), encoding="utf-8")
|
|
238
|
+
except Exception:
|
|
239
|
+
# meta.json bile yazılamıyorsa gerçekten kilitli — başarısız
|
|
240
|
+
if active_id == analysis_id:
|
|
241
|
+
try:
|
|
242
|
+
set_active_analysis(project_id, analysis_id)
|
|
243
|
+
except Exception:
|
|
244
|
+
pass
|
|
245
|
+
return False
|
|
246
|
+
return True
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def purge_soft_deleted(project_id: str) -> int:
|
|
250
|
+
"""Soft-delete edilmiş ama henüz fiziksel olarak silinmemiş analiz
|
|
251
|
+
klasörlerini temizlemeyi dene. Backend startup'ta veya periyodik
|
|
252
|
+
çağrılabilir. Başarıyla silinen klasör sayısını döner."""
|
|
253
|
+
import gc
|
|
254
|
+
base = analyses_dir(project_id)
|
|
255
|
+
if not base.exists():
|
|
256
|
+
return 0
|
|
257
|
+
purged = 0
|
|
258
|
+
for p in base.iterdir():
|
|
259
|
+
if not p.is_dir():
|
|
260
|
+
continue
|
|
261
|
+
meta = _read_meta(p)
|
|
262
|
+
if not meta.get("deleted"):
|
|
263
|
+
continue
|
|
264
|
+
gc.collect()
|
|
265
|
+
try:
|
|
266
|
+
shutil.rmtree(p, ignore_errors=True)
|
|
267
|
+
if not p.exists():
|
|
268
|
+
purged += 1
|
|
269
|
+
except Exception:
|
|
270
|
+
pass
|
|
271
|
+
return purged
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
# ─────────────────────────────────────────────────────────────
|
|
275
|
+
# Active analysis
|
|
276
|
+
# ─────────────────────────────────────────────────────────────
|
|
277
|
+
|
|
278
|
+
def get_active_analysis_id(project_id: str) -> Optional[str]:
|
|
279
|
+
p = active_pointer_path(project_id)
|
|
280
|
+
if not p.exists():
|
|
281
|
+
return None
|
|
282
|
+
try:
|
|
283
|
+
data = json.loads(p.read_text(encoding="utf-8"))
|
|
284
|
+
return data.get("id")
|
|
285
|
+
except Exception:
|
|
286
|
+
return None
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def set_active_analysis(project_id: str, analysis_id: str) -> None:
|
|
290
|
+
p = active_pointer_path(project_id)
|
|
291
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
292
|
+
p.write_text(json.dumps({"id": analysis_id, "set_at": time.time()}, indent=2), encoding="utf-8")
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def clear_active_analysis(project_id: str) -> None:
|
|
296
|
+
p = active_pointer_path(project_id)
|
|
297
|
+
if p.exists():
|
|
298
|
+
p.unlink()
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def get_active_analysis_dir(project_id: str) -> Optional[Path]:
|
|
302
|
+
"""Aktif analizin klasör yolunu döndür.
|
|
303
|
+
|
|
304
|
+
Eğer aktif analiz yoksa, son yapılan analizi otomatik aktif yap.
|
|
305
|
+
Hiç analiz yoksa None döner.
|
|
306
|
+
"""
|
|
307
|
+
active_id = get_active_analysis_id(project_id)
|
|
308
|
+
if active_id:
|
|
309
|
+
adir = analysis_dir(project_id, active_id)
|
|
310
|
+
if adir.exists():
|
|
311
|
+
return adir
|
|
312
|
+
# Aktif klasör silinmiş; pointer'ı temizle
|
|
313
|
+
clear_active_analysis(project_id)
|
|
314
|
+
|
|
315
|
+
# Aktif yoksa en yenisi
|
|
316
|
+
analyses = list_analyses(project_id)
|
|
317
|
+
if analyses:
|
|
318
|
+
latest = analyses[0]["id"]
|
|
319
|
+
set_active_analysis(project_id, latest)
|
|
320
|
+
return analysis_dir(project_id, latest)
|
|
321
|
+
|
|
322
|
+
return None
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def work_dir(project_id: str) -> Path:
|
|
326
|
+
"""Analiz-spesifik çıktıların (disambiguation önerileri, snapshot'lar, audit)
|
|
327
|
+
yazılacağı klasör = AKTİF analiz klasörü. Böylece her yeni merge (yeni analiz)
|
|
328
|
+
kendi temiz çalışma alanına sahip olur ve eski öneriler/snapshot'lar taşınmaz.
|
|
329
|
+
|
|
330
|
+
Hiç analiz yoksa proje köküne düşer (geriye uyumluluk; pratikte merge'siz bu
|
|
331
|
+
yollar çağrılmaz).
|
|
332
|
+
"""
|
|
333
|
+
adir = get_active_analysis_dir(project_id)
|
|
334
|
+
return adir if adir is not None else storage.project_dir(project_id)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
# ─────────────────────────────────────────────────────────────
|
|
338
|
+
# Dataset path resolver (filter_engine, merger için)
|
|
339
|
+
# ─────────────────────────────────────────────────────────────
|
|
340
|
+
|
|
341
|
+
# Aktif analiz klasöründeki ana dataset dosya adı (Smart Merge → merged.xlsx)
|
|
342
|
+
_DATASET_NAMES = (
|
|
343
|
+
"merged.xlsx",
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def active_dataset_path(project_id: str) -> Optional[Path]:
|
|
348
|
+
"""Aktif analizdeki ana birleştirilmiş dataset dosyasını döndür."""
|
|
349
|
+
adir = get_active_analysis_dir(project_id)
|
|
350
|
+
if adir is None:
|
|
351
|
+
return None
|
|
352
|
+
for name in _DATASET_NAMES:
|
|
353
|
+
p = adir / name
|
|
354
|
+
if p.exists():
|
|
355
|
+
return p
|
|
356
|
+
# Fallback: ilk *.xlsx (yan dosyaları atla)
|
|
357
|
+
SKIP = {"statistic.xlsx", "statistic_smart.xlsx", "match_audit.xlsx",
|
|
358
|
+
"conflict_log.xlsx", "borderline_queue.xlsx"}
|
|
359
|
+
for f in sorted(adir.iterdir()):
|
|
360
|
+
if (f.suffix.lower() == ".xlsx"
|
|
361
|
+
and f.name.lower() not in SKIP
|
|
362
|
+
and not f.name.lower().startswith("lost_")):
|
|
363
|
+
return f
|
|
364
|
+
return None
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"""Append-only audit log — kullanıcının her önemli işlemini kaydeder.
|
|
2
|
+
|
|
3
|
+
Her event JSON Lines (`operations.jsonl`) dosyasına yazılır:
|
|
4
|
+
{ts, kind, title, details, user_action, snapshot, before, after}
|
|
5
|
+
|
|
6
|
+
Kind örnekleri:
|
|
7
|
+
"upload" — dosya yüklendi
|
|
8
|
+
"convert" — CSV/TXT → XLSX
|
|
9
|
+
"merge" — simple / enhanced
|
|
10
|
+
"filter_save" — preset kaydedildi
|
|
11
|
+
"records_delete" — toplu silme
|
|
12
|
+
"enrich_api" — API enrichment çalıştı
|
|
13
|
+
"enrich_ml" — ML enrichment çalıştı
|
|
14
|
+
"disambiguate" — yazar/affiliation cluster uygulandı
|
|
15
|
+
"snapshot" — snapshot alındı
|
|
16
|
+
"export" — dosya export edildi
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import threading
|
|
23
|
+
import time
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any, Optional
|
|
26
|
+
|
|
27
|
+
from services import storage
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_LOCK = threading.Lock()
|
|
31
|
+
|
|
32
|
+
# analysis_id sentinel'leri — her analizin AYRI geçmişi için.
|
|
33
|
+
_AUTO = "__auto__" # write: kaydı aktif analize otomatik etiketle
|
|
34
|
+
_ALL = "__all__" # read: filtre yok (tüm proje)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _log_path(project_id: str) -> Path:
|
|
38
|
+
return storage.project_dir(project_id) / "operations.jsonl"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def write(
|
|
42
|
+
project_id: str,
|
|
43
|
+
kind: str,
|
|
44
|
+
title: str,
|
|
45
|
+
*,
|
|
46
|
+
title_key: Optional[str] = None,
|
|
47
|
+
title_params: Optional[dict[str, Any]] = None,
|
|
48
|
+
details: Optional[dict[str, Any]] = None,
|
|
49
|
+
before: Optional[dict[str, Any]] = None,
|
|
50
|
+
after: Optional[dict[str, Any]] = None,
|
|
51
|
+
snapshot: Optional[str] = None,
|
|
52
|
+
user_action: Optional[str] = None,
|
|
53
|
+
analysis_id: Optional[str] = _AUTO,
|
|
54
|
+
) -> dict[str, Any]:
|
|
55
|
+
"""Yeni bir kayıt ekle. Hatalar sessizce yutulur — log bozulması ana akışı durdurmasın.
|
|
56
|
+
|
|
57
|
+
`title` — geriye dönük / markdown rapor için okunabilir metin (fallback).
|
|
58
|
+
`title_key` + `title_params` — UI display-time i18n için (frontend t(key, params)).
|
|
59
|
+
`analysis_id` — kaydın ait olduğu analiz (her analizin AYRI geçmişi). Varsayılan
|
|
60
|
+
("__auto__") aktif analizi otomatik bulur; proje-seviye işlemler (upload/file
|
|
61
|
+
delete vb.) `analysis_id=None` geçer → tüm analizlerde "kurulum" olarak görünür.
|
|
62
|
+
"""
|
|
63
|
+
if analysis_id == _AUTO:
|
|
64
|
+
try:
|
|
65
|
+
from services import analyses
|
|
66
|
+
analysis_id = analyses.get_active_analysis_id(project_id)
|
|
67
|
+
except Exception:
|
|
68
|
+
analysis_id = None
|
|
69
|
+
entry = {
|
|
70
|
+
"ts": time.time(),
|
|
71
|
+
"kind": kind,
|
|
72
|
+
"title": title,
|
|
73
|
+
"title_key": title_key,
|
|
74
|
+
"title_params": title_params or None,
|
|
75
|
+
"analysis_id": analysis_id,
|
|
76
|
+
"details": details or {},
|
|
77
|
+
"before": before,
|
|
78
|
+
"after": after,
|
|
79
|
+
"snapshot": snapshot,
|
|
80
|
+
"user_action": user_action,
|
|
81
|
+
}
|
|
82
|
+
try:
|
|
83
|
+
with _LOCK:
|
|
84
|
+
p = _log_path(project_id)
|
|
85
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
86
|
+
with p.open("a", encoding="utf-8") as f:
|
|
87
|
+
f.write(json.dumps(entry, ensure_ascii=False, default=str) + "\n")
|
|
88
|
+
except Exception:
|
|
89
|
+
pass
|
|
90
|
+
return entry
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def read(
|
|
94
|
+
project_id: str,
|
|
95
|
+
limit: Optional[int] = None,
|
|
96
|
+
analysis_id: Optional[str] = _ALL,
|
|
97
|
+
) -> list[dict[str, Any]]:
|
|
98
|
+
"""Audit kayıtlarını oku.
|
|
99
|
+
|
|
100
|
+
`analysis_id`:
|
|
101
|
+
"__all__" → filtre yok (tüm proje geçmişi).
|
|
102
|
+
<id> / None → o analizin kayıtları + proje-seviye (analysis_id is None) kurulum
|
|
103
|
+
kayıtları (upload vb.) — "o analizde nelerin yapıldığı".
|
|
104
|
+
"""
|
|
105
|
+
p = _log_path(project_id)
|
|
106
|
+
if not p.exists():
|
|
107
|
+
return []
|
|
108
|
+
out: list[dict[str, Any]] = []
|
|
109
|
+
try:
|
|
110
|
+
with p.open("r", encoding="utf-8") as f:
|
|
111
|
+
for line in f:
|
|
112
|
+
line = line.strip()
|
|
113
|
+
if not line:
|
|
114
|
+
continue
|
|
115
|
+
try:
|
|
116
|
+
out.append(json.loads(line))
|
|
117
|
+
except Exception:
|
|
118
|
+
continue
|
|
119
|
+
except Exception:
|
|
120
|
+
return []
|
|
121
|
+
if analysis_id != _ALL:
|
|
122
|
+
out = [e for e in out if e.get("analysis_id") == analysis_id or e.get("analysis_id") is None]
|
|
123
|
+
if limit and limit > 0:
|
|
124
|
+
out = out[-limit:]
|
|
125
|
+
return out
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def delete_for_analysis(project_id: str, analysis_id: str) -> int:
|
|
129
|
+
"""Bir analizin kayıtlarını sil (o analiz silinince çağrılır). Proje-seviye
|
|
130
|
+
(analysis_id is None) kurulum kayıtları ve diğer analizlerin kayıtları KORUNUR."""
|
|
131
|
+
p = _log_path(project_id)
|
|
132
|
+
if not p.exists():
|
|
133
|
+
return 0
|
|
134
|
+
entries = read(project_id) # tümü
|
|
135
|
+
kept = [e for e in entries if e.get("analysis_id") != analysis_id]
|
|
136
|
+
removed = len(entries) - len(kept)
|
|
137
|
+
if removed <= 0:
|
|
138
|
+
return 0
|
|
139
|
+
try:
|
|
140
|
+
with _LOCK:
|
|
141
|
+
with p.open("w", encoding="utf-8") as f:
|
|
142
|
+
for e in kept:
|
|
143
|
+
f.write(json.dumps(e, ensure_ascii=False, default=str) + "\n")
|
|
144
|
+
except Exception:
|
|
145
|
+
return 0
|
|
146
|
+
return removed
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def clear(project_id: str) -> int:
|
|
150
|
+
"""Tüm log'u sil. Geri dönüşü olmayan operasyon (kullanıcı onayıyla)."""
|
|
151
|
+
p = _log_path(project_id)
|
|
152
|
+
if not p.exists():
|
|
153
|
+
return 0
|
|
154
|
+
count = sum(1 for _ in p.open("r", encoding="utf-8"))
|
|
155
|
+
p.unlink()
|
|
156
|
+
return count
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def summary(project_id: str) -> dict[str, Any]:
|
|
160
|
+
"""Kaba özet — kategori bazlı sayım, ilk/son zaman."""
|
|
161
|
+
entries = read(project_id)
|
|
162
|
+
if not entries:
|
|
163
|
+
return {"total": 0, "by_kind": {}, "first_ts": None, "last_ts": None}
|
|
164
|
+
by_kind: dict[str, int] = {}
|
|
165
|
+
for e in entries:
|
|
166
|
+
by_kind[e.get("kind", "?")] = by_kind.get(e.get("kind", "?"), 0) + 1
|
|
167
|
+
return {
|
|
168
|
+
"total": len(entries),
|
|
169
|
+
"by_kind": by_kind,
|
|
170
|
+
"first_ts": entries[0].get("ts"),
|
|
171
|
+
"last_ts": entries[-1].get("ts"),
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# ----- Markdown raporu -----
|
|
176
|
+
|
|
177
|
+
KIND_LABELS = {
|
|
178
|
+
"project_create": "📋 Proje oluşturma",
|
|
179
|
+
"upload": "📥 Dosya yükleme",
|
|
180
|
+
"convert": "🔄 Dönüşüm",
|
|
181
|
+
"merge": "🧩 Birleştirme",
|
|
182
|
+
"merge_borderline": "🔍 Borderline kararı",
|
|
183
|
+
"analysis_activate": "🎯 Aktif analiz değiştirildi",
|
|
184
|
+
"analysis_delete": "🗑️ Analiz silindi",
|
|
185
|
+
"filter_save": "⭐ Preset kaydı",
|
|
186
|
+
"records_delete": "🗑️ Kayıt silme",
|
|
187
|
+
"records_edit": "✏️ Kayıt düzenleme",
|
|
188
|
+
"enrich_api": "🌐 API zenginleştirme",
|
|
189
|
+
"enrich_ml": "🧠 ML zenginleştirme",
|
|
190
|
+
"enrich_field": "🔧 Alan zenginleştirme",
|
|
191
|
+
"enrich_selected": "🔧 Seçimsel zenginleştirme",
|
|
192
|
+
"disambiguate": "✨ Disambiguation",
|
|
193
|
+
"disambiguate_authors": "✨ Yazar Disambiguation",
|
|
194
|
+
"disambiguate_affiliations": "🏢 Affiliation Disambiguation",
|
|
195
|
+
"snapshot": "📸 Snapshot",
|
|
196
|
+
"snapshot_restore": "↩️ Snapshot geri yükleme",
|
|
197
|
+
"export": "📤 Export",
|
|
198
|
+
"report": "📄 Rapor",
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _render_smart_merge_md(details: dict) -> list[str]:
|
|
203
|
+
"""Smart Merge audit entry için zengin Markdown bloğu."""
|
|
204
|
+
lines: list[str] = []
|
|
205
|
+
wos = details.get("wos_input", 0)
|
|
206
|
+
scp = details.get("scopus_input", 0)
|
|
207
|
+
merged = details.get("merged_count", 0)
|
|
208
|
+
matched = details.get("matched_pairs", 0)
|
|
209
|
+
borderline = details.get("borderline_count", 0)
|
|
210
|
+
conflict = details.get("conflict_count", 0)
|
|
211
|
+
lost_wos = details.get("lost_wos_count", 0)
|
|
212
|
+
lost_scp = details.get("lost_scopus_count", 0)
|
|
213
|
+
duration = details.get("duration_seconds")
|
|
214
|
+
|
|
215
|
+
# Akış özeti
|
|
216
|
+
lines.append("**Birleştirme Özeti:**")
|
|
217
|
+
lines.append("")
|
|
218
|
+
lines.append("| Metrik | Değer |")
|
|
219
|
+
lines.append("|---|---|")
|
|
220
|
+
lines.append(f"| Ham girdi | {wos} WoS + {scp} Scopus = **{wos + scp}** |")
|
|
221
|
+
lines.append(f"| Eşleştirilen çift | **{matched}** |")
|
|
222
|
+
lines.append(f"| Borderline (manuel onay bekleyen) | {borderline} |")
|
|
223
|
+
lines.append(f"| Eşleşmemiş WoS (lost) | {lost_wos} |")
|
|
224
|
+
lines.append(f"| Eşleşmemiş Scopus (lost) | {lost_scp} |")
|
|
225
|
+
lines.append(f"| Çıktı (benzersiz kayıt) | **{merged}** |")
|
|
226
|
+
dedup_rate = (wos + scp - merged) / (wos + scp) if (wos + scp) else 0
|
|
227
|
+
lines.append(f"| Tekilleştirme oranı | %{dedup_rate * 100:.1f} |")
|
|
228
|
+
if duration:
|
|
229
|
+
lines.append(f"| Süre | {duration:.1f} sn |")
|
|
230
|
+
lines.append("")
|
|
231
|
+
|
|
232
|
+
# Eşleşme aşamaları
|
|
233
|
+
stages = details.get("match_stages") or {}
|
|
234
|
+
if stages:
|
|
235
|
+
lines.append("**Eşleşme Aşamaları** _(Hammerton 2013 + Caputo 2024)_:")
|
|
236
|
+
lines.append("")
|
|
237
|
+
lines.append("| Aşama | Eşleşme |")
|
|
238
|
+
lines.append("|---|---|")
|
|
239
|
+
for stage, count in sorted(stages.items(), key=lambda x: -x[1]):
|
|
240
|
+
lines.append(f"| {stage} | {count} |")
|
|
241
|
+
lines.append("")
|
|
242
|
+
|
|
243
|
+
# Çakışma kaynak dağılımı (Caputo 2024)
|
|
244
|
+
field_dist = details.get("field_source_distribution") or {}
|
|
245
|
+
if field_dist:
|
|
246
|
+
total = sum(field_dist.values())
|
|
247
|
+
lines.append(f"**Alan Çakışma Çözümü** _({conflict} çakışma — Caputo 2024 sabit defaults)_:")
|
|
248
|
+
lines.append("")
|
|
249
|
+
lines.append("| Tercih Edilen Kaynak | Sayı | Yüzde |")
|
|
250
|
+
lines.append("|---|---|---|")
|
|
251
|
+
for src, count in sorted(field_dist.items(), key=lambda x: -x[1]):
|
|
252
|
+
pct = (count / total * 100) if total else 0
|
|
253
|
+
lines.append(f"| {src} | {count} | %{pct:.1f} |")
|
|
254
|
+
lines.append("")
|
|
255
|
+
|
|
256
|
+
# Üretilen dosyalar
|
|
257
|
+
files = details.get("output_files") or []
|
|
258
|
+
if files:
|
|
259
|
+
lines.append("**Üretilen Dosyalar:**")
|
|
260
|
+
for f in files:
|
|
261
|
+
lines.append(f"- `{f}`")
|
|
262
|
+
lines.append("")
|
|
263
|
+
|
|
264
|
+
return lines
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def format_markdown_report(project_id: str, analysis_id: Optional[str] = _ALL) -> str:
|
|
268
|
+
import datetime as dt
|
|
269
|
+
meta = storage.get_project(project_id)
|
|
270
|
+
entries = read(project_id, analysis_id=analysis_id)
|
|
271
|
+
name = meta.name if meta else project_id
|
|
272
|
+
|
|
273
|
+
lines: list[str] = []
|
|
274
|
+
lines.append(f"# BibexPy v2 — Operasyon Raporu")
|
|
275
|
+
lines.append("")
|
|
276
|
+
lines.append(f"**Proje:** {name} (`{project_id}`) ")
|
|
277
|
+
lines.append(f"**Oluşturma:** {dt.datetime.now().strftime('%Y-%m-%d %H:%M')} ")
|
|
278
|
+
lines.append(f"**Toplam operasyon:** {len(entries)}")
|
|
279
|
+
lines.append("")
|
|
280
|
+
|
|
281
|
+
if not entries:
|
|
282
|
+
lines.append("_Henüz operasyon kaydı yok._")
|
|
283
|
+
return "\n".join(lines)
|
|
284
|
+
|
|
285
|
+
# Özet — filtrelenmiş kayıtlardan (aktif analiz geçmişi)
|
|
286
|
+
by_kind: dict[str, int] = {}
|
|
287
|
+
for e in entries:
|
|
288
|
+
by_kind[e.get("kind", "?")] = by_kind.get(e.get("kind", "?"), 0) + 1
|
|
289
|
+
lines.append("## Özet")
|
|
290
|
+
lines.append("")
|
|
291
|
+
lines.append("| Kategori | Sayı |")
|
|
292
|
+
lines.append("|---|---|")
|
|
293
|
+
for k, n in sorted(by_kind.items(), key=lambda x: -x[1]):
|
|
294
|
+
lines.append(f"| {KIND_LABELS.get(k, k)} | {n} |")
|
|
295
|
+
lines.append("")
|
|
296
|
+
|
|
297
|
+
lines.append("## Kronoloji")
|
|
298
|
+
lines.append("")
|
|
299
|
+
for i, e in enumerate(entries, 1):
|
|
300
|
+
ts = e.get("ts")
|
|
301
|
+
ts_str = dt.datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S") if ts else "—"
|
|
302
|
+
label = KIND_LABELS.get(e.get("kind", ""), e.get("kind", "?"))
|
|
303
|
+
title = e.get("title", "")
|
|
304
|
+
lines.append(f"### {i}. {label} — {title}")
|
|
305
|
+
lines.append(f"_{ts_str}_")
|
|
306
|
+
details = e.get("details") or {}
|
|
307
|
+
|
|
308
|
+
# Smart Merge için zengin format — JSON dump değil
|
|
309
|
+
if e.get("kind") == "merge" and details.get("method") == "smart":
|
|
310
|
+
lines.append("")
|
|
311
|
+
lines.extend(_render_smart_merge_md(details))
|
|
312
|
+
if e.get("snapshot"):
|
|
313
|
+
lines.append(f"- 📸 snapshot: `{e['snapshot']}`")
|
|
314
|
+
lines.append("")
|
|
315
|
+
continue
|
|
316
|
+
|
|
317
|
+
if details:
|
|
318
|
+
lines.append("")
|
|
319
|
+
for k, v in details.items():
|
|
320
|
+
if v is None or v == "":
|
|
321
|
+
continue
|
|
322
|
+
if isinstance(v, (list, dict)):
|
|
323
|
+
v = json.dumps(v, ensure_ascii=False)
|
|
324
|
+
if len(str(v)) > 200:
|
|
325
|
+
v = str(v)[:200] + "…"
|
|
326
|
+
lines.append(f"- **{k}:** {v}")
|
|
327
|
+
before = e.get("before")
|
|
328
|
+
after = e.get("after")
|
|
329
|
+
if before or after:
|
|
330
|
+
lines.append("")
|
|
331
|
+
if before:
|
|
332
|
+
lines.append(f"- _önce:_ `{json.dumps(before, ensure_ascii=False)}`")
|
|
333
|
+
if after:
|
|
334
|
+
lines.append(f"- _sonra:_ `{json.dumps(after, ensure_ascii=False)}`")
|
|
335
|
+
if e.get("snapshot"):
|
|
336
|
+
lines.append(f"- 📸 snapshot: `{e['snapshot']}`")
|
|
337
|
+
if e.get("user_action"):
|
|
338
|
+
lines.append(f"- 👤 kullanıcı: _{e['user_action']}_")
|
|
339
|
+
lines.append("")
|
|
340
|
+
return "\n".join(lines)
|