mnoorchenar commited on
Commit
e3aa9c3
·
1 Parent(s): a736553

Update 2026-04-05 15:34:53

Browse files
Files changed (1) hide show
  1. app.py +884 -936
app.py CHANGED
@@ -1,938 +1,886 @@
1
- """
2
- Flask Application for Reference Management Pipeline
3
- Complete implementation matching overleaf.py functionality
4
- Includes LaTeX citation parsing, BibTeX processing, and full pipeline
5
-
6
- FEATURES:
7
- - Fixed abbreviations to include periods (ISO 4 standard): "Energy" → "Ener."
8
- - LaTeX manuscript analysis for citation frequency and section tracking
9
- - Filter references by section
10
- - Clear entire database
11
- """
12
-
13
- from flask import Flask, render_template, request, jsonify, send_file
14
- import sqlite3
15
- import pandas as pd
16
- import os
17
- import json
18
- from datetime import datetime
19
- import re
20
- from difflib import SequenceMatcher
21
- import requests
22
- from requests.adapters import HTTPAdapter
23
- from urllib3.util.retry import Retry
24
- import io
25
- import time
26
- import random
27
- import hashlib
28
- from werkzeug.utils import secure_filename
29
-
30
- app = Flask(__name__)
31
- app.config['DATABASE'] = 'refs_management.db'
32
- app.config['API_KEY'] = os.environ.get('API_KEY', 'your-secret-key-here')
33
- app.config['ENVIRONMENT'] = os.environ.get('ENVIRONMENT', 'development')
34
- app.config['UPLOAD_FOLDER'] = '/tmp/uploads'
35
- app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB max
36
-
37
- # Create upload folder
38
- os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
39
-
40
- # Prepositions to keep lowercase in abbreviations
41
- LOWERCASE_WORDS = {"and", "or", "in", "on", "of", "for", "to", "the", "a", "an", "with", "at", "by", "from"}
42
-
43
- # =====================================================================
44
- # HTTP CLIENT WITH RETRIES
45
- # =====================================================================
46
-
47
- def make_http_session():
48
- """Create HTTP session with retries and proper headers"""
49
- s = requests.Session()
50
- retry = Retry(
51
- total=5,
52
- backoff_factor=0.8,
53
- status_forcelist=[429, 500, 502, 503, 504],
54
- allowed_methods=["GET"]
55
- )
56
- s.mount("https://", HTTPAdapter(max_retries=retry))
57
- s.headers.update({
58
- "User-Agent": "RefsManagement/1.0 (mailto:contact@example.com)",
59
- "Accept": "application/json",
60
- })
61
- return s
62
-
63
- HTTP = make_http_session()
64
-
65
- # =====================================================================
66
- # AUTHENTICATION
67
- # =====================================================================
68
-
69
- def check_api_key():
70
- """Check API key for protected routes"""
71
- if app.config['ENVIRONMENT'] == 'development':
72
- return True
73
- api_key = request.headers.get('X-API-Key')
74
- return api_key == app.config['API_KEY']
75
-
76
- # =====================================================================
77
- # LATEX CITATION PARSING
78
- # =====================================================================
79
-
80
- def parse_citations_from_tex(tex_content: str) -> pd.DataFrame:
81
- """Parse citations from LaTeX content with section tracking"""
82
- print("📖 Parsing citations from LaTeX")
83
-
84
- lines = tex_content.split('\n')
85
- clean_text = "\n".join(line for line in lines if not line.strip().startswith("%"))
86
-
87
- section_pattern = re.compile(r'\\section\{([^}]*)\}(?:\\label\{[^}]*\})?')
88
- cite_pattern = re.compile(r'\\cite\{([^}]*)\}')
89
- sections = section_pattern.split(clean_text)
90
-
91
- citations, ref_sections = [], {}
92
- for i in range(1, len(sections), 2):
93
- if i >= len(sections):
94
- break
95
- section_name = sections[i].strip()
96
- section_text = sections[i + 1] if i + 1 < len(sections) else ""
97
- matches = cite_pattern.findall(section_text)
98
- for match in matches:
99
- for key in match.split(","):
100
- ref = key.strip()
101
- citations.append(ref)
102
- if ref not in ref_sections:
103
- ref_sections[ref] = []
104
- if section_name not in ref_sections[ref]:
105
- ref_sections[ref].append(section_name)
106
-
107
- freq, order = {}, []
108
- for c in citations:
109
- if c not in freq:
110
- order.append(c)
111
- freq[c] = freq.get(c, 0) + 1
112
-
113
- df = pd.DataFrame({
114
- "Reference": order,
115
- "Frequency": [freq[c] for c in order],
116
- "Sections": [", ".join(ref_sections[c]) for c in order]
117
- })
118
- print(f"✅ Found {len(df)} unique citations")
119
- return df
120
-
121
- def merge_citations_with_bib(citations_df: pd.DataFrame, bib_df: pd.DataFrame) -> pd.DataFrame:
122
- """Merge citations with BibTeX entries"""
123
- print("🔗 Merging citations with BibTeX")
124
- bib_lookup = bib_df.set_index("Key").to_dict(orient="index")
125
- merged_records = []
126
-
127
- for _, row in citations_df.iterrows():
128
- key = row["Reference"]
129
- bib_info = bib_lookup.get(key, {})
130
- merged_records.append({
131
- "Reference": key,
132
- "Frequency": row["Frequency"],
133
- "Sections": row["Sections"],
134
- "Type": bib_info.get("Type", ""),
135
- "Authors": bib_info.get("Authors", ""),
136
- "Title": bib_info.get("Title", ""),
137
- "Journal/Booktitle": bib_info.get("Journal/Booktitle", ""),
138
- "Year": bib_info.get("Year", ""),
139
- "Publisher": bib_info.get("Publisher", ""),
140
- "Volume": bib_info.get("Volume", ""),
141
- "Pages": bib_info.get("Pages", ""),
142
- "DOI": bib_info.get("DOI", ""),
143
- "BibTeX": bib_info.get("BibTeX", "")
144
- })
145
-
146
- df = pd.DataFrame(merged_records)
147
- print(f"✅ Merged into {len(df)} rows")
148
- return df
149
-
150
- # =====================================================================
151
- # UTILITY FUNCTIONS
152
- # =====================================================================
153
-
154
- def abbreviate_journal_custom(title: str) -> str:
155
- """
156
- Custom abbreviation with periods (ISO 4 standard)
157
- Examples:
158
- "Energy" → "Ener."
159
- "Applied Energy" → "Appl. Ener."
160
- "Journal of Energy" → "J. of Ener."
161
- "IEEE Transactions on Neural Networks" → "IEEE Trans. on Neural Netw."
162
- """
163
- if not title:
164
- return ""
165
-
166
- words = title.split()
167
- abbr = []
168
-
169
- for i, word in enumerate(words):
170
- if word.lower() in LOWERCASE_WORDS and i != 0:
171
- abbr.append(word.lower())
172
- elif sum(1 for c in word if c.isupper()) >= 2:
173
- abbr.append(word)
174
- elif len(word) > 4:
175
- abbr.append(word[:4].capitalize() + ".")
176
- else:
177
- abbr.append(word.capitalize() + ".")
178
-
179
- return " ".join(abbr)
180
-
181
- def get_db_connection():
182
- """Get database connection"""
183
- conn = sqlite3.connect(app.config['DATABASE'])
184
- conn.row_factory = sqlite3.Row
185
- return conn
186
-
187
- def init_db():
188
- """Initialize database with proper schema"""
189
- conn = get_db_connection()
190
- cur = conn.cursor()
191
-
192
- cur.execute("""
193
- CREATE TABLE IF NOT EXISTS bibliography (
194
- id INTEGER PRIMARY KEY AUTOINCREMENT,
195
- index_num INTEGER,
196
- session_id TEXT,
197
- reference TEXT,
198
- frequency INTEGER,
199
- sections TEXT,
200
- key TEXT UNIQUE,
201
- doi TEXT,
202
- type TEXT,
203
- authors TEXT,
204
- title TEXT,
205
- journal_booktitle TEXT,
206
- year TEXT,
207
- year_int INTEGER,
208
- publisher TEXT,
209
- volume TEXT,
210
- pages TEXT,
211
- bibtex TEXT,
212
- crossref_bibtex TEXT,
213
- crossref_bibtex_localkey TEXT,
214
- title_similarity INTEGER,
215
- journal_abbreviation TEXT,
216
- crossref_bibtex_abbrev TEXT,
217
- crossref_bibtex_protected TEXT,
218
- used TEXT,
219
- imported_date TEXT,
220
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
221
- )
222
- """)
223
-
224
- cur.execute("""
225
- CREATE INDEX IF NOT EXISTS idx_bib_doi
226
- ON bibliography(doi)
227
- WHERE doi IS NOT NULL AND doi != ''
228
- """)
229
-
230
- conn.commit()
231
- conn.close()
232
-
233
- def extract_year_int(year_str):
234
- """Extract integer year from year string"""
235
- if not year_str:
236
- return None
237
- match = re.search(r'\d{4}', str(year_str))
238
- return int(match.group()) if match else None
239
-
240
- def scan_brace_balanced_value(text, start_pos):
241
- """Scan for brace-balanced field value"""
242
- if start_pos >= len(text):
243
- return "", start_pos
244
-
245
- if text[start_pos] == '{':
246
- depth = 1
247
- pos = start_pos + 1
248
- while pos < len(text) and depth > 0:
249
- if text[pos] == '{':
250
- depth += 1
251
- elif text[pos] == '}':
252
- depth -= 1
253
- pos += 1
254
- return text[start_pos + 1:pos - 1], pos
255
- elif text[start_pos] == '"':
256
- pos = start_pos + 1
257
- while pos < len(text):
258
- if text[pos] == '"' and text[pos - 1] != '\\':
259
- return text[start_pos + 1:pos], pos + 1
260
- pos += 1
261
- return text[start_pos + 1:], len(text)
262
- else:
263
- pos = start_pos
264
- while pos < len(text) and text[pos] not in ',}':
265
- pos += 1
266
- return text[start_pos:pos].strip(), pos
267
-
268
- def parse_bibtex_entry(entry_text):
269
- """Parse single BibTeX entry with proper brace balancing"""
270
- match = re.match(r'@(\w+)\s*\{([^,]+),', entry_text)
271
- if not match:
272
- return None
273
-
274
- entry_type, entry_key = match.groups()
275
- fields = {}
276
-
277
- fields_start = entry_text.find(entry_key) + len(entry_key) + 1
278
- fields_text = entry_text[fields_start:]
279
-
280
- pos = 0
281
- while pos < len(fields_text):
282
- while pos < len(fields_text) and fields_text[pos] in ' \t\n\r,':
283
- pos += 1
284
- if pos >= len(fields_text) or fields_text[pos] == '}':
285
- break
286
-
287
- field_match = re.match(r'(\w+)\s*=\s*', fields_text[pos:])
288
- if not field_match:
289
- break
290
-
291
- field_name = field_match.group(1).lower()
292
- pos += field_match.end()
293
-
294
- value, new_pos = scan_brace_balanced_value(fields_text, pos)
295
- fields[field_name] = value.strip()
296
- pos = new_pos
297
-
298
- return {
299
- 'type': entry_type,
300
- 'key': entry_key.strip(),
301
- 'fields': fields
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
302
  }
303
 
304
- def parse_bibtex_input(bibtex_content):
305
- """Parse BibTeX content from user input"""
306
- entries = ["@" + e for e in bibtex_content.split("@") if e.strip()]
307
- papers = []
308
-
309
- for entry in entries:
310
- parsed = parse_bibtex_entry(entry)
311
- if not parsed:
312
- continue
313
-
314
- fields = parsed['fields']
315
-
316
- papers.append({
317
- "Key": parsed['key'],
318
- "Type": parsed['type'],
319
- "Authors": fields.get("author", "").strip(),
320
- "Title": fields.get("title", "").strip(),
321
- "Journal/Booktitle": fields.get("journal", fields.get("booktitle", "")).strip(),
322
- "Year": fields.get("year", "").strip(),
323
- "Publisher": fields.get("publisher", fields.get("organization", "")).strip(),
324
- "Volume": fields.get("volume", "").strip(),
325
- "Pages": fields.get("pages", "").strip(),
326
- "DOI": fields.get("doi", "").strip(),
327
- "BibTeX": entry.strip(),
328
- "Imported_Date": datetime.now().isoformat()
329
- })
330
-
331
- return pd.DataFrame(papers).drop_duplicates(subset="Key", keep="first").reset_index(drop=True)
332
-
333
- def clean_bibtex_fields(bibtex):
334
- """Remove unwanted fields from BibTeX entries"""
335
- if not bibtex:
336
- return bibtex
337
-
338
- fields_to_remove = ['url', 'source', 'publication_stage', 'note', 'abstract']
339
-
340
- for field in fields_to_remove:
341
- pattern = rf'\s*{field}\s*=\s*'
342
- pos = 0
343
- result = []
344
-
345
- while pos < len(bibtex):
346
- match = re.search(pattern, bibtex[pos:], re.IGNORECASE)
347
- if not match:
348
- result.append(bibtex[pos:])
349
- break
350
-
351
- result.append(bibtex[pos:pos + match.start()])
352
- value_start = pos + match.end()
353
- _, value_end = scan_brace_balanced_value(bibtex, value_start)
354
-
355
- while value_end < len(bibtex) and bibtex[value_end] in ' \t\n\r,':
356
- value_end += 1
357
-
358
- pos = value_end
359
-
360
- bibtex = ''.join(result)
361
-
362
- bibtex = re.sub(r'\n\s*\n\s*\n', '\n\n', bibtex)
363
- bibtex = re.sub(r',\s*,', ',', bibtex)
364
- bibtex = re.sub(r',(\s*)\}', r'\1}', bibtex)
365
-
366
- lines = [line for line in bibtex.split('\n') if line.strip()]
367
- return '\n'.join(lines)
368
-
369
- def protect_acronyms_in_fields(bibtex):
370
- """Protect acronyms with braces"""
371
- if not bibtex:
372
- return bibtex
373
-
374
- def wrap_token(token):
375
- if token.startswith("{") and token.endswith("}"):
376
- return token
377
- if sum(1 for c in token if c.isupper()) >= 2:
378
- return "{" + token + "}"
379
- return token
380
-
381
- def process_field_value(value):
382
- if value.startswith("{") and value.endswith("}"):
383
- inner = value[1:-1]
384
- if not ('{' in inner and '}' in inner):
385
- return value
386
-
387
- tokens = re.split(r'(\s+)', value)
388
- fixed = "".join(wrap_token(tok) if tok.strip() else tok for tok in tokens)
389
- fixed = re.sub(r'\{\{([^{}]+)\}\}', r'{\1}', fixed)
390
- return fixed
391
-
392
- for field in ["title", "booktitle", "journal"]:
393
- pattern = rf'({field}\s*=\s*)'
394
- matches = list(re.finditer(pattern, bibtex, re.IGNORECASE))
395
-
396
- for match in reversed(matches):
397
- field_start = match.end()
398
- value, value_end = scan_brace_balanced_value(bibtex, field_start)
399
-
400
- if value:
401
- processed = process_field_value(value)
402
- new_field = f"{match.group(1)}{{{processed}}}"
403
- bibtex = bibtex[:match.start()] + new_field + bibtex[value_end:]
404
-
405
- return bibtex
406
-
407
- def replace_bibtex_key(bibtex, new_key):
408
- """Replace the citation key in a BibTeX entry"""
409
- if not bibtex:
410
- return bibtex
411
-
412
- try:
413
- start_brace = bibtex.index("{")
414
- first_comma = bibtex.index(",", start_brace)
415
- entry_type = bibtex[:start_brace]
416
- new_start = f"{entry_type}{{{new_key},"
417
- return new_start + bibtex[first_comma + 1:]
418
- except ValueError:
419
- return bibtex
420
-
421
- def enrich_with_crossref(df):
422
- """Enrich references with Crossref data"""
423
- enriched_rows = []
424
-
425
- for idx, row in df.iterrows():
426
- enriched_data = dict(row)
427
-
428
- if not row.get('Title'):
429
- enriched_data['Crossref_BibTeX'] = row.get('BibTeX', '')
430
- enriched_data['Title_Similarity'] = 0
431
- enriched_rows.append(enriched_data)
432
- continue
433
-
434
- query_parts = [row['Title']]
435
- if row.get('Authors'):
436
- query_parts.append(row['Authors'].split(',')[0])
437
- if row.get('Journal/Booktitle'):
438
- query_parts.append(row['Journal/Booktitle'])
439
- if row.get('Year'):
440
- query_parts.append(row['Year'])
441
-
442
- query = " ".join(query_parts)
443
-
444
- try:
445
- url = f"https://api.crossref.org/works?query.bibliographic={requests.utils.quote(query)}&rows=3"
446
- response = HTTP.get(url, timeout=15)
447
- response.raise_for_status()
448
- items = response.json().get("message", {}).get("items", [])
449
-
450
- best_score = 0
451
- crossref_bibtex = row.get('BibTeX', '')
452
- best_doi = row.get('DOI', '')
453
-
454
- for item in items:
455
- cr_title = item.get("title", [""])[0]
456
- score = SequenceMatcher(None, row['Title'].lower(), cr_title.lower()).ratio()
457
-
458
- if row.get('Year') and 'published-print' in item:
459
- cr_year = str(item['published-print'].get('date-parts', [['']])[0][0])
460
- if row['Year'].strip() == cr_year:
461
- score = min(1.0, score + 0.1)
462
-
463
- if score > best_score:
464
- best_score = score
465
- best_doi = item.get('DOI', best_doi)
466
-
467
- if best_doi:
468
- try:
469
- bibtex_response = HTTP.get(
470
- f"https://doi.org/{best_doi}",
471
- headers={"Accept": "application/x-bibtex"},
472
- timeout=15
473
- )
474
- if bibtex_response.status_code == 200:
475
- crossref_bibtex = bibtex_response.text.strip()
476
- except Exception as e:
477
- print(f"⚠️ BibTeX fetch failed for DOI {best_doi}: {e}")
478
-
479
- enriched_data['Crossref_BibTeX'] = crossref_bibtex if best_score >= 0.85 else row.get('BibTeX', '')
480
- enriched_data['Title_Similarity'] = int(round(best_score * 100))
481
- if best_doi:
482
- enriched_data['DOI'] = best_doi
483
-
484
- except Exception as e:
485
- print(f"⚠️ Crossref enrichment failed: {e}")
486
- enriched_data['Crossref_BibTeX'] = row.get('BibTeX', '')
487
- enriched_data['Title_Similarity'] = 0
488
-
489
- time.sleep(0.15 + random.uniform(0, 0.25))
490
- enriched_rows.append(enriched_data)
491
-
492
- return pd.DataFrame(enriched_rows)
493
-
494
- def add_journal_abbreviations(df):
495
- """Add journal abbreviations and create all BibTeX versions"""
496
- abbreviated_rows = []
497
-
498
- for idx, row in df.iterrows():
499
- journal = row.get('Journal/Booktitle', '')
500
- journal_abbrev = abbreviate_journal_custom(journal)
501
-
502
- row_data = dict(row)
503
- row_data['Journal_Abbreviation'] = journal_abbrev
504
-
505
- key_to_use = row_data.get('Key') or row_data.get('Reference') or f"ref_{idx}"
506
-
507
- if row_data.get('Crossref_BibTeX'):
508
- row_data['Crossref_BibTeX_LocalKey'] = replace_bibtex_key(
509
- row_data['Crossref_BibTeX'],
510
- key_to_use
511
- )
512
- else:
513
- row_data['Crossref_BibTeX_LocalKey'] = row_data.get('BibTeX', '')
514
-
515
- if journal_abbrev and row_data.get('Crossref_BibTeX_LocalKey'):
516
- new_bib = row_data['Crossref_BibTeX_LocalKey'].strip()
517
- new_bib = re.sub(
518
- r'(journal\s*=\s*\{)[^}]+(\})',
519
- rf'\1{journal_abbrev}\2',
520
- new_bib,
521
- flags=re.IGNORECASE
522
- )
523
- row_data['Crossref_BibTeX_Abbrev'] = new_bib
524
- else:
525
- row_data['Crossref_BibTeX_Abbrev'] = row_data.get('Crossref_BibTeX_LocalKey', row_data.get('BibTeX', ''))
526
-
527
- row_data['Crossref_BibTeX_Protected'] = protect_acronyms_in_fields(
528
- row_data.get('Crossref_BibTeX_Abbrev', row_data.get('BibTeX', ''))
529
- )
530
-
531
- for bib_col in ['BibTeX', 'Crossref_BibTeX', 'Crossref_BibTeX_LocalKey',
532
- 'Crossref_BibTeX_Abbrev', 'Crossref_BibTeX_Protected']:
533
- if row_data.get(bib_col):
534
- row_data[bib_col] = clean_bibtex_fields(row_data[bib_col])
535
-
536
- abbreviated_rows.append(row_data)
537
-
538
- return pd.DataFrame(abbreviated_rows)
539
-
540
- # =====================================================================
541
- # ROUTES
542
- # =====================================================================
543
-
544
- @app.route('/')
545
- def index():
546
- """Main page — falls back to JSON status if template is missing"""
547
- try:
548
- return render_template('index.html')
549
- except Exception:
550
- return jsonify({'status': 'running', 'message': 'Reference Management API is live. No index.html found in templates/.'}), 200
551
-
552
- @app.route('/api/process', methods=['POST'])
553
- def process_bibtex():
554
- """Process BibTeX content with optional LaTeX analysis"""
555
- if not check_api_key():
556
- return jsonify({'error': 'Unauthorized'}), 401
557
-
558
- try:
559
- if request.is_json:
560
- data = request.get_json()
561
- bibtex_content = data.get('bibtex_content') or data.get('bibtex', '')
562
- input_mode = data.get('input_mode', 'bibtex')
563
- enrich = data.get('enrich', False)
564
- abbreviate = data.get('abbreviate', False)
565
- protect = data.get('protect', False)
566
- save_to_db = data.get('save_to_db', False)
567
- latex_file = None
568
- else:
569
- bibtex_content = request.form.get('bibtex_content', '')
570
- input_mode = request.form.get('input_mode', 'bibtex')
571
- enrich = request.form.get('enrich', 'false').lower() == 'true'
572
- abbreviate = request.form.get('abbreviate', 'false').lower() == 'true'
573
- protect = request.form.get('protect', 'false').lower() == 'true'
574
- save_to_db = request.form.get('save_to_db', 'false').lower() == 'true'
575
- latex_file = request.files.get('latex_file')
576
-
577
- print(f"📥 Received: {len(bibtex_content)} chars, mode={input_mode}")
578
-
579
- # TITLE MODE: Search Crossref for each title
580
- if input_mode == 'title':
581
- titles = [line.strip() for line in bibtex_content.split('\n') if line.strip()]
582
- print(f"🔍 Title mode: searching for {len(titles)} titles")
583
-
584
- if not titles:
585
- return jsonify({'error': 'No titles provided'}), 400
586
-
587
- bibtex_entries = []
588
- for i, title in enumerate(titles, 1):
589
- print(f" [{i}/{len(titles)}] Searching: {title[:50]}...")
590
- try:
591
- url = f"https://api.crossref.org/works?query.bibliographic={requests.utils.quote(title)}&rows=1"
592
- response = HTTP.get(url, timeout=15)
593
- items = response.json().get("message", {}).get("items", [])
594
-
595
- if items and items[0].get('DOI'):
596
- doi = items[0]['DOI']
597
- bibtex_r = HTTP.get(
598
- f"https://doi.org/{doi}",
599
- headers={"Accept": "application/x-bibtex"},
600
- timeout=15
601
- )
602
- if bibtex_r.status_code == 200:
603
- bibtex_entries.append(bibtex_r.text.strip())
604
- print(f" ✅ Found via DOI: {doi}")
605
-
606
- time.sleep(random.uniform(1, 2))
607
- except Exception as e:
608
- print(f" ⚠️ Failed: {e}")
609
-
610
- bibtex_content = '\n\n'.join(bibtex_entries)
611
- print(f"✅ Retrieved {len(bibtex_entries)} BibTeX entries from titles")
612
-
613
- if not bibtex_content:
614
- return jsonify({'error': 'No BibTeX entries found for the provided titles'}), 400
615
-
616
- if not bibtex_content or len(bibtex_content.strip()) == 0:
617
- return jsonify({'error': 'No BibTeX content provided'}), 400
618
-
619
- df = parse_bibtex_input(bibtex_content)
620
-
621
- if df.empty:
622
- return jsonify({'error': 'No valid BibTeX entries found'}), 400
623
-
624
- latex_analyzed = False
625
- citations_found = 0
626
- if latex_file:
627
- print("📄 LaTeX file provided, analyzing...")
628
- try:
629
- latex_content = latex_file.read().decode('utf-8')
630
- citations_df = parse_citations_from_tex(latex_content)
631
- citations_found = len(citations_df)
632
- df = merge_citations_with_bib(citations_df, df)
633
- df.insert(0, "Index", range(1, len(df) + 1))
634
- latex_analyzed = True
635
- print(f"✅ LaTeX analyzed: {citations_found} citations found")
636
- except Exception as e:
637
- print(f"⚠️ LaTeX analysis failed: {e}")
638
-
639
- if enrich:
640
- df = enrich_with_crossref(df)
641
- else:
642
- df['Crossref_BibTeX'] = df['BibTeX']
643
- df['Title_Similarity'] = 0
644
-
645
- df = add_journal_abbreviations(df)
646
-
647
- db_id = None
648
- if save_to_db:
649
- conn = get_db_connection()
650
- cursor = conn.cursor()
651
- session_id = datetime.now().isoformat()
652
-
653
- for _, row in df.iterrows():
654
- doi = row.get('DOI', '').strip()
655
- year_int = extract_year_int(row.get('Year', ''))
656
- key_val = row.get('Reference') or row.get('Key', f"ref_{row.name}")
657
-
658
- try:
659
- cursor.execute('''
660
- INSERT OR REPLACE INTO bibliography
661
- (index_num, session_id, reference, frequency, sections, key, doi, type,
662
- authors, title, journal_booktitle, year, year_int, publisher, volume, pages,
663
- bibtex, crossref_bibtex, crossref_bibtex_localkey, title_similarity,
664
- journal_abbreviation, crossref_bibtex_abbrev, crossref_bibtex_protected,
665
- imported_date)
666
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
667
- ''', (
668
- row.get('Index'), session_id, row.get('Reference', key_val),
669
- row.get('Frequency', 0), row.get('Sections', ''),
670
- key_val, doi, row.get('Type', ''), row.get('Authors', ''),
671
- row.get('Title', ''), row.get('Journal/Booktitle', ''),
672
- row.get('Year', ''), year_int, row.get('Publisher', ''),
673
- row.get('Volume', ''), row.get('Pages', ''),
674
- row.get('BibTeX', ''), row.get('Crossref_BibTeX', ''),
675
- row.get('Crossref_BibTeX_LocalKey', ''),
676
- row.get('Title_Similarity', 0), row.get('Journal_Abbreviation', ''),
677
- row.get('Crossref_BibTeX_Abbrev', ''),
678
- row.get('Crossref_BibTeX_Protected', ''),
679
- datetime.now().isoformat()
680
- ))
681
- except sqlite3.IntegrityError as e:
682
- print(f"⚠️ DB insert failed for {key_val}: {e}")
683
-
684
- conn.commit()
685
- db_id = session_id
686
- conn.close()
687
-
688
- if protect:
689
- final_bibtex_col = 'Crossref_BibTeX_Protected'
690
- elif abbreviate:
691
- final_bibtex_col = 'Crossref_BibTeX_Abbrev'
692
- else:
693
- final_bibtex_col = 'Crossref_BibTeX_LocalKey'
694
-
695
- response_cols = ['Key', 'Type', 'Authors', 'Title', 'Journal/Booktitle', 'Year', final_bibtex_col]
696
- if latex_analyzed:
697
- response_cols.insert(6, 'Frequency')
698
- response_cols.insert(7, 'Sections')
699
-
700
- response_df = df[[col for col in response_cols if col in df.columns]].copy()
701
- response_df.columns = list(response_df.columns[:-1]) + ['Final_BibTeX']
702
-
703
- return jsonify({
704
- 'success': True,
705
- 'count': len(df),
706
- 'db_id': db_id,
707
- 'latex_analyzed': latex_analyzed,
708
- 'citations_found': citations_found,
709
- 'data': response_df.to_dict(orient='records'),
710
- 'full_data': df.to_dict(orient='records')
711
- })
712
-
713
- except Exception as e:
714
- import traceback
715
- traceback.print_exc()
716
- return jsonify({'error': str(e)}), 500
717
-
718
- @app.route('/api/sections/list', methods=['GET'])
719
- def list_sections():
720
- """Get list of all unique sections from database"""
721
- try:
722
- conn = get_db_connection()
723
- cursor = conn.cursor()
724
- cursor.execute('SELECT DISTINCT sections FROM bibliography WHERE sections IS NOT NULL AND sections != ""')
725
- rows = cursor.fetchall()
726
- conn.close()
727
-
728
- all_sections = set()
729
- for row in rows:
730
- if row[0]:
731
- sections = [s.strip() for s in row[0].split(',')]
732
- all_sections.update(sections)
733
-
734
- return jsonify({
735
- 'success': True,
736
- 'sections': sorted(list(all_sections))
737
- })
738
- except Exception as e:
739
- return jsonify({'error': str(e)}), 500
740
-
741
- @app.route('/api/sections/references', methods=['GET'])
742
- def get_references_by_section():
743
- """Get all references for a specific section"""
744
- section = request.args.get('section', '')
745
-
746
- if not section:
747
- return jsonify({'error': 'Section parameter required'}), 400
748
-
749
- try:
750
- conn = get_db_connection()
751
- cursor = conn.cursor()
752
- cursor.execute('''
753
- SELECT key, title, authors, year, frequency, sections, reference
754
- FROM bibliography
755
- WHERE sections LIKE ?
756
- ''', (f'%{section}%',))
757
-
758
- rows = cursor.fetchall()
759
- conn.close()
760
-
761
- references = []
762
- for row in rows:
763
- sections_list = [s.strip() for s in row[5].split(',') if s.strip()]
764
- if section in sections_list:
765
- total_freq = row[4] or 0
766
- num_sections = len(sections_list)
767
- freq_in_section = total_freq // num_sections if num_sections > 0 else total_freq
768
-
769
- references.append({
770
- 'key': row[0],
771
- 'title': row[1],
772
- 'authors': row[2],
773
- 'year': row[3],
774
- 'frequency_in_section': freq_in_section,
775
- 'total_frequency': total_freq,
776
- 'all_sections': row[5]
777
- })
778
-
779
- return jsonify({
780
- 'success': True,
781
- 'section': section,
782
- 'references': references
783
- })
784
- except Exception as e:
785
- return jsonify({'error': str(e)}), 500
786
-
787
- @app.route('/api/database/entries', methods=['GET'])
788
- def get_database_entries():
789
- """Get all entries from database"""
790
- try:
791
- conn = get_db_connection()
792
- cursor = conn.cursor()
793
- cursor.execute('SELECT * FROM bibliography ORDER BY created_at DESC LIMIT 100')
794
- columns = [description[0] for description in cursor.description]
795
- entries = [dict(zip(columns, row)) for row in cursor.fetchall()]
796
- conn.close()
797
-
798
- return jsonify({
799
- 'success': True,
800
- 'count': len(entries),
801
- 'entries': entries
802
- })
803
- except Exception as e:
804
- return jsonify({'error': str(e)}), 500
805
-
806
- @app.route('/api/database/delete/<key>', methods=['DELETE'])
807
- def delete_entry(key):
808
- """Delete entry from database"""
809
- if not check_api_key():
810
- return jsonify({'error': 'Unauthorized'}), 401
811
-
812
- try:
813
- conn = get_db_connection()
814
- cursor = conn.cursor()
815
- cursor.execute('DELETE FROM bibliography WHERE key=?', (key,))
816
- conn.commit()
817
- conn.close()
818
- return jsonify({'success': True})
819
- except Exception as e:
820
- return jsonify({'error': str(e)}), 500
821
-
822
- @app.route('/api/database/clear', methods=['POST'])
823
- def clear_database():
824
- """Clear all entries from database"""
825
- if not check_api_key():
826
- return jsonify({'error': 'Unauthorized'}), 401
827
-
828
- try:
829
- conn = get_db_connection()
830
- cursor = conn.cursor()
831
- cursor.execute('DELETE FROM bibliography')
832
- deleted_count = cursor.rowcount
833
- # Reset autoincrement counter safely
834
- try:
835
- cursor.execute('DELETE FROM sqlite_sequence WHERE name="bibliography"')
836
- except sqlite3.OperationalError:
837
- pass # sqlite_sequence may not exist if no rows were ever inserted
838
- conn.commit()
839
- conn.close()
840
-
841
- print(f"🗑️ Cleared {deleted_count} entries from database")
842
-
843
- return jsonify({
844
- 'success': True,
845
- 'deleted_count': deleted_count,
846
- 'message': f'Successfully cleared {deleted_count} entries from database'
847
- })
848
- except Exception as e:
849
- import traceback
850
- traceback.print_exc()
851
- return jsonify({'error': str(e)}), 500
852
-
853
- @app.route('/api/database/export', methods=['GET'])
854
- def export_database():
855
- """Export database as CSV"""
856
- try:
857
- conn = get_db_connection()
858
- df = pd.read_sql_query('SELECT * FROM bibliography', conn)
859
- conn.close()
860
-
861
- output = io.StringIO()
862
- df.to_csv(output, index=False)
863
- output.seek(0)
864
-
865
- return send_file(
866
- io.BytesIO(output.getvalue().encode()),
867
- mimetype='text/csv',
868
- as_attachment=True,
869
- download_name='references_export.csv'
870
- )
871
- except Exception as e:
872
- return jsonify({'error': str(e)}), 500
873
-
874
- @app.route('/api/database/export-bibtex', methods=['GET'])
875
- def export_bibtex():
876
- """Export database as BibTeX"""
877
- try:
878
- conn = get_db_connection()
879
- cursor = conn.cursor()
880
- cursor.execute('SELECT key, crossref_bibtex_protected FROM bibliography ORDER BY key')
881
- bibtex_content = '\n\n'.join([row[1] for row in cursor.fetchall() if row[1]])
882
- conn.close()
883
-
884
- return send_file(
885
- io.BytesIO(bibtex_content.encode()),
886
- mimetype='text/plain',
887
- as_attachment=True,
888
- download_name='references.bib'
889
- )
890
- except Exception as e:
891
- return jsonify({'error': str(e)}), 500
892
-
893
- @app.route('/api/database/download', methods=['GET'])
894
- def download_database():
895
- """Download entire database file"""
896
- try:
897
- return send_file(
898
- app.config['DATABASE'],
899
- mimetype='application/x-sqlite3',
900
- as_attachment=True,
901
- download_name='refs_management.db'
902
- )
903
- except Exception as e:
904
- return jsonify({'error': str(e)}), 500
905
-
906
- @app.route('/api/stats', methods=['GET'])
907
- def get_stats():
908
- """Get database statistics"""
909
- try:
910
- conn = get_db_connection()
911
- cursor = conn.cursor()
912
-
913
- cursor.execute('SELECT COUNT(*) FROM bibliography')
914
- total = cursor.fetchone()[0]
915
-
916
- cursor.execute('SELECT COUNT(DISTINCT type) FROM bibliography')
917
- types = cursor.fetchone()[0]
918
-
919
- cursor.execute('SELECT COUNT(DISTINCT year_int) FROM bibliography WHERE year_int IS NOT NULL')
920
- years = cursor.fetchone()[0]
921
-
922
- conn.close()
923
-
924
- return jsonify({
925
- 'total_entries': total,
926
- 'entry_types': types,
927
- 'unique_years': years
928
- })
929
- except Exception as e:
930
- return jsonify({'error': str(e)}), 500
931
-
932
- # =====================================================================
933
- # INIT DB AT MODULE LEVEL — required for gunicorn / HuggingFace Spaces
934
- # =====================================================================
935
- init_db()
936
-
937
- if __name__ == '__main__':
938
- app.run(debug=False, host='0.0.0.0', port=7860)
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>RefManager</title>
7
+ <style>
8
+ @import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@300;400;500&display=swap');
9
+
10
+ :root {
11
+ --bg: #0e0e0e;
12
+ --surface: #161616;
13
+ --border: #2a2a2a;
14
+ --accent: #c8f06e;
15
+ --accent-dim: #8aaa40;
16
+ --text: #e8e8e8;
17
+ --text-dim: #888;
18
+ --text-faint: #444;
19
+ --red: #ff5f5f;
20
+ --mono: 'IBM Plex Mono', monospace;
21
+ --sans: 'IBM Plex Sans', sans-serif;
22
+ }
23
+
24
+ * { box-sizing: border-box; margin: 0; padding: 0; }
25
+
26
+ body {
27
+ background: var(--bg);
28
+ color: var(--text);
29
+ font-family: var(--sans);
30
+ font-size: 14px;
31
+ line-height: 1.5;
32
+ min-height: 100vh;
33
+ }
34
+
35
+ /* LAYOUT */
36
+ .app {
37
+ display: grid;
38
+ grid-template-rows: auto 1fr;
39
+ height: 100vh;
40
+ overflow: hidden;
41
+ }
42
+
43
+ header {
44
+ padding: 14px 24px;
45
+ border-bottom: 1px solid var(--border);
46
+ display: flex;
47
+ align-items: center;
48
+ gap: 16px;
49
+ background: var(--surface);
50
+ }
51
+
52
+ header .logo {
53
+ font-family: var(--mono);
54
+ font-size: 13px;
55
+ font-weight: 600;
56
+ color: var(--accent);
57
+ letter-spacing: 0.08em;
58
+ text-transform: uppercase;
59
+ }
60
+
61
+ header .tagline {
62
+ color: var(--text-dim);
63
+ font-size: 12px;
64
+ font-family: var(--mono);
65
+ }
66
+
67
+ header .stats {
68
+ margin-left: auto;
69
+ display: flex;
70
+ gap: 20px;
71
+ }
72
+
73
+ .stat {
74
+ display: flex;
75
+ flex-direction: column;
76
+ align-items: flex-end;
77
+ gap: 1px;
78
+ }
79
+
80
+ .stat-val {
81
+ font-family: var(--mono);
82
+ font-size: 16px;
83
+ font-weight: 600;
84
+ color: var(--accent);
85
+ line-height: 1;
86
+ }
87
+
88
+ .stat-label {
89
+ font-size: 10px;
90
+ color: var(--text-dim);
91
+ text-transform: uppercase;
92
+ letter-spacing: 0.06em;
93
+ }
94
+
95
+ /* MAIN BODY */
96
+ .main {
97
+ display: grid;
98
+ grid-template-columns: 380px 1fr;
99
+ overflow: hidden;
100
+ }
101
+
102
+ /* LEFT PANEL */
103
+ .left-panel {
104
+ border-right: 1px solid var(--border);
105
+ display: flex;
106
+ flex-direction: column;
107
+ overflow: hidden;
108
+ background: var(--surface);
109
+ }
110
+
111
+ .panel-header {
112
+ padding: 14px 18px 10px;
113
+ border-bottom: 1px solid var(--border);
114
+ font-family: var(--mono);
115
+ font-size: 10px;
116
+ text-transform: uppercase;
117
+ letter-spacing: 0.1em;
118
+ color: var(--text-dim);
119
+ display: flex;
120
+ align-items: center;
121
+ justify-content: space-between;
122
+ }
123
+
124
+ .panel-header span { color: var(--accent); }
125
+
126
+ textarea {
127
+ flex: 1;
128
+ resize: none;
129
+ background: transparent;
130
+ border: none;
131
+ outline: none;
132
+ color: var(--text);
133
+ font-family: var(--mono);
134
+ font-size: 11.5px;
135
+ line-height: 1.6;
136
+ padding: 14px 18px;
137
+ overflow-y: auto;
138
+ }
139
+
140
+ textarea::placeholder { color: var(--text-faint); }
141
+
142
+ textarea::-webkit-scrollbar { width: 4px; }
143
+ textarea::-webkit-scrollbar-track { background: transparent; }
144
+ textarea::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
145
+
146
+ /* OPTIONS ROW */
147
+ .options-row {
148
+ padding: 12px 18px;
149
+ border-top: 1px solid var(--border);
150
+ display: flex;
151
+ flex-direction: column;
152
+ gap: 10px;
153
+ }
154
+
155
+ .toggle-group {
156
+ display: flex;
157
+ gap: 8px;
158
+ flex-wrap: wrap;
159
+ }
160
+
161
+ .toggle {
162
+ display: flex;
163
+ align-items: center;
164
+ gap: 6px;
165
+ cursor: pointer;
166
+ user-select: none;
167
+ }
168
+
169
+ .toggle input { display: none; }
170
+
171
+ .toggle-pill {
172
+ width: 28px;
173
+ height: 15px;
174
+ background: var(--border);
175
+ border-radius: 8px;
176
+ position: relative;
177
+ transition: background 0.2s;
178
+ }
179
+
180
+ .toggle-pill::after {
181
+ content: '';
182
+ position: absolute;
183
+ width: 11px;
184
+ height: 11px;
185
+ background: var(--text-dim);
186
+ border-radius: 50%;
187
+ top: 2px;
188
+ left: 2px;
189
+ transition: left 0.2s, background 0.2s;
190
+ }
191
+
192
+ .toggle input:checked ~ .toggle-pill { background: var(--accent-dim); }
193
+ .toggle input:checked ~ .toggle-pill::after { left: 15px; background: var(--accent); }
194
+
195
+ .toggle-label {
196
+ font-size: 11px;
197
+ color: var(--text-dim);
198
+ font-family: var(--mono);
199
+ }
200
+
201
+ .file-row {
202
+ display: flex;
203
+ align-items: center;
204
+ gap: 8px;
205
+ }
206
+
207
+ .file-label {
208
+ font-size: 11px;
209
+ color: var(--text-dim);
210
+ font-family: var(--mono);
211
+ white-space: nowrap;
212
+ }
213
+
214
+ .file-input-wrap {
215
+ flex: 1;
216
+ position: relative;
217
+ overflow: hidden;
218
+ }
219
+
220
+ .file-input-wrap input[type="file"] {
221
+ position: absolute;
222
+ opacity: 0;
223
+ width: 100%;
224
+ height: 100%;
225
+ cursor: pointer;
226
+ }
227
+
228
+ .file-display {
229
+ font-family: var(--mono);
230
+ font-size: 10px;
231
+ color: var(--text-faint);
232
+ background: var(--bg);
233
+ border: 1px solid var(--border);
234
+ border-radius: 3px;
235
+ padding: 4px 8px;
236
+ white-space: nowrap;
237
+ overflow: hidden;
238
+ text-overflow: ellipsis;
239
+ cursor: pointer;
240
+ }
241
+
242
+ .file-display.has-file { color: var(--accent); border-color: var(--accent-dim); }
243
+
244
+ /* ACTION BUTTONS */
245
+ .action-row {
246
+ padding: 12px 18px;
247
+ border-top: 1px solid var(--border);
248
+ display: flex;
249
+ gap: 8px;
250
+ }
251
+
252
+ .btn {
253
+ font-family: var(--mono);
254
+ font-size: 11px;
255
+ font-weight: 600;
256
+ text-transform: uppercase;
257
+ letter-spacing: 0.08em;
258
+ border: none;
259
+ border-radius: 3px;
260
+ cursor: pointer;
261
+ padding: 8px 14px;
262
+ transition: all 0.15s;
263
+ }
264
+
265
+ .btn-primary {
266
+ background: var(--accent);
267
+ color: #0e0e0e;
268
+ flex: 1;
269
+ }
270
+
271
+ .btn-primary:hover { background: #d8ff7e; }
272
+ .btn-primary:active { transform: scale(0.98); }
273
+ .btn-primary:disabled { background: var(--border); color: var(--text-faint); cursor: not-allowed; }
274
+
275
+ .btn-ghost {
276
+ background: transparent;
277
+ color: var(--text-dim);
278
+ border: 1px solid var(--border);
279
+ }
280
+
281
+ .btn-ghost:hover { border-color: var(--text-dim); color: var(--text); }
282
+
283
+ .btn-danger {
284
+ background: transparent;
285
+ color: var(--red);
286
+ border: 1px solid #3a1a1a;
287
+ }
288
+
289
+ .btn-danger:hover { background: #1e0a0a; border-color: var(--red); }
290
+
291
+ /* RIGHT PANEL */
292
+ .right-panel {
293
+ display: flex;
294
+ flex-direction: column;
295
+ overflow: hidden;
296
+ background: var(--bg);
297
+ }
298
+
299
+ .right-toolbar {
300
+ padding: 10px 18px;
301
+ border-bottom: 1px solid var(--border);
302
+ display: flex;
303
+ align-items: center;
304
+ gap: 10px;
305
+ flex-shrink: 0;
306
+ }
307
+
308
+ .right-toolbar .panel-label {
309
+ font-family: var(--mono);
310
+ font-size: 10px;
311
+ text-transform: uppercase;
312
+ letter-spacing: 0.1em;
313
+ color: var(--text-dim);
314
+ }
315
+
316
+ .right-toolbar .count-badge {
317
+ font-family: var(--mono);
318
+ font-size: 10px;
319
+ color: var(--accent);
320
+ background: rgba(200,240,110,0.08);
321
+ border: 1px solid rgba(200,240,110,0.2);
322
+ border-radius: 10px;
323
+ padding: 2px 8px;
324
+ }
325
+
326
+ .toolbar-right {
327
+ margin-left: auto;
328
+ display: flex;
329
+ gap: 8px;
330
+ }
331
+
332
+ /* REFERENCES LIST */
333
+ .refs-list {
334
+ flex: 1;
335
+ overflow-y: auto;
336
+ padding: 0;
337
+ }
338
+
339
+ .refs-list::-webkit-scrollbar { width: 4px; }
340
+ .refs-list::-webkit-scrollbar-track { background: transparent; }
341
+ .refs-list::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
342
+
343
+ .ref-row {
344
+ display: grid;
345
+ grid-template-columns: 48px 100px 1fr 130px 140px;
346
+ gap: 0;
347
+ border-bottom: 1px solid var(--border);
348
+ align-items: center;
349
+ transition: background 0.1s;
350
+ cursor: pointer;
351
+ min-height: 38px;
352
+ }
353
+
354
+ .ref-row:hover { background: rgba(255,255,255,0.025); }
355
+ .ref-row.expanded { background: rgba(200,240,110,0.04); }
356
+
357
+ .ref-cell {
358
+ padding: 8px 12px;
359
+ overflow: hidden;
360
+ text-overflow: ellipsis;
361
+ white-space: nowrap;
362
+ font-size: 12px;
363
+ border-right: 1px solid var(--border);
364
+ }
365
+
366
+ .ref-cell:last-child { border-right: none; }
367
+
368
+ .cell-idx {
369
+ font-family: var(--mono);
370
+ font-size: 10px;
371
+ color: var(--text-faint);
372
+ text-align: center;
373
+ }
374
+
375
+ .cell-year {
376
+ font-family: var(--mono);
377
+ font-size: 12px;
378
+ color: var(--accent);
379
+ font-weight: 500;
380
+ }
381
+
382
+ .cell-title {
383
+ color: var(--text);
384
+ font-size: 12px;
385
+ }
386
+
387
+ .cell-journal {
388
+ color: var(--text-dim);
389
+ font-size: 11px;
390
+ font-style: italic;
391
+ }
392
+
393
+ .cell-key {
394
+ font-family: var(--mono);
395
+ font-size: 10px;
396
+ color: var(--text-faint);
397
+ }
398
+
399
+ /* COL HEADERS */
400
+ .refs-header {
401
+ display: grid;
402
+ grid-template-columns: 48px 100px 1fr 130px 140px;
403
+ border-bottom: 1px solid var(--border);
404
+ background: var(--surface);
405
+ position: sticky;
406
+ top: 0;
407
+ z-index: 10;
408
+ flex-shrink: 0;
409
+ }
410
+
411
+ .col-head {
412
+ padding: 6px 12px;
413
+ font-family: var(--mono);
414
+ font-size: 9px;
415
+ text-transform: uppercase;
416
+ letter-spacing: 0.1em;
417
+ color: var(--text-faint);
418
+ border-right: 1px solid var(--border);
419
+ }
420
+
421
+ .col-head:last-child { border-right: none; }
422
+
423
+ /* EXPANDED BIB ROW */
424
+ .bibtex-row {
425
+ display: none;
426
+ border-bottom: 1px solid var(--border);
427
+ background: rgba(200,240,110,0.03);
428
+ }
429
+
430
+ .bibtex-row.open { display: block; }
431
+
432
+ .bibtex-content {
433
+ padding: 10px 14px;
434
+ font-family: var(--mono);
435
+ font-size: 10.5px;
436
+ color: var(--text-dim);
437
+ white-space: pre-wrap;
438
+ line-height: 1.6;
439
+ border-left: 2px solid var(--accent-dim);
440
+ margin: 0 0 0 60px;
441
+ position: relative;
442
+ }
443
+
444
+ .copy-inline-btn {
445
+ position: absolute;
446
+ top: 8px;
447
+ right: 10px;
448
+ font-family: var(--mono);
449
+ font-size: 10px;
450
+ background: var(--border);
451
+ color: var(--text-dim);
452
+ border: none;
453
+ border-radius: 2px;
454
+ padding: 3px 8px;
455
+ cursor: pointer;
456
+ letter-spacing: 0.05em;
457
+ text-transform: uppercase;
458
+ transition: all 0.15s;
459
+ }
460
+
461
+ .copy-inline-btn:hover { background: var(--accent-dim); color: #0e0e0e; }
462
+ .copy-inline-btn.copied { background: var(--accent); color: #0e0e0e; }
463
+
464
+ /* EMPTY STATE */
465
+ .empty-state {
466
+ flex: 1;
467
+ display: flex;
468
+ flex-direction: column;
469
+ align-items: center;
470
+ justify-content: center;
471
+ color: var(--text-faint);
472
+ gap: 8px;
473
+ font-family: var(--mono);
474
+ font-size: 12px;
475
+ }
476
+
477
+ .empty-icon { font-size: 32px; opacity: 0.3; }
478
+
479
+ /* LOADING */
480
+ .loading-bar {
481
+ height: 2px;
482
+ background: var(--border);
483
+ display: none;
484
+ overflow: hidden;
485
+ flex-shrink: 0;
486
+ }
487
+
488
+ .loading-bar.active { display: block; }
489
+
490
+ .loading-fill {
491
+ height: 100%;
492
+ background: var(--accent);
493
+ animation: load-sweep 1.4s infinite ease-in-out;
494
+ }
495
+
496
+ @keyframes load-sweep {
497
+ 0% { width: 0; margin-left: 0; }
498
+ 50% { width: 60%; margin-left: 20%; }
499
+ 100% { width: 0; margin-left: 100%; }
500
+ }
501
+
502
+ /* TOAST */
503
+ .toast {
504
+ position: fixed;
505
+ bottom: 24px;
506
+ right: 24px;
507
+ background: var(--surface);
508
+ border: 1px solid var(--border);
509
+ border-radius: 4px;
510
+ padding: 10px 16px;
511
+ font-family: var(--mono);
512
+ font-size: 11px;
513
+ color: var(--text);
514
+ opacity: 0;
515
+ transform: translateY(8px);
516
+ transition: all 0.2s;
517
+ z-index: 100;
518
+ pointer-events: none;
519
+ }
520
+
521
+ .toast.show { opacity: 1; transform: translateY(0); }
522
+ .toast.success { border-color: var(--accent-dim); color: var(--accent); }
523
+ .toast.error { border-color: var(--red); color: var(--red); }
524
+
525
+ /* MODE TABS */
526
+ .mode-tabs {
527
+ display: flex;
528
+ gap: 0;
529
+ border-bottom: 1px solid var(--border);
530
+ }
531
+
532
+ .mode-tab {
533
+ font-family: var(--mono);
534
+ font-size: 10px;
535
+ text-transform: uppercase;
536
+ letter-spacing: 0.08em;
537
+ padding: 8px 14px;
538
+ cursor: pointer;
539
+ color: var(--text-dim);
540
+ border: none;
541
+ background: transparent;
542
+ border-bottom: 2px solid transparent;
543
+ margin-bottom: -1px;
544
+ transition: all 0.15s;
545
+ }
546
+
547
+ .mode-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
548
+ .mode-tab:hover:not(.active) { color: var(--text); }
549
+
550
+ .author-chip {
551
+ display: inline-block;
552
+ font-size: 11px;
553
+ color: var(--text-dim);
554
+ }
555
+ </style>
556
+ </head>
557
+ <body>
558
+ <div class="app">
559
+ <header>
560
+ <div class="logo">RefManager</div>
561
+ <div class="tagline">// bibtex pipeline</div>
562
+ <div class="stats">
563
+ <div class="stat">
564
+ <div class="stat-val" id="stat-total">0</div>
565
+ <div class="stat-label">refs</div>
566
+ </div>
567
+ <div class="stat">
568
+ <div class="stat-val" id="stat-enriched">—</div>
569
+ <div class="stat-label">enriched</div>
570
+ </div>
571
+ </div>
572
+ </header>
573
+
574
+ <div class="main">
575
+ <!-- LEFT: INPUT PANEL -->
576
+ <div class="left-panel">
577
+ <div class="mode-tabs">
578
+ <button class="mode-tab active" data-mode="bibtex">BibTeX</button>
579
+ <button class="mode-tab" data-mode="title">Titles</button>
580
+ </div>
581
+
582
+ <div class="panel-header">
583
+ Input <span id="char-count">0 chars</span>
584
+ </div>
585
+
586
+ <textarea id="bibtex-input" placeholder="@article{smith2024,
587
+ author = {Smith, John},
588
+ title = {Deep Learning for Energy Systems},
589
+ journal = {Applied Energy},
590
+ year = {2024},
591
+ doi = {10.1000/xyz}
592
+ }
593
+
594
+ Paste one or more BibTeX entries..."></textarea>
595
+
596
+ <div class="options-row">
597
+ <div class="toggle-group">
598
+ <label class="toggle">
599
+ <input type="checkbox" id="opt-enrich">
600
+ <div class="toggle-pill"></div>
601
+ <span class="toggle-label">Crossref</span>
602
+ </label>
603
+ <label class="toggle">
604
+ <input type="checkbox" id="opt-abbreviate">
605
+ <div class="toggle-pill"></div>
606
+ <span class="toggle-label">Abbreviate</span>
607
+ </label>
608
+ <label class="toggle">
609
+ <input type="checkbox" id="opt-protect">
610
+ <div class="toggle-pill"></div>
611
+ <span class="toggle-label">Protect caps</span>
612
+ </label>
613
+ <label class="toggle">
614
+ <input type="checkbox" id="opt-save">
615
+ <div class="toggle-pill"></div>
616
+ <span class="toggle-label">Save to DB</span>
617
+ </label>
618
+ </div>
619
+
620
+ <div class="file-row">
621
+ <span class="file-label">.tex file</span>
622
+ <div class="file-input-wrap">
623
+ <input type="file" id="latex-file" accept=".tex">
624
+ <div class="file-display" id="file-display">no file selected</div>
625
+ </div>
626
+ </div>
627
+ </div>
628
+
629
+ <div class="action-row">
630
+ <button class="btn btn-primary" id="process-btn" onclick="processRefs()">Process</button>
631
+ <button class="btn btn-ghost" onclick="clearInput()" title="Clear input">↺</button>
632
+ <button class="btn btn-danger" onclick="clearAll()" title="Clear all results">✕</button>
633
+ </div>
634
+ </div>
635
+
636
+ <!-- RIGHT: RESULTS PANEL -->
637
+ <div class="right-panel">
638
+ <div class="loading-bar" id="loading-bar">
639
+ <div class="loading-fill"></div>
640
+ </div>
641
+
642
+ <div class="right-toolbar">
643
+ <span class="panel-label">References</span>
644
+ <span class="count-badge" id="ref-count">0 entries</span>
645
+ <div class="toolbar-right">
646
+ <button class="btn btn-ghost" onclick="copyAll()" id="copy-all-btn" style="display:none">Copy all BibTeX</button>
647
+ <button class="btn btn-ghost" onclick="exportBib()" id="export-btn" style="display:none">Export .bib</button>
648
+ </div>
649
+ </div>
650
+
651
+ <div class="refs-header" id="refs-header" style="display:none">
652
+ <div class="col-head">#</div>
653
+ <div class="col-head">Year / Author</div>
654
+ <div class="col-head">Title</div>
655
+ <div class="col-head">Journal</div>
656
+ <div class="col-head">BibKey</div>
657
+ </div>
658
+
659
+ <div class="refs-list" id="refs-list">
660
+ <div class="empty-state" id="empty-state">
661
+ <div class="empty-icon">⌗</div>
662
+ <div>paste bibtex and press process</div>
663
+ </div>
664
+ </div>
665
+ </div>
666
+ </div>
667
+ </div>
668
+
669
+ <div class="toast" id="toast"></div>
670
+
671
+ <script>
672
+ let processedData = [];
673
+ let currentMode = 'bibtex';
674
+
675
+ // MODE TABS
676
+ document.querySelectorAll('.mode-tab').forEach(tab => {
677
+ tab.addEventListener('click', () => {
678
+ document.querySelectorAll('.mode-tab').forEach(t => t.classList.remove('active'));
679
+ tab.classList.add('active');
680
+ currentMode = tab.dataset.mode;
681
+ const ta = document.getElementById('bibtex-input');
682
+ if (currentMode === 'title') {
683
+ ta.placeholder = 'Deep Learning for Energy Systems\nNeural Networks for Battery Management\n...\n\nOne title per line';
684
+ } else {
685
+ ta.placeholder = '@article{smith2024,\n author = {Smith, John},\n title = {Deep Learning for Energy Systems},\n journal = {Applied Energy},\n year = {2024},\n doi = {10.1000/xyz}\n}\n\nPaste one or more BibTeX entries...';
686
+ }
687
+ });
688
+ });
689
+
690
+ // CHAR COUNT
691
+ document.getElementById('bibtex-input').addEventListener('input', function() {
692
+ const len = this.value.length;
693
+ document.getElementById('char-count').textContent = len.toLocaleString() + ' chars';
694
+ });
695
+
696
+ // FILE LABEL
697
+ document.getElementById('latex-file').addEventListener('change', function() {
698
+ const display = document.getElementById('file-display');
699
+ if (this.files.length > 0) {
700
+ display.textContent = this.files[0].name;
701
+ display.classList.add('has-file');
702
+ } else {
703
+ display.textContent = 'no file selected';
704
+ display.classList.remove('has-file');
705
+ }
706
+ });
707
+
708
+ function showToast(msg, type = 'success') {
709
+ const t = document.getElementById('toast');
710
+ t.textContent = msg;
711
+ t.className = 'toast ' + type + ' show';
712
+ setTimeout(() => t.classList.remove('show'), 2800);
713
+ }
714
+
715
+ function setLoading(v) {
716
+ document.getElementById('loading-bar').classList.toggle('active', v);
717
+ document.getElementById('process-btn').disabled = v;
718
+ document.getElementById('process-btn').textContent = v ? 'Processing…' : 'Process';
719
+ }
720
+
721
+ async function processRefs() {
722
+ const content = document.getElementById('bibtex-input').value.trim();
723
+ if (!content) { showToast('No input provided', 'error'); return; }
724
+
725
+ setLoading(true);
726
+
727
+ const formData = new FormData();
728
+ formData.append('bibtex_content', content);
729
+ formData.append('input_mode', currentMode);
730
+ formData.append('enrich', document.getElementById('opt-enrich').checked);
731
+ formData.append('abbreviate', document.getElementById('opt-abbreviate').checked);
732
+ formData.append('protect', document.getElementById('opt-protect').checked);
733
+ formData.append('save_to_db', document.getElementById('opt-save').checked);
734
+
735
+ const latexFile = document.getElementById('latex-file').files[0];
736
+ if (latexFile) formData.append('latex_file', latexFile);
737
+
738
+ try {
739
+ const res = await fetch('/api/process', { method: 'POST', body: formData });
740
+ const data = await res.json();
741
+
742
+ if (!res.ok || !data.success) {
743
+ showToast(data.error || 'Processing failed', 'error');
744
+ setLoading(false);
745
+ return;
746
  }
747
 
748
+ processedData = data.full_data || data.data || [];
749
+ renderRefs(processedData);
750
+ document.getElementById('stat-total').textContent = processedData.length;
751
+
752
+ const enriched = processedData.filter(r => r.Title_Similarity > 80).length;
753
+ document.getElementById('stat-enriched').textContent =
754
+ document.getElementById('opt-enrich').checked ? enriched : '—';
755
+
756
+ showToast(`Processed ${processedData.length} references`);
757
+ } catch (e) {
758
+ showToast('Network error: ' + e.message, 'error');
759
+ }
760
+ setLoading(false);
761
+ }
762
+
763
+ function getFirstAuthor(authors) {
764
+ if (!authors) return '—';
765
+ const parts = authors.split(/,|and/);
766
+ const first = parts[0].trim();
767
+ const nameParts = first.split(/\s+/);
768
+ return nameParts[nameParts.length - 1];
769
+ }
770
+
771
+ function getBestBib(row) {
772
+ return row.Crossref_BibTeX_Protected
773
+ || row.Crossref_BibTeX_Abbrev
774
+ || row.Crossref_BibTeX_LocalKey
775
+ || row.Crossref_BibTeX
776
+ || row.BibTeX
777
+ || '';
778
+ }
779
+
780
+ function renderRefs(refs) {
781
+ const list = document.getElementById('refs-list');
782
+ const header = document.getElementById('refs-header');
783
+ const empty = document.getElementById('empty-state');
784
+ const copyBtn = document.getElementById('copy-all-btn');
785
+ const exportBtn = document.getElementById('export-btn');
786
+ document.getElementById('ref-count').textContent = refs.length + ' entries';
787
+
788
+ if (!refs.length) {
789
+ list.innerHTML = '';
790
+ list.appendChild(empty);
791
+ header.style.display = 'none';
792
+ copyBtn.style.display = 'none';
793
+ exportBtn.style.display = 'none';
794
+ return;
795
+ }
796
+
797
+ header.style.display = 'grid';
798
+ copyBtn.style.display = 'inline-block';
799
+ exportBtn.style.display = 'inline-block';
800
+
801
+ list.innerHTML = refs.map((r, i) => {
802
+ const key = r.Key || r.Reference || '—';
803
+ const year = r.Year || '—';
804
+ const author = getFirstAuthor(r.Authors);
805
+ const title = r.Title || '—';
806
+ const journal = r['Journal/Booktitle'] || r.Journal_Booktitle || '—';
807
+ const bib = getBestBib(r);
808
+
809
+ return `
810
+ <div class="ref-row" onclick="toggleBib(${i})" id="row-${i}">
811
+ <div class="ref-cell cell-idx">${i + 1}</div>
812
+ <div class="ref-cell cell-year"><div>${year}</div><div class="author-chip">${author}</div></div>
813
+ <div class="ref-cell cell-title">${escHtml(title)}</div>
814
+ <div class="ref-cell cell-journal">${escHtml(journal)}</div>
815
+ <div class="ref-cell cell-key">${escHtml(key)}</div>
816
+ </div>
817
+ <div class="bibtex-row" id="bib-${i}">
818
+ <div class="bibtex-content" id="bib-content-${i}">${escHtml(bib)}<button class="copy-inline-btn" id="copy-btn-${i}" onclick="copyBib(event,${i})">copy</button></div>
819
+ </div>`;
820
+ }).join('');
821
+ }
822
+
823
+ function toggleBib(i) {
824
+ const row = document.getElementById(`row-${i}`);
825
+ const bib = document.getElementById(`bib-${i}`);
826
+ const isOpen = bib.classList.contains('open');
827
+ // close all others
828
+ document.querySelectorAll('.bibtex-row.open').forEach(el => {
829
+ el.classList.remove('open');
830
+ const idx = el.id.replace('bib-', '');
831
+ document.getElementById(`row-${idx}`).classList.remove('expanded');
832
+ });
833
+ if (!isOpen) {
834
+ bib.classList.add('open');
835
+ row.classList.add('expanded');
836
+ }
837
+ }
838
+
839
+ function copyBib(e, i) {
840
+ e.stopPropagation();
841
+ const bib = getBestBib(processedData[i]);
842
+ navigator.clipboard.writeText(bib).then(() => {
843
+ const btn = document.getElementById(`copy-btn-${i}`);
844
+ btn.textContent = 'copied!';
845
+ btn.classList.add('copied');
846
+ setTimeout(() => { btn.textContent = 'copy'; btn.classList.remove('copied'); }, 1500);
847
+ });
848
+ }
849
+
850
+ function copyAll() {
851
+ const all = processedData.map(r => getBestBib(r)).filter(Boolean).join('\n\n');
852
+ navigator.clipboard.writeText(all).then(() => showToast('Copied ' + processedData.length + ' entries'));
853
+ }
854
+
855
+ function exportBib() {
856
+ const all = processedData.map(r => getBestBib(r)).filter(Boolean).join('\n\n');
857
+ const blob = new Blob([all], { type: 'text/plain' });
858
+ const a = document.createElement('a');
859
+ a.href = URL.createObjectURL(blob);
860
+ a.download = 'references.bib';
861
+ a.click();
862
+ showToast('Exported references.bib');
863
+ }
864
+
865
+ function clearInput() {
866
+ document.getElementById('bibtex-input').value = '';
867
+ document.getElementById('char-count').textContent = '0 chars';
868
+ }
869
+
870
+ function clearAll() {
871
+ processedData = [];
872
+ renderRefs([]);
873
+ document.getElementById('stat-total').textContent = '0';
874
+ document.getElementById('stat-enriched').textContent = '—';
875
+ const empty = document.getElementById('empty-state');
876
+ document.getElementById('refs-list').appendChild(empty);
877
+ showToast('Cleared');
878
+ }
879
+
880
+ function escHtml(s) {
881
+ if (!s) return '';
882
+ return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
883
+ }
884
+ </script>
885
+ </body>
886
+ </html>