mirror of
https://github.com/OFFIS-ESC/constellation-analyzer
synced 2026-01-26 23:43:40 +00:00
feat: add comprehensive bibliography and citation system
Implements complete bibliography management with citation assignment to nodes and edges, following CSL-JSON standard. Features: - Bibliography store with Zustand and citation.js integration - Smart import supporting DOI, BibTeX, PubMed ID, and URLs - Manual entry and editing forms for references - Citation selector with autocomplete text field interface - History tracking for undo/redo support - Workspace integration for import/export - Support for multiple reference types including interview and other - Description/notes field for additional reference information Components: - CitationSelector: autocomplete UI for selecting citations - BibliographyConfigModal: main bibliography management interface - QuickAddReferenceForm: smart import and manual entry - EditReferenceInline: full reference editing form - ReferenceManagementList: list view with citation counts Integration: - NodeEditorPanel: citation assignment to actors - EdgeEditorPanel: citation assignment to relations - MenuBar: bibliography menu item - WorkspaceStore: bibliography persistence in workspace files Technical details: - CSL-JSON format for bibliographic data - citation.js for parsing and formatting - TypeScript with proper type definitions - Debounced updates for performance - Citation count tracking across graph elements 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
c1cd2d3114
commit
36f44d61ac
21 changed files with 4585 additions and 2 deletions
1635
TECHNICAL_SPEC_BIBLIOGRAPHY.md
Normal file
1635
TECHNICAL_SPEC_BIBLIOGRAPHY.md
Normal file
File diff suppressed because it is too large
Load diff
638
UX_BIBLIOGRAPHY_DESIGN.md
Normal file
638
UX_BIBLIOGRAPHY_DESIGN.md
Normal file
|
|
@ -0,0 +1,638 @@
|
|||
# Bibliography System - UX Design Concept
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This document outlines the UX design for integrating a citation/reference management system into Constellation Analyzer. The design prioritizes ease of use for social scientists who may not be technically versed, while maintaining flexibility for power users.
|
||||
|
||||
---
|
||||
|
||||
## 1. User Research & Requirements
|
||||
|
||||
### Target Users
|
||||
- **Primary**: Social scientists conducting constellation analyses
|
||||
- **Technical level**: Low to medium (not LaTeX/BibTeX users)
|
||||
- **Use case**: Track sources for actors and relationships in their analysis
|
||||
- **Workflow**: Need quick access to add/edit/cite references without interrupting graph work
|
||||
|
||||
### Key Requirements
|
||||
- Simple, form-based reference entry (not code-based like BibTeX)
|
||||
- Support common social science citation types (books, articles, websites, reports)
|
||||
- Quick citation from node/edge properties
|
||||
- Export capability for academic writing
|
||||
- Integration with existing document structure
|
||||
|
||||
---
|
||||
|
||||
## 2. Data Format Decision: CSL-JSON
|
||||
|
||||
### Why CSL-JSON over BibTeX?
|
||||
|
||||
**CSL-JSON Advantages:**
|
||||
- ✅ Human-readable JSON format
|
||||
- ✅ Industry standard (Zotero, Mendeley, Papers)
|
||||
- ✅ 10,000+ pre-built citation styles available
|
||||
- ✅ Better support for non-English sources
|
||||
- ✅ UTF-8 native support
|
||||
- ✅ Easier to parse and manipulate programmatically
|
||||
- ✅ Can be converted to/from BibTeX for power users
|
||||
|
||||
**BibTeX Limitations:**
|
||||
- ❌ Complex syntax (`@article{key, field={value}}`)
|
||||
- ❌ Requires understanding of LaTeX conventions
|
||||
- ❌ Less intuitive for non-technical users
|
||||
- ❌ ASCII-focused (problematic for international names)
|
||||
|
||||
### CSL-JSON Structure (Simplified)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "unique-id",
|
||||
"type": "article-journal",
|
||||
"title": "Understanding Social Networks",
|
||||
"author": [
|
||||
{"family": "Smith", "given": "Jane"},
|
||||
{"family": "Doe", "given": "John"}
|
||||
],
|
||||
"issued": {"date-parts": [[2023]]},
|
||||
"container-title": "Journal of Social Sciences",
|
||||
"volume": "45",
|
||||
"issue": "3",
|
||||
"page": "123-145",
|
||||
"DOI": "10.1000/example",
|
||||
"URL": "https://example.com/article"
|
||||
}
|
||||
```
|
||||
|
||||
### Supported Reference Types
|
||||
1. **article-journal** - Journal articles
|
||||
2. **book** - Books
|
||||
3. **chapter** - Book chapters
|
||||
4. **paper-conference** - Conference papers
|
||||
5. **report** - Technical reports, white papers
|
||||
6. **thesis** - Theses and dissertations
|
||||
7. **webpage** - Web pages and online sources
|
||||
8. **interview** - Interviews (common in social sciences)
|
||||
9. **manuscript** - Unpublished manuscripts
|
||||
10. **personal_communication** - Personal communications
|
||||
|
||||
---
|
||||
|
||||
## 3. UI Component Design
|
||||
|
||||
### 3.1 Access Points
|
||||
|
||||
**Primary Access: Menu Bar**
|
||||
```
|
||||
Edit Menu:
|
||||
├── Configure Actor Types
|
||||
├── Configure Relation Types
|
||||
├── Configure Labels
|
||||
├── [NEW] Manage Bibliography (Ctrl+B)
|
||||
```
|
||||
|
||||
**Secondary Access: Property Panels**
|
||||
- Add "Citations" field to Node Editor Panel
|
||||
- Add "Citations" field to Edge Editor Panel
|
||||
|
||||
**Tertiary Access: Export Menu**
|
||||
```
|
||||
File → Export → Bibliography (CSL-JSON)
|
||||
File → Export → Bibliography (BibTeX) [for power users]
|
||||
File → Export → Formatted Bibliography (HTML/PDF)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.2 Bibliography Management Modal
|
||||
|
||||
**Layout: Two-Column Design** (consistent with existing Label/Type config modals)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ Manage Bibliography [X] │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────────────────────┬──────────────────────────────────┐ │
|
||||
│ │ Quick Add Reference │ Your References (15) │ │
|
||||
│ │ (60% width) │ (40% width) │ │
|
||||
│ │ │ │ │
|
||||
│ │ [Reference Type ▼] │ ┌─────────────────────────────┐│ │
|
||||
│ │ ○ Journal Article │ │ Smith & Doe (2023) ││ │
|
||||
│ │ ○ Book │ │ Understanding Social... ││ │
|
||||
│ │ ○ Website │ │ Journal of Social Sciences ││ │
|
||||
│ │ ○ Other... │ │ [Edit] [Delete] [Cite] ││ │
|
||||
│ │ │ └─────────────────────────────┘│ │
|
||||
│ │ Smart Input: │ │ │
|
||||
│ │ ┌─────────────────────┐ │ ┌─────────────────────────────┐│ │
|
||||
│ │ │ Paste DOI, URL, or │ │ │ Johnson (2022) ││ │
|
||||
│ │ │ formatted citation │ │ │ Network Theory in... ││ │
|
||||
│ │ └─────────────────────┘ │ │ Academic Press ││ │
|
||||
│ │ [Auto-Fill] [Clear] │ │ [Edit] [Delete] [Cite] ││ │
|
||||
│ │ │ └─────────────────────────────┘│ │
|
||||
│ │ -- OR Manual Entry -- │ │ │
|
||||
│ │ │ [Search references...] │ │
|
||||
│ │ Title * │ Filter: [All Types ▼] │ │
|
||||
│ │ ┌─────────────────────┐ │ │ │
|
||||
│ │ │ │ │ Sort by: [Recent ▼] │ │
|
||||
│ │ └─────────────────────┘ │ │ │
|
||||
│ │ │ │ │
|
||||
│ │ Authors (one per line) │ │ │
|
||||
│ │ ┌─────────────────────┐ │ │ │
|
||||
│ │ │ Jane Smith │ │ │ │
|
||||
│ │ │ John Doe │ │ │ │
|
||||
│ │ └─────────────────────┘ │ │ │
|
||||
│ │ │ │ │
|
||||
│ │ Year * │ │ │
|
||||
│ │ ┌────┐ │ │ │
|
||||
│ │ │2023│ │ │ │
|
||||
│ │ └────┘ │ │ │
|
||||
│ │ │ │ │
|
||||
│ │ [+ Show More Fields] │ │ │
|
||||
│ │ │ │ │
|
||||
│ │ [Add Reference] │ │ │
|
||||
│ └──────────────────────────┴──────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 💡 Tip: Paste a DOI or URL for automatic citation import │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [Import File] [Close] │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Edit Mode: Full-Width** (when editing existing reference)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ Edit Reference [X] │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ ← Back to List │
|
||||
│ │
|
||||
│ Reference Type: [Journal Article ▼] │
|
||||
│ │
|
||||
│ Title * │
|
||||
│ ┌──────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Understanding Social Networks in Constellation Analysis │ │
|
||||
│ └──────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Authors (one per line or separated by semicolons) * │
|
||||
│ ┌──────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Jane Smith; John Doe │ │
|
||||
│ └──────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Year * [2023] Volume [45] Issue [3] Pages [123-145] │
|
||||
│ │
|
||||
│ Journal/Container Title │
|
||||
│ ┌──────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Journal of Social Sciences │ │
|
||||
│ └──────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ DOI (Optional) URL (Optional) │
|
||||
│ ┌───────────────────────────┐ ┌───────────────────────────────┐ │
|
||||
│ │ 10.1000/example │ │ https://example.com/article │ │
|
||||
│ └───────────────────────────┘ └───────────────────────────────┘ │
|
||||
│ │
|
||||
│ Abstract/Notes (Optional) │
|
||||
│ ┌──────────────────────────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ └──────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Tags (comma separated) │
|
||||
│ ┌──────────────────────────────────────────────────────────────┐ │
|
||||
│ │ social networks, methodology, qualitative │ │
|
||||
│ └──────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Preview: │ │
|
||||
│ │ Smith, J., & Doe, J. (2023). Understanding Social Networks │ │
|
||||
│ │ in Constellation Analysis. Journal of Social Sciences, │ │
|
||||
│ │ 45(3), 123-145. https://doi.org/10.1000/example │ │
|
||||
│ └────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Citation Format: [APA 7th ▼] │
|
||||
│ │
|
||||
│ [Cancel] [Save Changes] │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.3 Citation Field in Property Panels
|
||||
|
||||
**Node Editor Panel - Add "Citations" Section** (after Labels, before Connections)
|
||||
|
||||
```
|
||||
┌───────────────────────────────┐
|
||||
│ Actor Properties │
|
||||
├───────────────────────────────┤
|
||||
│ Actor Type: [Researcher ▼] │
|
||||
│ Label: Jane Smith │
|
||||
│ Description: ... │
|
||||
│ Labels: [methodology] │
|
||||
│ │
|
||||
│ Citations (2) │
|
||||
│ ┌───────────────────────────┐ │
|
||||
│ │ • Smith & Doe (2023) │ │
|
||||
│ │ • Johnson (2022) │ │
|
||||
│ │ [+ Add Citation] │ │
|
||||
│ └───────────────────────────┘ │
|
||||
│ │
|
||||
│ Connections (3) │
|
||||
│ ... │
|
||||
└───────────────────────────────┘
|
||||
```
|
||||
|
||||
**Citation Selection Dropdown**
|
||||
|
||||
When user clicks [+ Add Citation]:
|
||||
|
||||
```
|
||||
┌───────────────────────────────────┐
|
||||
│ Add Citation │
|
||||
├───────────────────────────────────┤
|
||||
│ Search references... │
|
||||
│ ┌───────────────────────────────┐ │
|
||||
│ │ 🔍 smith │ │
|
||||
│ └───────────────────────────────┘ │
|
||||
│ │
|
||||
│ Results (2): │
|
||||
│ ┌───────────────────────────────┐ │
|
||||
│ │ ☑ Smith & Doe (2023) │ │
|
||||
│ │ Understanding Social... │ │
|
||||
│ │ J. Social Sciences │ │
|
||||
│ ├───────────────────────────────┤ │
|
||||
│ │ ☐ Smithson (2021) │ │
|
||||
│ │ Network Analysis Methods │ │
|
||||
│ │ Academic Press │ │
|
||||
│ └───────────────────────────────┘ │
|
||||
│ │
|
||||
│ [+ Create New Reference] │
|
||||
│ │
|
||||
│ [Cancel] [Add (1)] │
|
||||
└───────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- Multi-select checkbox list
|
||||
- Real-time search filtering
|
||||
- Shows abbreviated citation info
|
||||
- Quick create if reference doesn't exist
|
||||
- Selected references show checkmarks
|
||||
|
||||
---
|
||||
|
||||
### 3.4 Reference Card Design (in Management List)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Smith & Doe (2023) [📋][✏️][🗑️] │
|
||||
│ Understanding Social Networks in... │
|
||||
│ Journal of Social Sciences │
|
||||
│ ─────────────────────────────────────────── │
|
||||
│ 🏷️ social networks, methodology │
|
||||
│ 📊 Cited by: 3 actors, 2 relations │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Card Components:**
|
||||
1. **Header**: Short citation (Author-Date)
|
||||
2. **Title**: Truncated with ellipsis
|
||||
3. **Container**: Journal/Book/Website name
|
||||
4. **Tags**: Visual tag chips
|
||||
5. **Usage**: Count of actors/relations citing this
|
||||
6. **Actions**: Copy citation, Edit, Delete
|
||||
|
||||
---
|
||||
|
||||
## 4. User Workflows
|
||||
|
||||
### 4.1 Workflow: Quick Add Reference (Smart Import)
|
||||
|
||||
**Scenario**: User has a DOI or formatted citation
|
||||
|
||||
1. Open "Manage Bibliography" (Ctrl+B or Edit menu)
|
||||
2. Paste DOI/URL in "Smart Input" field
|
||||
3. Click "Auto-Fill"
|
||||
4. System fetches metadata and populates fields
|
||||
5. User reviews/adjusts if needed
|
||||
6. Click "Add Reference"
|
||||
7. Toast: "Reference added successfully"
|
||||
|
||||
**Fallback**: If auto-fill fails, form remains with manual entry option
|
||||
|
||||
---
|
||||
|
||||
### 4.2 Workflow: Manual Reference Entry
|
||||
|
||||
**Scenario**: User has a book without DOI
|
||||
|
||||
1. Open "Manage Bibliography"
|
||||
2. Select reference type: "Book"
|
||||
3. Form adjusts to show relevant fields:
|
||||
- Title, Authors, Year (required)
|
||||
- Publisher, Place, ISBN, Pages (optional)
|
||||
4. Fill in required fields
|
||||
5. Click "Add Reference"
|
||||
6. Reference appears in list on right
|
||||
|
||||
---
|
||||
|
||||
### 4.3 Workflow: Cite Reference in Node
|
||||
|
||||
**Scenario**: User wants to cite sources for an actor
|
||||
|
||||
1. Select node in graph
|
||||
2. Right panel shows Node Editor
|
||||
3. Scroll to "Citations" section
|
||||
4. Click [+ Add Citation]
|
||||
5. Search dropdown appears
|
||||
6. Type to filter or browse list
|
||||
7. Check box(es) for desired reference(s)
|
||||
8. Click "Add"
|
||||
9. Citations appear as bullet list with author-date
|
||||
10. Change auto-saves after 500ms debounce
|
||||
|
||||
---
|
||||
|
||||
### 4.4 Workflow: Edit Existing Reference
|
||||
|
||||
**Scenario**: User needs to correct publication year
|
||||
|
||||
1. Open "Manage Bibliography"
|
||||
2. Find reference in right column list
|
||||
3. Click [Edit] button
|
||||
4. Modal switches to full-width edit mode
|
||||
5. Modify year field
|
||||
6. Preview updates in real-time
|
||||
7. Click "Save Changes"
|
||||
8. Returns to two-column view
|
||||
9. Toast: "Reference updated"
|
||||
|
||||
**Note**: All actors/relations citing this reference show updated info
|
||||
|
||||
---
|
||||
|
||||
### 4.5 Workflow: Delete Reference with Usage
|
||||
|
||||
**Scenario**: User deletes reference cited by 5 items
|
||||
|
||||
1. Click [Delete] on reference card
|
||||
2. Confirmation dialog appears:
|
||||
```
|
||||
Delete Reference?
|
||||
|
||||
This reference is cited by:
|
||||
• 3 actors
|
||||
• 2 relations
|
||||
|
||||
Deleting will remove all citations to this reference.
|
||||
|
||||
[Cancel] [Delete Reference]
|
||||
```
|
||||
3. User confirms
|
||||
4. Reference deleted from bibliography
|
||||
5. All citation links removed from actors/relations
|
||||
6. Toast: "Reference deleted"
|
||||
|
||||
---
|
||||
|
||||
### 4.6 Workflow: Export Bibliography
|
||||
|
||||
**Scenario**: User wants formatted bibliography for paper
|
||||
|
||||
1. File → Export → Formatted Bibliography
|
||||
2. Dialog appears:
|
||||
```
|
||||
Export Bibliography
|
||||
|
||||
Format: [APA 7th Edition ▼]
|
||||
|
||||
Options:
|
||||
☑ Include only cited references
|
||||
☐ Include all references
|
||||
☑ Sort alphabetically by author
|
||||
|
||||
Output:
|
||||
○ HTML (for web/Word)
|
||||
○ PDF
|
||||
○ Plain text
|
||||
|
||||
[Cancel] [Export]
|
||||
```
|
||||
3. User selects options
|
||||
4. Click "Export"
|
||||
5. File save dialog
|
||||
6. Toast: "Bibliography exported successfully"
|
||||
|
||||
---
|
||||
|
||||
## 5. Design Principles
|
||||
|
||||
### 5.1 Progressive Disclosure
|
||||
- **Basic mode**: Show only essential fields (Title, Author, Year)
|
||||
- **Advanced mode**: "Show More Fields" reveals DOI, ISBN, Volume, etc.
|
||||
- **Smart defaults**: Type selection pre-fills appropriate fields
|
||||
|
||||
### 5.2 Forgiveness & Flexibility
|
||||
- **Auto-save**: No manual save needed (consistent with current app)
|
||||
- **Undo support**: Bibliography changes tracked in history
|
||||
- **Validation**: Soft validation (warnings, not errors)
|
||||
- **Import/Export**: Allow bulk operations for power users
|
||||
|
||||
### 5.3 Consistency with Existing Patterns
|
||||
- **Modal layout**: Two-column → Full-width (like Labels/Types)
|
||||
- **Toast notifications**: All actions get feedback
|
||||
- **Confirmation dialogs**: Destructive operations require confirmation
|
||||
- **Debounced updates**: 500ms delay for citation assignments
|
||||
- **Color coding**: None needed (unlike types), but tags use label-like chips
|
||||
|
||||
### 5.4 Social Science Focus
|
||||
- **Citation preview**: Always show formatted citation (APA/Chicago/MLA)
|
||||
- **Interview type**: Support non-traditional sources
|
||||
- **Qualitative notes**: Abstract field for researcher notes
|
||||
- **Tagging**: Organize by themes, not just bibliographic categories
|
||||
|
||||
---
|
||||
|
||||
## 6. Technical Considerations
|
||||
|
||||
### 6.1 Data Storage
|
||||
|
||||
**Document Structure** (extend ConstellationDocument):
|
||||
```typescript
|
||||
export interface ConstellationDocument {
|
||||
// ... existing fields
|
||||
bibliography?: {
|
||||
references: BibliographyReference[];
|
||||
settings?: {
|
||||
defaultStyle: string; // e.g., "apa-7th"
|
||||
sortOrder: 'author' | 'year' | 'title';
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Node/Edge Structure** (extend ActorData/RelationData):
|
||||
```typescript
|
||||
export interface ActorData {
|
||||
// ... existing fields
|
||||
citations?: string[]; // Array of reference IDs
|
||||
}
|
||||
|
||||
export interface RelationData {
|
||||
// ... existing fields
|
||||
citations?: string[]; // Array of reference IDs
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 Import/Export
|
||||
|
||||
**Supported Formats:**
|
||||
1. **CSL-JSON** (primary)
|
||||
- Native format
|
||||
- Import from Zotero, Mendeley
|
||||
- Export for use in other tools
|
||||
|
||||
2. **BibTeX** (secondary, for power users)
|
||||
- Convert CSL-JSON ↔ BibTeX
|
||||
- Use existing libraries (citation-js, bibtex-parser)
|
||||
|
||||
3. **RIS** (tertiary, for compatibility)
|
||||
- Common in older reference managers
|
||||
- Convert via citation-js
|
||||
|
||||
4. **Formatted HTML/PDF**
|
||||
- For inclusion in papers/reports
|
||||
- Use CSL processor (citeproc-js)
|
||||
|
||||
### 6.3 Auto-Fill Service
|
||||
|
||||
**DOI Lookup**:
|
||||
- Use CrossRef API (free, no auth required)
|
||||
- Endpoint: `https://api.crossref.org/works/{doi}`
|
||||
- Returns JSON metadata
|
||||
|
||||
**URL Metadata**:
|
||||
- Use Open Graph tags or Dublin Core metadata
|
||||
- Fallback to page title/author extraction
|
||||
- Not as reliable as DOI
|
||||
|
||||
**Fallback**:
|
||||
- If lookup fails, show error toast
|
||||
- Keep form populated with what was entered
|
||||
- Allow manual completion
|
||||
|
||||
### 6.4 Citation Style Library
|
||||
|
||||
**Approach**: Use Citation Style Language (CSL)
|
||||
|
||||
**Libraries**:
|
||||
- `citeproc-js`: CSL processor for formatting
|
||||
- `citation-js`: Parse/convert between formats
|
||||
- Preload common styles: APA 7th, Chicago, MLA, Harvard
|
||||
|
||||
**Style Switching**:
|
||||
- User can change preview style in modal
|
||||
- Setting saved per document
|
||||
- All citations reformat instantly
|
||||
|
||||
---
|
||||
|
||||
## 7. Accessibility & Usability
|
||||
|
||||
### 7.1 Keyboard Navigation
|
||||
- `Ctrl+B`: Open bibliography modal
|
||||
- `Tab`: Navigate between fields
|
||||
- `Enter`: Submit forms (add/save)
|
||||
- `Esc`: Close modal/cancel
|
||||
- `/` in search: Focus search input
|
||||
|
||||
### 7.2 Screen Reader Support
|
||||
- Form labels with `aria-label`
|
||||
- Button descriptions
|
||||
- Status announcements for actions
|
||||
- Semantic HTML (fieldset, legend, label)
|
||||
|
||||
### 7.3 Visual Design
|
||||
- **High contrast**: Readable citation text
|
||||
- **Clear hierarchy**: Title > Authors > Container
|
||||
- **Action buttons**: Icon + text for clarity
|
||||
- **Tooltips**: Help text for advanced fields
|
||||
|
||||
---
|
||||
|
||||
## 8. Future Enhancements (Not MVP)
|
||||
|
||||
### Phase 2
|
||||
- **Duplicate detection**: Warn if similar reference exists
|
||||
- **Citation count**: Show most/least cited references
|
||||
- **Bulk import**: CSV, BibTeX file upload
|
||||
- **Collaboration**: Shared bibliography across workspace
|
||||
- **Citation network**: Graph of which references cite each other
|
||||
|
||||
### Phase 3
|
||||
- **PDF import**: Drag PDF, extract citation
|
||||
- **Web clipper**: Browser extension for one-click import
|
||||
- **Smart suggestions**: Recommend related references
|
||||
- **Citation graph**: Visualize citation relationships as constellation
|
||||
- **Integration**: Export to LaTeX/Word with in-text citations
|
||||
|
||||
---
|
||||
|
||||
## 9. Success Metrics
|
||||
|
||||
### User Experience
|
||||
- ✅ Non-technical users can add reference in <2 minutes
|
||||
- ✅ 90% of DOI lookups succeed automatically
|
||||
- ✅ Zero training needed (intuitive interface)
|
||||
- ✅ No "Save" button confusion (auto-save)
|
||||
|
||||
### Technical
|
||||
- ✅ Bibliography data persists with document
|
||||
- ✅ Import/export works with Zotero/Mendeley
|
||||
- ✅ No performance impact on graph rendering
|
||||
- ✅ Undo/redo works for bibliography changes
|
||||
|
||||
### Adoption
|
||||
- ✅ Users cite at least 1 reference per document
|
||||
- ✅ Bibliography modal opened in >50% of sessions
|
||||
- ✅ Export feature used by academic users
|
||||
|
||||
---
|
||||
|
||||
## 10. Implementation Phases
|
||||
|
||||
### Phase 1: Core Functionality (MVP)
|
||||
1. Bibliography modal (two-column layout)
|
||||
2. CRUD operations (Create, Read, Update, Delete)
|
||||
3. Basic CSL-JSON storage
|
||||
4. Citation field in node/edge properties
|
||||
5. Simple formatted preview (APA only)
|
||||
6. Export as CSL-JSON
|
||||
|
||||
### Phase 2: Smart Features
|
||||
1. DOI auto-fill
|
||||
2. Multiple citation styles (APA, Chicago, MLA)
|
||||
3. Import from BibTeX
|
||||
4. Export formatted bibliography (HTML)
|
||||
5. Tag filtering and search
|
||||
6. Usage tracking (citation counts)
|
||||
|
||||
### Phase 3: Power User Features
|
||||
1. Bulk operations
|
||||
2. Advanced search/filter
|
||||
3. Custom citation styles
|
||||
4. PDF import
|
||||
5. Duplicate detection
|
||||
6. Citation graph visualization
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
This design balances simplicity for non-technical social scientists with power features for advanced users. By using CSL-JSON as the underlying format, we ensure compatibility with popular reference managers while keeping the UI approachable. The two-column modal pattern maintains consistency with existing app design, reducing the learning curve.
|
||||
|
||||
**Key Innovation**: Smart input field that accepts DOI/URL and auto-fills, dramatically reducing manual entry burden while still allowing full manual control.
|
||||
|
||||
**Next Steps**: Create technical specification and begin implementation with Phase 1 (MVP).
|
||||
224
package-lock.json
generated
224
package-lock.json
generated
|
|
@ -8,6 +8,11 @@
|
|||
"name": "constellation-analyzer",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@citation-js/core": "^0.7.18",
|
||||
"@citation-js/plugin-bibtex": "^0.7.18",
|
||||
"@citation-js/plugin-csl": "^0.7.18",
|
||||
"@citation-js/plugin-doi": "^0.7.18",
|
||||
"@citation-js/plugin-ris": "^0.7.18",
|
||||
"@emotion/react": "^11.11.3",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@mui/icons-material": "^5.15.10",
|
||||
|
|
@ -326,6 +331,96 @@
|
|||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@citation-js/core": {
|
||||
"version": "0.7.18",
|
||||
"resolved": "https://registry.npmjs.org/@citation-js/core/-/core-0.7.18.tgz",
|
||||
"integrity": "sha512-EjLuZWA5156dIFGdF7OnyPyWFBW43B8Ckje6Sn/W2RFxHDu0oACvW4/6TNgWT80jhEA4bVFm7ahrZe9MJ2B2UQ==",
|
||||
"dependencies": {
|
||||
"@citation-js/date": "^0.5.0",
|
||||
"@citation-js/name": "^0.4.2",
|
||||
"fetch-ponyfill": "^7.1.0",
|
||||
"sync-fetch": "^0.4.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@citation-js/date": {
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@citation-js/date/-/date-0.5.1.tgz",
|
||||
"integrity": "sha512-1iDKAZ4ie48PVhovsOXQ+C6o55dWJloXqtznnnKy6CltJBQLIuLLuUqa8zlIvma0ZigjVjgDUhnVaNU1MErtZw==",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@citation-js/name": {
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@citation-js/name/-/name-0.4.2.tgz",
|
||||
"integrity": "sha512-brSPsjs2fOVzSnARLKu0qncn6suWjHVQtrqSUrnqyaRH95r/Ad4wPF5EsoWr+Dx8HzkCGb/ogmoAzfCsqlTwTQ==",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@citation-js/plugin-bibtex": {
|
||||
"version": "0.7.18",
|
||||
"resolved": "https://registry.npmjs.org/@citation-js/plugin-bibtex/-/plugin-bibtex-0.7.18.tgz",
|
||||
"integrity": "sha512-TdsZSMpgpfcx2NMPu0KiulEoecllwT5EtRUzAJl2pDsdPD1tUqqbyj/NBi0l8fwNy1r7WwAqSFGiqGPjQWpFdg==",
|
||||
"dependencies": {
|
||||
"@citation-js/date": "^0.5.0",
|
||||
"@citation-js/name": "^0.4.2",
|
||||
"moo": "^0.5.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@citation-js/core": "^0.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@citation-js/plugin-csl": {
|
||||
"version": "0.7.18",
|
||||
"resolved": "https://registry.npmjs.org/@citation-js/plugin-csl/-/plugin-csl-0.7.18.tgz",
|
||||
"integrity": "sha512-cJcOdEZurmtIxNj0d4cOERHpVQJB/mN3YPSDNqfI/xTFRN3bWDpFAsaqubPtMO2ZPpoDS+ZGIP1kggbwCfMmlA==",
|
||||
"dependencies": {
|
||||
"@citation-js/date": "^0.5.0",
|
||||
"citeproc": "^2.4.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@citation-js/core": "^0.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@citation-js/plugin-doi": {
|
||||
"version": "0.7.18",
|
||||
"resolved": "https://registry.npmjs.org/@citation-js/plugin-doi/-/plugin-doi-0.7.18.tgz",
|
||||
"integrity": "sha512-7ccmhfJJSDUhUqpWxesLAp3m1P5dhnZ4QNMctwJnU41T9vKGF7MXPKqMONSvL5JDZ7o7iWQTj2BFhSmh0euQxw==",
|
||||
"dependencies": {
|
||||
"@citation-js/date": "^0.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@citation-js/core": "^0.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@citation-js/plugin-ris": {
|
||||
"version": "0.7.18",
|
||||
"resolved": "https://registry.npmjs.org/@citation-js/plugin-ris/-/plugin-ris-0.7.18.tgz",
|
||||
"integrity": "sha512-aHd8isGGxz6P63UjyTR4RfX1alqxu8PQLWeoUBcnTMAPmyn6qzJfdB+iZEFA8HyL4g3I33n431zL/0BV8XlJiw==",
|
||||
"dependencies": {
|
||||
"@citation-js/date": "^0.5.0",
|
||||
"@citation-js/name": "^0.4.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@citation-js/core": "^0.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emotion/babel-plugin": {
|
||||
"version": "11.13.5",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz",
|
||||
|
|
@ -2410,6 +2505,25 @@
|
|||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
]
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.8.14",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.14.tgz",
|
||||
|
|
@ -2485,6 +2599,29 @@
|
|||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer": {
|
||||
"version": "5.7.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
|
||||
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.1",
|
||||
"ieee754": "^1.1.13"
|
||||
}
|
||||
},
|
||||
"node_modules/callsites": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
||||
|
|
@ -2574,6 +2711,11 @@
|
|||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/citeproc": {
|
||||
"version": "2.4.63",
|
||||
"resolved": "https://registry.npmjs.org/citeproc/-/citeproc-2.4.63.tgz",
|
||||
"integrity": "sha512-68F95Bp4UbgZU/DBUGQn0qV3HDZLCdI9+Bb2ByrTaNJDL5VEm9LqaiNaxljsvoaExSLEXe1/r6n2Z06SCzW3/Q=="
|
||||
},
|
||||
"node_modules/classcat": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
|
||||
|
|
@ -3173,6 +3315,14 @@
|
|||
"reusify": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/fetch-ponyfill": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fetch-ponyfill/-/fetch-ponyfill-7.1.0.tgz",
|
||||
"integrity": "sha512-FhbbL55dj/qdVO3YNK7ZEkshvj3eQ7EuIGV2I6ic/2YiocvyWv+7jg2s4AyS0wdRU75s3tA8ZxI/xPigb0v5Aw==",
|
||||
"dependencies": {
|
||||
"node-fetch": "~2.6.1"
|
||||
}
|
||||
},
|
||||
"node_modules/file-entry-cache": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
|
||||
|
|
@ -3438,6 +3588,25 @@
|
|||
"resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.11.tgz",
|
||||
"integrity": "sha512-9gux8QhvjRO/erSnDPv28noDZcPZmYE7e1vFsBLKLlRlKDSqNJYebj6Qz1TGd5lsRV+X+xYyjCKjuZdABinWjA=="
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
]
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
|
|
@ -3814,6 +3983,11 @@
|
|||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/moo": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/moo/-/moo-0.5.2.tgz",
|
||||
"integrity": "sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q=="
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
|
|
@ -3854,6 +4028,25 @@
|
|||
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "2.6.13",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.13.tgz",
|
||||
"integrity": "sha512-StxNAxh15zr77QvvkmveSQ8uCQ4+v5FkvNTj0OESmiHu+VRi/gXArXtkWMElOsOUNLtUEvI4yS+rdtOHZTwlQA==",
|
||||
"dependencies": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "4.x || >=6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"encoding": "^0.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"encoding": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.23",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.23.tgz",
|
||||
|
|
@ -4806,6 +4999,18 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/sync-fetch": {
|
||||
"version": "0.4.5",
|
||||
"resolved": "https://registry.npmjs.org/sync-fetch/-/sync-fetch-0.4.5.tgz",
|
||||
"integrity": "sha512-esiWJ7ixSKGpd9DJPBTC4ckChqdOjIwJfYhVHkcQ2Gnm41323p1TRmEI+esTQ9ppD+b5opps2OTEGTCGX5kF+g==",
|
||||
"dependencies": {
|
||||
"buffer": "^5.7.1",
|
||||
"node-fetch": "^2.6.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "3.4.18",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz",
|
||||
|
|
@ -4882,6 +5087,11 @@
|
|||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
|
||||
},
|
||||
"node_modules/ts-api-utils": {
|
||||
"version": "1.4.3",
|
||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz",
|
||||
|
|
@ -5048,6 +5258,20 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||
"dependencies": {
|
||||
"tr46": "~0.0.3",
|
||||
"webidl-conversions": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
|
|
|||
|
|
@ -10,6 +10,11 @@
|
|||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@citation-js/core": "^0.7.18",
|
||||
"@citation-js/plugin-bibtex": "^0.7.18",
|
||||
"@citation-js/plugin-csl": "^0.7.18",
|
||||
"@citation-js/plugin-doi": "^0.7.18",
|
||||
"@citation-js/plugin-ris": "^0.7.18",
|
||||
"@emotion/react": "^11.11.3",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@mui/icons-material": "^5.15.10",
|
||||
|
|
|
|||
247
src/components/Common/CitationSelector.tsx
Normal file
247
src/components/Common/CitationSelector.tsx
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useBibliographyStore } from '../../stores/bibliographyStore';
|
||||
import { formatShortCitation } from '../../utils/bibliography/formatting';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import MenuBookIcon from '@mui/icons-material/MenuBook';
|
||||
import type { BibliographyReference } from '../../types/bibliography';
|
||||
|
||||
interface CitationSelectorProps {
|
||||
value: string[];
|
||||
onChange: (citationIds: string[]) => void;
|
||||
onOpenBibliography?: () => void;
|
||||
}
|
||||
|
||||
const CitationSelector = ({ value, onChange, onOpenBibliography }: CitationSelectorProps) => {
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(0);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const { getReferences } = useBibliographyStore();
|
||||
|
||||
const allReferences = getReferences();
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
setInputValue('');
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Filter references by search query
|
||||
const filteredReferences = allReferences.filter(ref => {
|
||||
const query = inputValue.toLowerCase();
|
||||
const title = (ref.title || '').toLowerCase();
|
||||
const authors = ref.author?.map(a =>
|
||||
a.literal || `${a.given || ''} ${a.family || ''}`.trim()
|
||||
).join(' ').toLowerCase() || '';
|
||||
const year = ref.issued?.['date-parts']?.[0]?.[0]?.toString() || '';
|
||||
|
||||
return title.includes(query) || authors.includes(query) || year.includes(query);
|
||||
});
|
||||
|
||||
// Get selected references
|
||||
const selectedReferences = value
|
||||
.map(id => allReferences.find(ref => ref.id === id))
|
||||
.filter((ref): ref is BibliographyReference => ref !== undefined);
|
||||
|
||||
const handleToggleReference = (refId: string) => {
|
||||
if (value.includes(refId)) {
|
||||
onChange(value.filter(id => id !== refId));
|
||||
} else {
|
||||
onChange([...value, refId]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveReference = (refId: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onChange(value.filter(id => id !== refId));
|
||||
};
|
||||
|
||||
// Keyboard navigation
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (!isOpen && e.key !== 'Escape') {
|
||||
setIsOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const totalOptions = filteredReferences.length;
|
||||
if (totalOptions === 0) return;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setHighlightedIndex((prev) => (prev + 1) % totalOptions);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setHighlightedIndex((prev) => (prev - 1 + totalOptions) % totalOptions);
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
if (highlightedIndex >= 0 && highlightedIndex < totalOptions) {
|
||||
handleToggleReference(filteredReferences[highlightedIndex].id);
|
||||
setInputValue('');
|
||||
setHighlightedIndex(0);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
setIsOpen(false);
|
||||
setInputValue('');
|
||||
inputRef.current?.blur();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// Reset highlighted index when filtered references change
|
||||
useEffect(() => {
|
||||
setHighlightedIndex(0);
|
||||
}, [inputValue]);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
{/* Selected Citations Display */}
|
||||
{selectedReferences.length > 0 && (
|
||||
<div className="mb-2 space-y-1">
|
||||
{selectedReferences.map(ref => (
|
||||
<div
|
||||
key={ref.id}
|
||||
className="flex items-start justify-between gap-2 px-2 py-1.5 bg-blue-50 border border-blue-200 rounded text-xs"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-gray-900 line-clamp-1">
|
||||
{ref.title || 'Untitled'}
|
||||
</div>
|
||||
<div className="text-gray-600 text-xs">
|
||||
{formatShortCitation(ref)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => handleRemoveReference(ref.id, e)}
|
||||
className="flex-shrink-0 p-0.5 text-gray-500 hover:text-red-600 transition-colors"
|
||||
aria-label="Remove citation"
|
||||
>
|
||||
<CloseIcon sx={{ fontSize: 14 }} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Text Input Field */}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={(e) => {
|
||||
setInputValue(e.target.value);
|
||||
setIsOpen(true);
|
||||
}}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type to search citations..."
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
|
||||
{/* Dropdown Menu */}
|
||||
{isOpen && (
|
||||
<div className="absolute z-50 mt-1 w-full bg-white border border-gray-200 rounded-md shadow-lg max-h-64 flex flex-col">
|
||||
{/* Reference List */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{filteredReferences.length === 0 ? (
|
||||
<div className="p-4 text-center">
|
||||
{allReferences.length === 0 ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-gray-500">No references yet.</p>
|
||||
{onOpenBibliography && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onOpenBibliography();
|
||||
setIsOpen(false);
|
||||
setInputValue('');
|
||||
}}
|
||||
className="text-sm text-blue-600 hover:text-blue-700 font-medium flex items-center justify-center gap-1"
|
||||
>
|
||||
<MenuBookIcon sx={{ fontSize: 16 }} />
|
||||
Add References
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">No matching references.</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-1">
|
||||
{filteredReferences.map((ref, index) => {
|
||||
const isSelected = value.includes(ref.id);
|
||||
const isHighlighted = index === highlightedIndex;
|
||||
return (
|
||||
<button
|
||||
key={ref.id}
|
||||
onClick={() => {
|
||||
handleToggleReference(ref.id);
|
||||
setInputValue('');
|
||||
setHighlightedIndex(0);
|
||||
}}
|
||||
onMouseEnter={() => setHighlightedIndex(index)}
|
||||
className={`w-full text-left px-3 py-2 text-sm transition-colors ${
|
||||
isHighlighted ? 'bg-gray-100' : isSelected ? 'bg-blue-50' : 'hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => {}} // Handled by button onClick
|
||||
className="mt-0.5 flex-shrink-0"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-gray-900 line-clamp-1">
|
||||
{ref.title || 'Untitled'}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">
|
||||
{formatShortCitation(ref)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{allReferences.length > 0 && onOpenBibliography && (
|
||||
<div className="p-2 border-t border-gray-200">
|
||||
<button
|
||||
onClick={() => {
|
||||
onOpenBibliography();
|
||||
setIsOpen(false);
|
||||
setInputValue('');
|
||||
}}
|
||||
className="w-full px-2 py-1.5 text-xs text-gray-600 hover:text-gray-900 hover:bg-gray-50 rounded transition-colors flex items-center justify-center gap-1"
|
||||
>
|
||||
<MenuBookIcon sx={{ fontSize: 14 }} />
|
||||
Manage Bibliography
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CitationSelector;
|
||||
124
src/components/Config/BibliographyConfig.tsx
Normal file
124
src/components/Config/BibliographyConfig.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useBibliographyStore } from '../../stores/bibliographyStore';
|
||||
import { useBibliographyWithHistory } from '../../hooks/useBibliographyWithHistory';
|
||||
import QuickAddReferenceForm from './QuickAddReferenceForm';
|
||||
import ReferenceManagementList from './ReferenceManagementList';
|
||||
import EditReferenceInline from './EditReferenceInline';
|
||||
|
||||
interface BibliographyConfigProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
initialEditingReferenceId?: string | null;
|
||||
}
|
||||
|
||||
const BibliographyConfig = ({ isOpen, onClose, initialEditingReferenceId = null }: BibliographyConfigProps) => {
|
||||
const [editingReferenceId, setEditingReferenceId] = useState<string | null>(initialEditingReferenceId);
|
||||
const { getReferences } = useBibliographyStore();
|
||||
const { deleteReference: deleteReferenceWithHistory } = useBibliographyWithHistory();
|
||||
|
||||
const references = getReferences();
|
||||
|
||||
// Handle initial editing reference if provided
|
||||
useEffect(() => {
|
||||
if (isOpen && initialEditingReferenceId) {
|
||||
setEditingReferenceId(initialEditingReferenceId);
|
||||
}
|
||||
}, [isOpen, initialEditingReferenceId]);
|
||||
|
||||
// Close modal on Escape key
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
if (editingReferenceId) {
|
||||
setEditingReferenceId(null);
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => document.removeEventListener('keydown', handleEscape);
|
||||
}, [isOpen, onClose, editingReferenceId]);
|
||||
|
||||
const handleEditReference = (id: string) => {
|
||||
setEditingReferenceId(id);
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditingReferenceId(null);
|
||||
};
|
||||
|
||||
const handleDeleteReference = (id: string) => {
|
||||
deleteReferenceWithHistory(id);
|
||||
};
|
||||
|
||||
const handleDone = () => {
|
||||
setEditingReferenceId(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black bg-opacity-50"
|
||||
onClick={handleDone}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative bg-white rounded-lg shadow-xl w-full max-w-5xl max-h-[85vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-xl font-bold text-gray-900">Bibliography Manager</h2>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Add and manage references for your constellation analysis. Import from DOI, URL, BibTeX, or enter manually.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-hidden flex min-h-0">
|
||||
{editingReferenceId ? (
|
||||
// Edit mode - full width
|
||||
<EditReferenceInline
|
||||
referenceId={editingReferenceId}
|
||||
onCancel={handleCancelEdit}
|
||||
/>
|
||||
) : (
|
||||
// Two-column layout
|
||||
<>
|
||||
{/* Left Column - Quick Add */}
|
||||
<div className="w-3/5 border-r border-gray-200 p-6 overflow-y-auto">
|
||||
<QuickAddReferenceForm />
|
||||
</div>
|
||||
|
||||
{/* Right Column - Reference List */}
|
||||
<div className="w-2/5 p-6 overflow-y-auto bg-gray-50">
|
||||
<ReferenceManagementList
|
||||
references={references}
|
||||
onEdit={handleEditReference}
|
||||
onDelete={handleDeleteReference}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t border-gray-200 flex justify-end">
|
||||
<button
|
||||
onClick={handleDone}
|
||||
className="px-4 py-2 bg-gray-200 text-gray-800 text-sm font-medium rounded-md hover:bg-gray-300 transition-colors focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BibliographyConfig;
|
||||
364
src/components/Config/EditReferenceInline.tsx
Normal file
364
src/components/Config/EditReferenceInline.tsx
Normal file
|
|
@ -0,0 +1,364 @@
|
|||
import { useState, useEffect, KeyboardEvent } from 'react';
|
||||
import { useBibliographyStore } from '../../stores/bibliographyStore';
|
||||
import { useBibliographyWithHistory } from '../../hooks/useBibliographyWithHistory';
|
||||
import { useToastStore } from '../../stores/toastStore';
|
||||
import type { CSLReference } from '../../types/bibliography';
|
||||
|
||||
interface EditReferenceInlineProps {
|
||||
referenceId: string;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const EditReferenceInline = ({ referenceId, onCancel }: EditReferenceInlineProps) => {
|
||||
const { getReferenceById } = useBibliographyStore();
|
||||
const { updateReference } = useBibliographyWithHistory();
|
||||
const { showToast } = useToastStore();
|
||||
|
||||
const reference = getReferenceById(referenceId);
|
||||
|
||||
const [title, setTitle] = useState('');
|
||||
const [type, setType] = useState<CSLReference['type']>('article-journal');
|
||||
const [authors, setAuthors] = useState('');
|
||||
const [year, setYear] = useState('');
|
||||
const [journal, setJournal] = useState('');
|
||||
const [volume, setVolume] = useState('');
|
||||
const [issue, setIssue] = useState('');
|
||||
const [pages, setPages] = useState('');
|
||||
const [doi, setDoi] = useState('');
|
||||
const [url, setUrl] = useState('');
|
||||
const [abstract, setAbstract] = useState('');
|
||||
const [note, setNote] = useState('');
|
||||
|
||||
// Load reference data
|
||||
useEffect(() => {
|
||||
if (reference) {
|
||||
setTitle(reference.title || '');
|
||||
setType(reference.type || 'article-journal');
|
||||
|
||||
// Parse authors
|
||||
if (reference.author) {
|
||||
const authorStr = reference.author
|
||||
.map(a => {
|
||||
if (a.literal) return a.literal;
|
||||
return [a.given, a.family].filter(Boolean).join(' ');
|
||||
})
|
||||
.join(', ');
|
||||
setAuthors(authorStr);
|
||||
}
|
||||
|
||||
// Parse year
|
||||
if (reference.issued?.['date-parts']?.[0]?.[0]) {
|
||||
setYear(reference.issued['date-parts'][0][0].toString());
|
||||
}
|
||||
|
||||
setJournal(reference['container-title'] || '');
|
||||
setVolume(reference.volume?.toString() || '');
|
||||
setIssue(reference.issue?.toString() || '');
|
||||
setPages(reference.page || '');
|
||||
setDoi(reference.DOI || '');
|
||||
setUrl(reference.URL || '');
|
||||
setAbstract(reference.abstract || '');
|
||||
setNote(reference.note || '');
|
||||
}
|
||||
}, [reference]);
|
||||
|
||||
const handleSave = () => {
|
||||
if (!title.trim()) {
|
||||
showToast('Title is required', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const updates: Partial<CSLReference> = {
|
||||
title: title.trim(),
|
||||
type,
|
||||
};
|
||||
|
||||
// Parse authors
|
||||
if (authors.trim()) {
|
||||
const authorList = authors
|
||||
.split(/,| and /i)
|
||||
.map(name => {
|
||||
const parts = name.trim().split(' ');
|
||||
if (parts.length >= 2) {
|
||||
return {
|
||||
given: parts.slice(0, -1).join(' '),
|
||||
family: parts[parts.length - 1],
|
||||
};
|
||||
}
|
||||
return { literal: name.trim() };
|
||||
});
|
||||
updates.author = authorList;
|
||||
}
|
||||
|
||||
// Parse year
|
||||
if (year.trim()) {
|
||||
const yearNum = parseInt(year, 10);
|
||||
if (!isNaN(yearNum)) {
|
||||
updates.issued = { 'date-parts': [[yearNum]] };
|
||||
}
|
||||
}
|
||||
|
||||
// Add other fields if present
|
||||
if (journal.trim()) updates['container-title'] = journal.trim();
|
||||
if (volume.trim()) updates.volume = volume.trim();
|
||||
if (issue.trim()) updates.issue = issue.trim();
|
||||
if (pages.trim()) updates.page = pages.trim();
|
||||
if (doi.trim()) updates.DOI = doi.trim();
|
||||
if (url.trim()) updates.URL = url.trim();
|
||||
if (abstract.trim()) updates.abstract = abstract.trim();
|
||||
if (note.trim()) updates.note = note.trim();
|
||||
|
||||
updateReference(referenceId, updates);
|
||||
showToast('Reference updated successfully', 'success');
|
||||
onCancel();
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
if (!reference) {
|
||||
return (
|
||||
<div className="w-full p-6 text-center text-gray-500">
|
||||
Reference not found
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isMac = navigator.platform.includes('Mac');
|
||||
|
||||
return (
|
||||
<div className="w-full p-6 overflow-y-auto">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="flex flex-col min-h-full">
|
||||
<div className="flex-1 mb-6 space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Edit Reference</h3>
|
||||
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Title <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Type */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Type
|
||||
</label>
|
||||
<select
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value as CSLReference['type'])}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="article-journal">Journal Article</option>
|
||||
<option value="book">Book</option>
|
||||
<option value="chapter">Book Chapter</option>
|
||||
<option value="paper-conference">Conference Paper</option>
|
||||
<option value="thesis">Thesis</option>
|
||||
<option value="report">Report</option>
|
||||
<option value="webpage">Web Page</option>
|
||||
<option value="article-newspaper">Newspaper Article</option>
|
||||
<option value="interview">Interview</option>
|
||||
<option value="no-type">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Authors */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Authors
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={authors}
|
||||
onChange={(e) => setAuthors(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="e.g., John Doe, Jane Smith"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Year, Volume, Issue, Pages */}
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Year
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={year}
|
||||
onChange={(e) => setYear(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="2024"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Volume
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={volume}
|
||||
onChange={(e) => setVolume(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="42"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Issue
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={issue}
|
||||
onChange={(e) => setIssue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="3"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Pages
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={pages}
|
||||
onChange={(e) => setPages(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="123-145"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Journal/Container Title */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Journal / Publication
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={journal}
|
||||
onChange={(e) => setJournal(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Journal of Example Studies"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* DOI and URL */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
DOI
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={doi}
|
||||
onChange={(e) => setDoi(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="10.1234/example"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
URL
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="https://example.com"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Abstract */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Abstract
|
||||
</label>
|
||||
<textarea
|
||||
value={abstract}
|
||||
onChange={(e) => setAbstract(e.target.value)}
|
||||
placeholder="Brief summary of the work..."
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description/Note */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Description / Notes
|
||||
</label>
|
||||
<textarea
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder="Additional information about this reference..."
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="pt-6 border-t border-gray-200 space-y-3">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="w-full px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
>
|
||||
Save Changes
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="w-full px-4 py-2 bg-white border border-gray-300 text-gray-700 text-sm font-medium rounded-md hover:bg-gray-50 transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
<p className="text-xs text-center text-gray-500">
|
||||
Press{' '}
|
||||
<kbd className="px-1.5 py-0.5 bg-gray-100 border border-gray-300 rounded text-xs">
|
||||
{isMac ? '⌘' : 'Ctrl'}+Enter
|
||||
</kbd>{' '}
|
||||
to save,{' '}
|
||||
<kbd className="px-1.5 py-0.5 bg-gray-100 border border-gray-300 rounded text-xs">
|
||||
Esc
|
||||
</kbd>{' '}
|
||||
to cancel
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditReferenceInline;
|
||||
319
src/components/Config/QuickAddReferenceForm.tsx
Normal file
319
src/components/Config/QuickAddReferenceForm.tsx
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
import { useState, useRef, useEffect, KeyboardEvent } from 'react';
|
||||
import { useBibliographyWithHistory } from '../../hooks/useBibliographyWithHistory';
|
||||
import { useBibliographyStore } from '../../stores/bibliographyStore';
|
||||
import { useToastStore } from '../../stores/toastStore';
|
||||
import { isValidCitationInput, getInputTypeHint } from '../../utils/bibliography/smart-parser';
|
||||
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import type { CSLReference } from '../../types/bibliography';
|
||||
|
||||
const QuickAddReferenceForm = () => {
|
||||
const [smartInput, setSmartInput] = useState('');
|
||||
const [manualEntry, setManualEntry] = useState(false);
|
||||
const [title, setTitle] = useState('');
|
||||
const [authors, setAuthors] = useState('');
|
||||
const [year, setYear] = useState('');
|
||||
const [type, setType] = useState<CSLReference['type']>('article-journal');
|
||||
const [description, setDescription] = useState('');
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
|
||||
const smartInputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const titleInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { addReference } = useBibliographyWithHistory();
|
||||
const { parseInput } = useBibliographyStore();
|
||||
const { showToast } = useToastStore();
|
||||
|
||||
// Auto-focus appropriate field
|
||||
useEffect(() => {
|
||||
if (manualEntry) {
|
||||
titleInputRef.current?.focus();
|
||||
} else {
|
||||
smartInputRef.current?.focus();
|
||||
}
|
||||
}, [manualEntry]);
|
||||
|
||||
const resetForm = () => {
|
||||
setSmartInput('');
|
||||
setTitle('');
|
||||
setAuthors('');
|
||||
setYear('');
|
||||
setType('article-journal');
|
||||
setDescription('');
|
||||
setManualEntry(false);
|
||||
};
|
||||
|
||||
const handleSmartAdd = async () => {
|
||||
if (!smartInput.trim()) return;
|
||||
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
// Use citation.js to parse the input
|
||||
const parsed = await parseInput(smartInput.trim());
|
||||
|
||||
if (parsed.length > 0) {
|
||||
// Add all parsed references
|
||||
parsed.forEach(ref => addReference(ref));
|
||||
showToast(
|
||||
parsed.length === 1
|
||||
? 'Reference added successfully'
|
||||
: `${parsed.length} references added successfully`,
|
||||
'success'
|
||||
);
|
||||
resetForm();
|
||||
smartInputRef.current?.focus();
|
||||
} else {
|
||||
showToast('Could not parse citation data', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Parse error:', error);
|
||||
showToast('Failed to parse input. Try manual entry instead.', 'error');
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleManualAdd = () => {
|
||||
if (!title.trim()) {
|
||||
showToast('Title is required', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const ref: Partial<CSLReference> = {
|
||||
type,
|
||||
title: title.trim(),
|
||||
};
|
||||
|
||||
// Parse authors (simplified - split by comma or "and")
|
||||
if (authors.trim()) {
|
||||
const authorList = authors
|
||||
.split(/,| and /i)
|
||||
.map(name => {
|
||||
const parts = name.trim().split(' ');
|
||||
if (parts.length >= 2) {
|
||||
return {
|
||||
given: parts.slice(0, -1).join(' '),
|
||||
family: parts[parts.length - 1],
|
||||
};
|
||||
}
|
||||
return { literal: name.trim() };
|
||||
});
|
||||
ref.author = authorList;
|
||||
}
|
||||
|
||||
// Parse year
|
||||
if (year.trim()) {
|
||||
const yearNum = parseInt(year, 10);
|
||||
if (!isNaN(yearNum)) {
|
||||
ref.issued = { 'date-parts': [[yearNum]] };
|
||||
}
|
||||
}
|
||||
|
||||
// Add description/note if present
|
||||
if (description.trim()) {
|
||||
ref.note = description.trim();
|
||||
}
|
||||
|
||||
addReference(ref);
|
||||
showToast('Reference added successfully', 'success');
|
||||
resetForm();
|
||||
titleInputRef.current?.focus();
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
if (manualEntry) {
|
||||
handleManualAdd();
|
||||
} else {
|
||||
handleSmartAdd();
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
resetForm();
|
||||
}
|
||||
};
|
||||
|
||||
const inputTypeHint = smartInput.trim() ? getInputTypeHint(smartInput) : null;
|
||||
const isValidInput = smartInput.trim() ? isValidCitationInput(smartInput) : false;
|
||||
const hasManualContent = title.trim() || authors.trim() || year.trim() || description.trim();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-3">Quick Add Reference</h3>
|
||||
|
||||
{/* Toggle between Smart and Manual */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
<button
|
||||
onClick={() => setManualEntry(false)}
|
||||
className={`flex-1 px-3 py-2 text-sm font-medium rounded-md transition-colors ${
|
||||
!manualEntry
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-white border border-gray-300 text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<AutoAwesomeIcon className="text-sm mr-1" />
|
||||
Smart Import
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setManualEntry(true)}
|
||||
className={`flex-1 px-3 py-2 text-sm font-medium rounded-md transition-colors ${
|
||||
manualEntry
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-white border border-gray-300 text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<AddIcon className="text-sm mr-1" />
|
||||
Manual Entry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!manualEntry ? (
|
||||
// Smart Import Mode
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
DOI, URL, BibTeX, or PubMed ID
|
||||
</label>
|
||||
<textarea
|
||||
ref={smartInputRef}
|
||||
value={smartInput}
|
||||
onChange={(e) => setSmartInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Paste DOI (10.1234/example), URL, BibTeX entry, or PubMed ID..."
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none font-mono"
|
||||
/>
|
||||
{inputTypeHint && (
|
||||
<p className={`text-xs mt-1 ${isValidInput ? 'text-green-600' : 'text-orange-600'}`}>
|
||||
Detected: {inputTypeHint}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSmartAdd}
|
||||
disabled={!smartInput.trim() || isProcessing}
|
||||
className="w-full px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
>
|
||||
{isProcessing ? 'Processing...' : 'Import Reference'}
|
||||
</button>
|
||||
|
||||
{smartInput.trim() && (
|
||||
<p className="text-xs text-gray-500">
|
||||
Press <kbd className="px-1.5 py-0.5 bg-gray-100 border border-gray-300 rounded text-xs">
|
||||
{navigator.platform.includes('Mac') ? '⌘' : 'Ctrl'}+Enter
|
||||
</kbd> to add
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// Manual Entry Mode
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Title <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
ref={titleInputRef}
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Enter reference title"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Authors
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={authors}
|
||||
onChange={(e) => setAuthors(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="e.g., John Doe, Jane Smith"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Year
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={year}
|
||||
onChange={(e) => setYear(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="2024"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Type
|
||||
</label>
|
||||
<select
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value as CSLReference['type'])}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="article-journal">Journal Article</option>
|
||||
<option value="book">Book</option>
|
||||
<option value="chapter">Book Chapter</option>
|
||||
<option value="paper-conference">Conference Paper</option>
|
||||
<option value="thesis">Thesis</option>
|
||||
<option value="report">Report</option>
|
||||
<option value="webpage">Web Page</option>
|
||||
<option value="article-newspaper">Newspaper Article</option>
|
||||
<option value="interview">Interview</option>
|
||||
<option value="no-type">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description/Note */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Description / Notes
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Additional information about this reference..."
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleManualAdd}
|
||||
disabled={!title.trim()}
|
||||
className="w-full px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
>
|
||||
Add Reference
|
||||
</button>
|
||||
|
||||
{hasManualContent && (
|
||||
<p className="text-xs text-gray-500">
|
||||
Press <kbd className="px-1.5 py-0.5 bg-gray-100 border border-gray-300 rounded text-xs">
|
||||
{navigator.platform.includes('Mac') ? '⌘' : 'Ctrl'}+Enter
|
||||
</kbd> to add
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuickAddReferenceForm;
|
||||
134
src/components/Config/ReferenceManagementList.tsx
Normal file
134
src/components/Config/ReferenceManagementList.tsx
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import { useBibliographyWithHistory } from '../../hooks/useBibliographyWithHistory';
|
||||
import { formatShortCitation } from '../../utils/bibliography/formatting';
|
||||
import { useConfirm } from '../../hooks/useConfirm';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import type { BibliographyReference } from '../../types/bibliography';
|
||||
|
||||
interface ReferenceManagementListProps {
|
||||
references: BibliographyReference[];
|
||||
onEdit: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
const ReferenceManagementList = ({ references, onEdit, onDelete }: ReferenceManagementListProps) => {
|
||||
const { getCitationCount } = useBibliographyWithHistory();
|
||||
const { confirm } = useConfirm();
|
||||
|
||||
const handleDelete = async (ref: BibliographyReference) => {
|
||||
const citationCount = getCitationCount(ref.id);
|
||||
const totalCitations = citationCount.nodes + citationCount.edges;
|
||||
|
||||
const message =
|
||||
totalCitations > 0
|
||||
? `Delete "${ref.title}"? This reference is cited ${totalCitations} time${totalCitations > 1 ? 's' : ''} (${citationCount.nodes} actors, ${citationCount.edges} relations). Citations will be removed.`
|
||||
: `Delete "${ref.title}"?`;
|
||||
|
||||
const confirmed = await confirm({
|
||||
title: 'Delete Reference',
|
||||
message,
|
||||
severity: totalCitations > 0 ? 'warning' : 'info',
|
||||
confirmLabel: 'Delete',
|
||||
cancelLabel: 'Cancel',
|
||||
});
|
||||
|
||||
if (confirmed) {
|
||||
onDelete(ref.id);
|
||||
}
|
||||
};
|
||||
|
||||
if (references.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<p className="text-sm">No references yet.</p>
|
||||
<p className="text-xs mt-1">Add your first reference using the form on the left.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-3">
|
||||
All References ({references.length})
|
||||
</h3>
|
||||
|
||||
{references.map((ref) => {
|
||||
const shortCitation = formatShortCitation(ref);
|
||||
const citationCount = getCitationCount(ref.id);
|
||||
const totalCitations = citationCount.nodes + citationCount.edges;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={ref.id}
|
||||
className="group bg-white border border-gray-200 rounded-lg p-3 hover:border-blue-300 hover:shadow-sm transition-all"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div
|
||||
className="flex-1 cursor-pointer min-w-0"
|
||||
onClick={() => onEdit(ref.id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onEdit(ref.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="font-medium text-sm text-gray-900 line-clamp-2">
|
||||
{ref.title || 'Untitled'}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 mt-1">
|
||||
{shortCitation}
|
||||
</div>
|
||||
|
||||
{/* Reference Type Badge */}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-700">
|
||||
{ref.type.replace(/-/g, ' ')}
|
||||
</span>
|
||||
|
||||
{/* Citation Count */}
|
||||
{totalCitations > 0 && (
|
||||
<span className="text-xs text-gray-500">
|
||||
{totalCitations} citation{totalCitations > 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* DOI indicator */}
|
||||
{ref.DOI && (
|
||||
<span className="text-xs text-blue-600">
|
||||
DOI
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => onEdit(ref.id)}
|
||||
className="p-1.5 text-gray-600 hover:bg-gray-100 rounded transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
aria-label="Edit reference"
|
||||
title="Edit reference"
|
||||
>
|
||||
<EditIcon className="text-base" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(ref)}
|
||||
className="p-1.5 text-red-600 hover:bg-red-50 rounded transition-colors focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||
aria-label="Delete reference"
|
||||
title="Delete reference"
|
||||
>
|
||||
<DeleteIcon className="text-base" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReferenceManagementList;
|
||||
|
|
@ -7,6 +7,7 @@ import DocumentManager from '../Workspace/DocumentManager';
|
|||
import NodeTypeConfigModal from '../Config/NodeTypeConfig';
|
||||
import EdgeTypeConfigModal from '../Config/EdgeTypeConfig';
|
||||
import LabelConfigModal from '../Config/LabelConfig';
|
||||
import BibliographyConfigModal from '../Config/BibliographyConfig';
|
||||
import InputDialog from '../Common/InputDialog';
|
||||
import { useConfirm } from '../../hooks/useConfirm';
|
||||
import { useShortcutLabels } from '../../hooks/useShortcutLabels';
|
||||
|
|
@ -34,6 +35,7 @@ const MenuBar: React.FC<MenuBarProps> = ({ onOpenHelp, onFitView, onExport }) =>
|
|||
const [showNodeConfig, setShowNodeConfig] = useState(false);
|
||||
const [showEdgeConfig, setShowEdgeConfig] = useState(false);
|
||||
const [showLabelConfig, setShowLabelConfig] = useState(false);
|
||||
const [showBibliographyConfig, setShowBibliographyConfig] = useState(false);
|
||||
const [showNewDocDialog, setShowNewDocDialog] = useState(false);
|
||||
const [showNewFromTemplateDialog, setShowNewFromTemplateDialog] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -180,6 +182,11 @@ const MenuBar: React.FC<MenuBarProps> = ({ onOpenHelp, onFitView, onExport }) =>
|
|||
closeMenu();
|
||||
}, [closeMenu]);
|
||||
|
||||
const handleManageBibliography = useCallback(() => {
|
||||
setShowBibliographyConfig(true);
|
||||
closeMenu();
|
||||
}, [closeMenu]);
|
||||
|
||||
const handleUndo = useCallback(() => {
|
||||
undo();
|
||||
closeMenu();
|
||||
|
|
@ -386,6 +393,12 @@ const MenuBar: React.FC<MenuBarProps> = ({ onOpenHelp, onFitView, onExport }) =>
|
|||
>
|
||||
Configure Labels
|
||||
</button>
|
||||
<button
|
||||
onClick={handleManageBibliography}
|
||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
Manage Bibliography
|
||||
</button>
|
||||
|
||||
<hr className="my-1 border-gray-200" />
|
||||
|
||||
|
|
@ -482,6 +495,10 @@ const MenuBar: React.FC<MenuBarProps> = ({ onOpenHelp, onFitView, onExport }) =>
|
|||
isOpen={showLabelConfig}
|
||||
onClose={() => setShowLabelConfig(false)}
|
||||
/>
|
||||
<BibliographyConfigModal
|
||||
isOpen={showBibliographyConfig}
|
||||
onClose={() => setShowBibliographyConfig(false)}
|
||||
/>
|
||||
|
||||
{/* Input Dialogs */}
|
||||
<InputDialog
|
||||
|
|
|
|||
|
|
@ -12,7 +12,9 @@ import { useConfirm } from '../../hooks/useConfirm';
|
|||
import ConnectionDisplay from '../Common/ConnectionDisplay';
|
||||
import EdgeTypeConfigModal from '../Config/EdgeTypeConfig';
|
||||
import LabelConfigModal from '../Config/LabelConfig';
|
||||
import BibliographyConfigModal from '../Config/BibliographyConfig';
|
||||
import AutocompleteLabelSelector from '../Common/AutocompleteLabelSelector';
|
||||
import CitationSelector from '../Common/CitationSelector';
|
||||
import type { Relation, EdgeDirectionality } from '../../types';
|
||||
|
||||
interface EdgeEditorPanelProps {
|
||||
|
|
@ -29,6 +31,7 @@ const EdgeEditorPanel = ({ selectedEdge, onClose }: EdgeEditorPanelProps) => {
|
|||
const [relationType, setRelationType] = useState('');
|
||||
const [relationLabel, setRelationLabel] = useState('');
|
||||
const [relationLabels, setRelationLabels] = useState<string[]>([]);
|
||||
const [relationCitations, setRelationCitations] = useState<string[]>([]);
|
||||
const [relationDirectionality, setRelationDirectionality] = useState<EdgeDirectionality>('directed');
|
||||
|
||||
// Track if user has made changes
|
||||
|
|
@ -41,6 +44,9 @@ const EdgeEditorPanel = ({ selectedEdge, onClose }: EdgeEditorPanelProps) => {
|
|||
// Label modal state
|
||||
const [showLabelModal, setShowLabelModal] = useState(false);
|
||||
|
||||
// Bibliography modal state
|
||||
const [showBibliographyModal, setShowBibliographyModal] = useState(false);
|
||||
|
||||
// Update state when selected edge changes
|
||||
useEffect(() => {
|
||||
if (selectedEdge.data) {
|
||||
|
|
@ -49,6 +55,7 @@ const EdgeEditorPanel = ({ selectedEdge, onClose }: EdgeEditorPanelProps) => {
|
|||
const hasCustomLabel = selectedEdge.data.label && selectedEdge.data.label !== typeLabel;
|
||||
setRelationLabel((hasCustomLabel && selectedEdge.data.label) || '');
|
||||
setRelationLabels(selectedEdge.data.labels || []);
|
||||
setRelationCitations(selectedEdge.data.citations || []);
|
||||
const edgeTypeConfig = edgeTypes.find((et) => et.id === selectedEdge.data?.type);
|
||||
setRelationDirectionality(selectedEdge.data.directionality || edgeTypeConfig?.defaultDirectionality || 'directed');
|
||||
setHasEdgeChanges(false);
|
||||
|
|
@ -63,9 +70,10 @@ const EdgeEditorPanel = ({ selectedEdge, onClose }: EdgeEditorPanelProps) => {
|
|||
label: relationLabel.trim() || undefined,
|
||||
directionality: relationDirectionality,
|
||||
labels: relationLabels.length > 0 ? relationLabels : undefined,
|
||||
citations: relationCitations.length > 0 ? relationCitations : undefined,
|
||||
});
|
||||
setHasEdgeChanges(false);
|
||||
}, [selectedEdge.id, relationType, relationLabel, relationDirectionality, relationLabels, hasEdgeChanges, updateEdge]);
|
||||
}, [selectedEdge.id, relationType, relationLabel, relationDirectionality, relationLabels, relationCitations, hasEdgeChanges, updateEdge]);
|
||||
|
||||
// Debounce live updates
|
||||
useEffect(() => {
|
||||
|
|
@ -187,6 +195,7 @@ const EdgeEditorPanel = ({ selectedEdge, onClose }: EdgeEditorPanelProps) => {
|
|||
label: relationLabel.trim() || undefined,
|
||||
directionality: relationDirectionality,
|
||||
labels: relationLabels.length > 0 ? relationLabels : undefined,
|
||||
citations: relationCitations.length > 0 ? relationCitations : undefined,
|
||||
});
|
||||
}}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
|
|
@ -246,6 +255,21 @@ const EdgeEditorPanel = ({ selectedEdge, onClose }: EdgeEditorPanelProps) => {
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* Citations */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Citations (optional)
|
||||
</label>
|
||||
<CitationSelector
|
||||
value={relationCitations}
|
||||
onChange={(newCitations) => {
|
||||
setRelationCitations(newCitations);
|
||||
setHasEdgeChanges(true);
|
||||
}}
|
||||
onOpenBibliography={() => setShowBibliographyModal(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Directionality */}
|
||||
<div className="pt-3 border-t border-gray-200">
|
||||
<label className="block text-xs font-medium text-gray-700 mb-2">
|
||||
|
|
@ -263,6 +287,7 @@ const EdgeEditorPanel = ({ selectedEdge, onClose }: EdgeEditorPanelProps) => {
|
|||
label: relationLabel.trim() || undefined,
|
||||
directionality: newValue,
|
||||
labels: relationLabels.length > 0 ? relationLabels : undefined,
|
||||
citations: relationCitations.length > 0 ? relationCitations : undefined,
|
||||
});
|
||||
}
|
||||
}}
|
||||
|
|
@ -345,6 +370,10 @@ const EdgeEditorPanel = ({ selectedEdge, onClose }: EdgeEditorPanelProps) => {
|
|||
isOpen={showLabelModal}
|
||||
onClose={() => setShowLabelModal(false)}
|
||||
/>
|
||||
<BibliographyConfigModal
|
||||
isOpen={showBibliographyModal}
|
||||
onClose={() => setShowBibliographyModal(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@ import { useConfirm } from '../../hooks/useConfirm';
|
|||
import ConnectionDisplay from '../Common/ConnectionDisplay';
|
||||
import NodeTypeConfigModal from '../Config/NodeTypeConfig';
|
||||
import LabelConfigModal from '../Config/LabelConfig';
|
||||
import BibliographyConfigModal from '../Config/BibliographyConfig';
|
||||
import AutocompleteLabelSelector from '../Common/AutocompleteLabelSelector';
|
||||
import CitationSelector from '../Common/CitationSelector';
|
||||
import type { Actor } from '../../types';
|
||||
|
||||
interface NodeEditorPanelProps {
|
||||
|
|
@ -24,6 +26,7 @@ const NodeEditorPanel = ({ selectedNode, onClose }: NodeEditorPanelProps) => {
|
|||
const [actorLabel, setActorLabel] = useState('');
|
||||
const [actorDescription, setActorDescription] = useState('');
|
||||
const [actorLabels, setActorLabels] = useState<string[]>([]);
|
||||
const [actorCitations, setActorCitations] = useState<string[]>([]);
|
||||
const labelInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Track if user has made changes
|
||||
|
|
@ -36,12 +39,16 @@ const NodeEditorPanel = ({ selectedNode, onClose }: NodeEditorPanelProps) => {
|
|||
// Label modal state
|
||||
const [showLabelModal, setShowLabelModal] = useState(false);
|
||||
|
||||
// Bibliography modal state
|
||||
const [showBibliographyModal, setShowBibliographyModal] = useState(false);
|
||||
|
||||
// Update state when selected node changes
|
||||
useEffect(() => {
|
||||
setActorType(selectedNode.data?.type || '');
|
||||
setActorLabel(selectedNode.data?.label || '');
|
||||
setActorDescription(selectedNode.data?.description || '');
|
||||
setActorLabels(selectedNode.data?.labels || []);
|
||||
setActorCitations(selectedNode.data?.citations || []);
|
||||
setHasNodeChanges(false);
|
||||
|
||||
// Focus and select the label input when node is selected
|
||||
|
|
@ -62,10 +69,11 @@ const NodeEditorPanel = ({ selectedNode, onClose }: NodeEditorPanelProps) => {
|
|||
label: actorLabel,
|
||||
description: actorDescription || undefined,
|
||||
labels: actorLabels.length > 0 ? actorLabels : undefined,
|
||||
citations: actorCitations.length > 0 ? actorCitations : undefined,
|
||||
},
|
||||
});
|
||||
setHasNodeChanges(false);
|
||||
}, [selectedNode.id, actorType, actorLabel, actorDescription, actorLabels, hasNodeChanges, updateNode]);
|
||||
}, [selectedNode.id, actorType, actorLabel, actorDescription, actorLabels, actorCitations, hasNodeChanges, updateNode]);
|
||||
|
||||
// Debounce live updates
|
||||
useEffect(() => {
|
||||
|
|
@ -147,6 +155,7 @@ const NodeEditorPanel = ({ selectedNode, onClose }: NodeEditorPanelProps) => {
|
|||
label: actorLabel,
|
||||
description: actorDescription || undefined,
|
||||
labels: actorLabels.length > 0 ? actorLabels : undefined,
|
||||
citations: actorCitations.length > 0 ? actorCitations : undefined,
|
||||
},
|
||||
});
|
||||
}}
|
||||
|
|
@ -232,6 +241,21 @@ const NodeEditorPanel = ({ selectedNode, onClose }: NodeEditorPanelProps) => {
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* Citations */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Citations (optional)
|
||||
</label>
|
||||
<CitationSelector
|
||||
value={actorCitations}
|
||||
onChange={(newCitations) => {
|
||||
setActorCitations(newCitations);
|
||||
setHasNodeChanges(true);
|
||||
}}
|
||||
onOpenBibliography={() => setShowBibliographyModal(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Connections */}
|
||||
<div className="pt-3 border-t border-gray-200">
|
||||
<h3 className="text-xs font-semibold text-gray-700 mb-2">
|
||||
|
|
@ -314,6 +338,10 @@ const NodeEditorPanel = ({ selectedNode, onClose }: NodeEditorPanelProps) => {
|
|||
isOpen={showLabelModal}
|
||||
onClose={() => setShowLabelModal(false)}
|
||||
/>
|
||||
<BibliographyConfigModal
|
||||
isOpen={showBibliographyModal}
|
||||
onClose={() => setShowBibliographyModal(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
81
src/hooks/useBibliographyWithHistory.ts
Normal file
81
src/hooks/useBibliographyWithHistory.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { useGraphStore } from '../stores/graphStore';
|
||||
import { useBibliographyStore } from '../stores/bibliographyStore';
|
||||
import { useWorkspaceStore } from '../stores/workspaceStore';
|
||||
import { useGraphWithHistory } from './useGraphWithHistory';
|
||||
import { useDocumentHistory } from './useDocumentHistory';
|
||||
import type { CSLReference } from '../types/bibliography';
|
||||
|
||||
/**
|
||||
* Hook that wraps bibliography operations with history tracking
|
||||
* Similar to useGraphWithHistory pattern
|
||||
*/
|
||||
export const useBibliographyWithHistory = () => {
|
||||
const { addReference, updateReference, deleteReference } = useBibliographyStore();
|
||||
const { pushToHistory } = useDocumentHistory();
|
||||
const { activeDocumentId } = useWorkspaceStore();
|
||||
const { nodes, edges } = useGraphStore();
|
||||
const { updateNode, updateEdge } = useGraphWithHistory();
|
||||
|
||||
const addReferenceWithHistory = (ref: Partial<CSLReference>) => {
|
||||
const id = addReference(ref);
|
||||
pushToHistory(`Added reference: ${ref.title || 'Untitled'}`);
|
||||
|
||||
// Mark document as dirty
|
||||
useWorkspaceStore.getState().markDocumentDirty(activeDocumentId!);
|
||||
|
||||
return id;
|
||||
};
|
||||
|
||||
const updateReferenceWithHistory = (id: string, updates: Partial<CSLReference>) => {
|
||||
const ref = useBibliographyStore.getState().getReferenceById(id);
|
||||
updateReference(id, updates);
|
||||
pushToHistory(`Updated reference: ${ref?.title || 'Unknown'}`);
|
||||
|
||||
// Mark document as dirty
|
||||
useWorkspaceStore.getState().markDocumentDirty(activeDocumentId!);
|
||||
};
|
||||
|
||||
const deleteReferenceWithHistory = (id: string) => {
|
||||
const ref = useBibliographyStore.getState().getReferenceById(id);
|
||||
|
||||
// Remove citations from nodes and edges
|
||||
nodes.forEach(node => {
|
||||
if (node.data.citations?.includes(id)) {
|
||||
updateNode(node.id, {
|
||||
data: {
|
||||
...node.data,
|
||||
citations: node.data.citations.filter(cid => cid !== id),
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
edges.forEach(edge => {
|
||||
if (edge.data?.citations?.includes(id)) {
|
||||
updateEdge(edge.id, {
|
||||
...edge.data,
|
||||
citations: edge.data.citations.filter(cid => cid !== id),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
deleteReference(id);
|
||||
pushToHistory(`Deleted reference: ${ref?.title || 'Unknown'}`);
|
||||
|
||||
// Mark document as dirty
|
||||
useWorkspaceStore.getState().markDocumentDirty(activeDocumentId!);
|
||||
};
|
||||
|
||||
const getCitationCount = (referenceId: string) => {
|
||||
const nodeCount = nodes.filter(n => n.data.citations?.includes(referenceId)).length;
|
||||
const edgeCount = edges.filter(e => e.data?.citations?.includes(referenceId)).length;
|
||||
return { nodes: nodeCount, edges: edgeCount };
|
||||
};
|
||||
|
||||
return {
|
||||
addReference: addReferenceWithHistory,
|
||||
updateReference: updateReferenceWithHistory,
|
||||
deleteReference: deleteReferenceWithHistory,
|
||||
getCitationCount,
|
||||
};
|
||||
};
|
||||
302
src/stores/bibliographyStore.ts
Normal file
302
src/stores/bibliographyStore.ts
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
import { create } from 'zustand';
|
||||
// @ts-expect-error - citation.js doesn't have TypeScript definitions
|
||||
import { Cite } from '@citation-js/core';
|
||||
// Load plugins
|
||||
import '@citation-js/plugin-csl';
|
||||
import '@citation-js/plugin-doi';
|
||||
import '@citation-js/plugin-bibtex';
|
||||
import '@citation-js/plugin-ris';
|
||||
|
||||
import type {
|
||||
BibliographyReference,
|
||||
CSLReference,
|
||||
ReferenceAppMetadata,
|
||||
BibliographySettings
|
||||
} from '../types/bibliography';
|
||||
|
||||
interface BibliographyStore {
|
||||
// State
|
||||
citeInstance: Cite; // The citation.js instance
|
||||
appMetadata: Record<string, ReferenceAppMetadata>; // App-specific data
|
||||
settings: BibliographySettings;
|
||||
|
||||
// Getters
|
||||
getReferences: () => BibliographyReference[]; // Merges CSL + app data
|
||||
getReferenceById: (id: string) => BibliographyReference | undefined;
|
||||
getCSLData: () => CSLReference[]; // Raw CSL-JSON from Cite
|
||||
|
||||
// CRUD operations (use citation.js methods)
|
||||
addReference: (ref: Partial<CSLReference>) => string;
|
||||
updateReference: (id: string, updates: Partial<CSLReference>) => void;
|
||||
deleteReference: (id: string) => void;
|
||||
duplicateReference: (id: string) => string;
|
||||
|
||||
// Bulk operations
|
||||
setReferences: (refs: CSLReference[]) => void;
|
||||
importReferences: (refs: CSLReference[]) => void;
|
||||
clearAll: () => void;
|
||||
|
||||
// Metadata operations
|
||||
updateMetadata: (id: string, metadata: Partial<ReferenceAppMetadata>) => void;
|
||||
|
||||
// Settings
|
||||
setSettings: (settings: BibliographySettings) => void;
|
||||
|
||||
// Citation.js powered operations
|
||||
formatReference: (id: string, style?: string, format?: 'html' | 'text') => string;
|
||||
formatBibliography: (style?: string, format?: 'html' | 'text') => string;
|
||||
parseInput: (input: string) => Promise<CSLReference[]>;
|
||||
exportAs: (format: 'bibtex' | 'ris' | 'json') => string;
|
||||
}
|
||||
|
||||
export const useBibliographyStore = create<BibliographyStore>((set, get) => ({
|
||||
citeInstance: new Cite([]),
|
||||
appMetadata: {},
|
||||
settings: {
|
||||
defaultStyle: 'apa',
|
||||
sortOrder: 'author',
|
||||
},
|
||||
|
||||
getReferences: () => {
|
||||
const csl = get().citeInstance.data as CSLReference[];
|
||||
const metadata = get().appMetadata;
|
||||
|
||||
// Merge CSL data with app metadata
|
||||
return csl.map(ref => ({
|
||||
...ref,
|
||||
_app: metadata[ref.id],
|
||||
}));
|
||||
},
|
||||
|
||||
getReferenceById: (id) => {
|
||||
const refs = get().getReferences();
|
||||
return refs.find(ref => ref.id === id);
|
||||
},
|
||||
|
||||
getCSLData: () => {
|
||||
return get().citeInstance.data as CSLReference[];
|
||||
},
|
||||
|
||||
addReference: (ref) => {
|
||||
const { citeInstance } = get();
|
||||
|
||||
// Generate ID if not provided
|
||||
const id = ref.id || `ref-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
const fullRef = { ...ref, id };
|
||||
|
||||
// Use citation.js add() method
|
||||
citeInstance.add(fullRef);
|
||||
|
||||
// Add app metadata
|
||||
const now = new Date().toISOString();
|
||||
set(state => ({
|
||||
appMetadata: {
|
||||
...state.appMetadata,
|
||||
[id]: {
|
||||
id,
|
||||
tags: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
return id;
|
||||
},
|
||||
|
||||
updateReference: (id, updates) => {
|
||||
const { citeInstance } = get();
|
||||
const data = citeInstance.data as CSLReference[];
|
||||
|
||||
// Find and update the reference
|
||||
const updatedData = data.map(ref =>
|
||||
ref.id === id ? { ...ref, ...updates } : ref
|
||||
);
|
||||
|
||||
// Use citation.js set() method to replace all data
|
||||
citeInstance.set(updatedData);
|
||||
|
||||
// Update metadata timestamp
|
||||
set(state => ({
|
||||
appMetadata: {
|
||||
...state.appMetadata,
|
||||
[id]: {
|
||||
...state.appMetadata[id],
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
deleteReference: (id) => {
|
||||
const { citeInstance } = get();
|
||||
const data = citeInstance.data as CSLReference[];
|
||||
|
||||
// Remove from citation.js
|
||||
const filteredData = data.filter(ref => ref.id !== id);
|
||||
citeInstance.set(filteredData);
|
||||
|
||||
// Remove metadata
|
||||
set(state => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { [id]: _removed, ...rest } = state.appMetadata;
|
||||
return { appMetadata: rest };
|
||||
});
|
||||
},
|
||||
|
||||
duplicateReference: (id) => {
|
||||
const original = get().getReferenceById(id);
|
||||
if (!original) return '';
|
||||
|
||||
const newId = `ref-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
const duplicate = {
|
||||
...original,
|
||||
id: newId,
|
||||
title: `${original.title} (Copy)`,
|
||||
};
|
||||
|
||||
// Remove _app before adding to Cite
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { _app, ...cslData } = duplicate;
|
||||
get().addReference(cslData);
|
||||
|
||||
return newId;
|
||||
},
|
||||
|
||||
setReferences: (refs) => {
|
||||
const { citeInstance } = get();
|
||||
|
||||
// Use citation.js set() to replace all references
|
||||
citeInstance.set(refs);
|
||||
|
||||
// Initialize metadata for new references
|
||||
const metadata: Record<string, ReferenceAppMetadata> = {};
|
||||
const now = new Date().toISOString();
|
||||
|
||||
refs.forEach(ref => {
|
||||
metadata[ref.id] = {
|
||||
id: ref.id,
|
||||
tags: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
});
|
||||
|
||||
set({ appMetadata: metadata });
|
||||
},
|
||||
|
||||
importReferences: (refs) => {
|
||||
const { citeInstance } = get();
|
||||
|
||||
// Use citation.js add() to append references
|
||||
citeInstance.add(refs);
|
||||
|
||||
// Add metadata for new references
|
||||
const now = new Date().toISOString();
|
||||
set(state => {
|
||||
const newMetadata = { ...state.appMetadata };
|
||||
refs.forEach(ref => {
|
||||
if (!newMetadata[ref.id]) {
|
||||
newMetadata[ref.id] = {
|
||||
id: ref.id,
|
||||
tags: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
});
|
||||
return { appMetadata: newMetadata };
|
||||
});
|
||||
},
|
||||
|
||||
clearAll: () => {
|
||||
get().citeInstance.reset(); // citation.js method to clear all data
|
||||
set({ appMetadata: {} });
|
||||
},
|
||||
|
||||
updateMetadata: (id, updates) => {
|
||||
set(state => ({
|
||||
appMetadata: {
|
||||
...state.appMetadata,
|
||||
[id]: {
|
||||
...state.appMetadata[id],
|
||||
...updates,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
setSettings: (settings) => set({ settings }),
|
||||
|
||||
formatReference: (id, style, format = 'html') => {
|
||||
const { citeInstance, settings } = get();
|
||||
const styleToUse = style || settings.defaultStyle;
|
||||
|
||||
// Find the reference
|
||||
const ref = (citeInstance.data as CSLReference[]).find(r => r.id === id);
|
||||
if (!ref) return '';
|
||||
|
||||
// Create temporary Cite instance for single reference
|
||||
const cite = new Cite(ref);
|
||||
|
||||
// Use citation.js format() method
|
||||
return cite.format('bibliography', {
|
||||
format: format,
|
||||
template: styleToUse,
|
||||
lang: 'en-US',
|
||||
});
|
||||
},
|
||||
|
||||
formatBibliography: (style, format = 'html') => {
|
||||
const { citeInstance, settings } = get();
|
||||
const styleToUse = style || settings.defaultStyle;
|
||||
|
||||
// Use citation.js format() method on entire instance
|
||||
return citeInstance.format('bibliography', {
|
||||
format: format,
|
||||
template: styleToUse,
|
||||
lang: 'en-US',
|
||||
});
|
||||
},
|
||||
|
||||
parseInput: async (input) => {
|
||||
try {
|
||||
// Use citation.js async parsing
|
||||
// This handles DOIs, URLs, BibTeX, RIS, etc. automatically
|
||||
const cite = await Cite.async(input);
|
||||
return cite.data as CSLReference[];
|
||||
} catch (error) {
|
||||
console.error('Failed to parse input:', error);
|
||||
throw new Error('Could not parse citation data');
|
||||
}
|
||||
},
|
||||
|
||||
exportAs: (format) => {
|
||||
const { citeInstance } = get();
|
||||
|
||||
// Use citation.js format() method
|
||||
switch (format) {
|
||||
case 'bibtex':
|
||||
return citeInstance.format('bibtex');
|
||||
case 'ris':
|
||||
return citeInstance.format('ris');
|
||||
case 'json':
|
||||
return JSON.stringify(citeInstance.data, null, 2);
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
// Clear bibliography when switching documents
|
||||
export const clearBibliographyForDocumentSwitch = () => {
|
||||
useBibliographyStore.setState({
|
||||
citeInstance: new Cite([]),
|
||||
appMetadata: {},
|
||||
settings: {
|
||||
defaultStyle: 'apa',
|
||||
sortOrder: 'author',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import type { ActorData, RelationData, NodeTypeConfig, EdgeTypeConfig, LabelConfig } from '../../types';
|
||||
import type { ConstellationState } from '../../types/timeline';
|
||||
import type { Bibliography } from '../../types/bibliography';
|
||||
|
||||
/**
|
||||
* Persistence Types
|
||||
|
|
@ -44,6 +45,8 @@ export interface ConstellationDocument {
|
|||
edgeTypes: EdgeTypeConfig[];
|
||||
// Global labels for the entire document (optional for backward compatibility)
|
||||
labels?: LabelConfig[];
|
||||
// Global bibliography for the entire document (optional for backward compatibility)
|
||||
bibliography?: Bibliography;
|
||||
// Timeline with multiple states - every document has this
|
||||
// The graph is stored within each state (nodes and edges only, not types)
|
||||
timeline: {
|
||||
|
|
|
|||
|
|
@ -26,8 +26,12 @@ import {
|
|||
import { useToastStore } from './toastStore';
|
||||
import { useTimelineStore } from './timelineStore';
|
||||
import { useGraphStore } from './graphStore';
|
||||
import { useBibliographyStore } from './bibliographyStore';
|
||||
import type { ConstellationState, Timeline } from '../types/timeline';
|
||||
import { getCurrentGraphFromDocument } from './persistence/loader';
|
||||
// @ts-expect-error - citation.js doesn't have TypeScript definitions
|
||||
import { Cite } from '@citation-js/core';
|
||||
import type { CSLReference } from '../types/bibliography';
|
||||
|
||||
/**
|
||||
* Workspace Store
|
||||
|
|
@ -91,6 +95,19 @@ function initializeWorkspace(): Workspace {
|
|||
if (doc.timeline) {
|
||||
useTimelineStore.getState().loadTimeline(savedState.activeDocumentId, doc.timeline as unknown as Timeline);
|
||||
}
|
||||
|
||||
// Load bibliography into bibliographyStore
|
||||
const bibliographyStore = useBibliographyStore.getState();
|
||||
if (doc.bibliography) {
|
||||
bibliographyStore.citeInstance = new Cite(doc.bibliography.references);
|
||||
bibliographyStore.appMetadata = doc.bibliography.metadata;
|
||||
bibliographyStore.settings = doc.bibliography.settings;
|
||||
} else {
|
||||
// Initialize empty bibliography if not present (backward compatibility)
|
||||
bibliographyStore.citeInstance = new Cite([]);
|
||||
bibliographyStore.appMetadata = {};
|
||||
bibliographyStore.settings = { defaultStyle: 'apa', sortOrder: 'author' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -144,6 +161,11 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
|
|||
newDoc.metadata.documentId = documentId;
|
||||
newDoc.metadata.title = title;
|
||||
newDoc.labels = []; // Initialize with empty labels
|
||||
newDoc.bibliography = { // Initialize with empty bibliography
|
||||
references: [],
|
||||
metadata: {},
|
||||
settings: { defaultStyle: 'apa', sortOrder: 'author' },
|
||||
};
|
||||
|
||||
const metadata: DocumentMetadata = {
|
||||
id: documentId,
|
||||
|
|
@ -223,6 +245,11 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
|
|||
newDoc.metadata.documentId = documentId;
|
||||
newDoc.metadata.title = title;
|
||||
newDoc.labels = sourceDoc.labels || []; // Copy labels from source document
|
||||
newDoc.bibliography = { // Initialize with empty bibliography (don't copy bibliography from template)
|
||||
references: [],
|
||||
metadata: {},
|
||||
settings: sourceDoc.bibliography?.settings || { defaultStyle: 'apa', sortOrder: 'author' },
|
||||
};
|
||||
|
||||
const metadata: DocumentMetadata = {
|
||||
id: documentId,
|
||||
|
|
@ -291,6 +318,19 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
|
|||
useTimelineStore.getState().loadTimeline(documentId, doc.timeline as unknown as Timeline);
|
||||
}
|
||||
|
||||
// Load bibliography into bibliographyStore
|
||||
const bibliographyStore = useBibliographyStore.getState();
|
||||
if (doc.bibliography) {
|
||||
bibliographyStore.citeInstance = new Cite(doc.bibliography.references);
|
||||
bibliographyStore.appMetadata = doc.bibliography.metadata;
|
||||
bibliographyStore.settings = doc.bibliography.settings;
|
||||
} else {
|
||||
// Initialize empty bibliography if not present (backward compatibility)
|
||||
bibliographyStore.citeInstance = new Cite([]);
|
||||
bibliographyStore.appMetadata = {};
|
||||
bibliographyStore.settings = { defaultStyle: 'apa', sortOrder: 'author' };
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const newDocuments = new Map(state.documents);
|
||||
newDocuments.set(documentId, doc);
|
||||
|
|
@ -459,6 +499,16 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
|
|||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
// Deep copy bibliography to avoid shared references
|
||||
bibliography: sourceDoc.bibliography ? {
|
||||
references: JSON.parse(JSON.stringify(sourceDoc.bibliography.references)),
|
||||
metadata: JSON.parse(JSON.stringify(sourceDoc.bibliography.metadata)),
|
||||
settings: { ...sourceDoc.bibliography.settings },
|
||||
} : {
|
||||
references: [],
|
||||
metadata: {},
|
||||
settings: { defaultStyle: 'apa', sortOrder: 'author' },
|
||||
},
|
||||
};
|
||||
|
||||
const metadata: DocumentMetadata = {
|
||||
|
|
@ -574,6 +624,19 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
|
|||
// This preserves all timeline states, not just the current one
|
||||
useTimelineStore.getState().loadTimeline(documentId, importedDoc.timeline as unknown as Timeline);
|
||||
|
||||
// Load bibliography into bibliographyStore
|
||||
const bibliographyStore = useBibliographyStore.getState();
|
||||
if (importedDoc.bibliography) {
|
||||
bibliographyStore.citeInstance = new Cite(importedDoc.bibliography.references);
|
||||
bibliographyStore.appMetadata = importedDoc.bibliography.metadata;
|
||||
bibliographyStore.settings = importedDoc.bibliography.settings;
|
||||
} else {
|
||||
// Initialize empty bibliography if not present (backward compatibility)
|
||||
bibliographyStore.citeInstance = new Cite([]);
|
||||
bibliographyStore.appMetadata = {};
|
||||
bibliographyStore.settings = { defaultStyle: 'apa', sortOrder: 'author' };
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const newDocuments = new Map(state.documents);
|
||||
newDocuments.set(documentId, importedDoc);
|
||||
|
|
@ -641,6 +704,14 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
|
|||
};
|
||||
}
|
||||
|
||||
// Ensure bibliography is up-to-date before exporting
|
||||
const bibliographyStore = useBibliographyStore.getState();
|
||||
doc.bibliography = {
|
||||
references: bibliographyStore.citeInstance.data as CSLReference[],
|
||||
metadata: bibliographyStore.appMetadata,
|
||||
settings: bibliographyStore.settings,
|
||||
};
|
||||
|
||||
// Export the complete document with all timeline states
|
||||
exportDocumentToFile(doc);
|
||||
useToastStore.getState().showToast('Document exported successfully', 'success');
|
||||
|
|
@ -740,6 +811,14 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
|
|||
};
|
||||
}
|
||||
|
||||
// Save bibliography from bibliographyStore
|
||||
const bibliographyStore = useBibliographyStore.getState();
|
||||
doc.bibliography = {
|
||||
references: bibliographyStore.citeInstance.data as CSLReference[],
|
||||
metadata: bibliographyStore.appMetadata,
|
||||
settings: bibliographyStore.settings,
|
||||
};
|
||||
|
||||
saveDocumentToStorage(documentId, doc);
|
||||
|
||||
const metadata = state.documentMetadata.get(documentId);
|
||||
|
|
|
|||
100
src/types/bibliography.ts
Normal file
100
src/types/bibliography.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
/**
|
||||
* Bibliography Types
|
||||
*
|
||||
* Type definitions for the bibliography/reference management system.
|
||||
* Based on CSL-JSON format (Citation Style Language) for compatibility with citation.js
|
||||
*/
|
||||
|
||||
// Standard CSL-JSON reference types
|
||||
export type ReferenceType =
|
||||
| 'article-journal'
|
||||
| 'article-magazine'
|
||||
| 'article-newspaper'
|
||||
| 'book'
|
||||
| 'chapter'
|
||||
| 'paper-conference'
|
||||
| 'report'
|
||||
| 'thesis'
|
||||
| 'webpage'
|
||||
| 'interview'
|
||||
| 'manuscript'
|
||||
| 'personal_communication'
|
||||
| 'entry-encyclopedia'
|
||||
| 'entry-dictionary';
|
||||
|
||||
/**
|
||||
* Standard CSL-JSON reference structure
|
||||
* This is what citation.js expects and produces
|
||||
*/
|
||||
export interface CSLReference {
|
||||
id: string;
|
||||
type: ReferenceType;
|
||||
title?: string;
|
||||
author?: Array<{ family?: string; given?: string; literal?: string }>;
|
||||
issued?: { 'date-parts': [[number, number?, number?]] };
|
||||
'container-title'?: string;
|
||||
publisher?: string;
|
||||
'publisher-place'?: string;
|
||||
volume?: string | number;
|
||||
issue?: string | number;
|
||||
page?: string;
|
||||
DOI?: string;
|
||||
ISBN?: string;
|
||||
ISSN?: string;
|
||||
URL?: string;
|
||||
PMID?: string;
|
||||
abstract?: string;
|
||||
note?: string;
|
||||
keyword?: string;
|
||||
accessed?: { 'date-parts': [[number, number?, number?]] };
|
||||
interviewer?: Array<{ family?: string; given?: string; literal?: string }>;
|
||||
interviewee?: Array<{ family?: string; given?: string; literal?: string }>;
|
||||
'container-author'?: Array<{ family?: string; given?: string; literal?: string }>;
|
||||
'collection-title'?: string;
|
||||
edition?: string | number;
|
||||
genre?: string;
|
||||
// CSL-JSON is extensible - allow additional fields
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* App-specific metadata (NOT part of CSL-JSON)
|
||||
* Stored separately from the Cite instance
|
||||
*/
|
||||
export interface ReferenceAppMetadata {
|
||||
id: string; // Matches CSL reference ID
|
||||
tags?: string[];
|
||||
favorite?: boolean;
|
||||
color?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Combined reference for UI display
|
||||
* Merges CSL data + app metadata
|
||||
*/
|
||||
export interface BibliographyReference extends CSLReference {
|
||||
_app?: ReferenceAppMetadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bibliography settings per document
|
||||
*/
|
||||
export interface BibliographySettings {
|
||||
defaultStyle: string; // CSL style ID (e.g., "apa", "chicago")
|
||||
sortOrder: 'author' | 'year' | 'title';
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete bibliography data structure for persistence
|
||||
*/
|
||||
export interface Bibliography {
|
||||
// CSL-JSON array (can be loaded directly into Cite)
|
||||
references: CSLReference[];
|
||||
// App-specific metadata (stored separately)
|
||||
metadata: Record<string, ReferenceAppMetadata>;
|
||||
// Settings
|
||||
settings: BibliographySettings;
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ export interface ActorData {
|
|||
type: string;
|
||||
description?: string;
|
||||
labels?: string[]; // Array of LabelConfig IDs
|
||||
citations?: string[]; // Array of bibliography reference IDs
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
|
|
@ -20,6 +21,7 @@ export interface RelationData {
|
|||
directionality?: EdgeDirectionality;
|
||||
strength?: number;
|
||||
labels?: string[]; // Array of LabelConfig IDs
|
||||
citations?: string[]; // Array of bibliography reference IDs
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
|
|
|
|||
88
src/utils/bibliography/formatting.ts
Normal file
88
src/utils/bibliography/formatting.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
// @ts-expect-error - citation.js doesn't have TypeScript definitions
|
||||
import { Cite } from '@citation-js/core';
|
||||
import type { CSLReference } from '../../types/bibliography';
|
||||
|
||||
/**
|
||||
* Format a single reference using citation.js
|
||||
*/
|
||||
export const formatReference = (
|
||||
ref: CSLReference,
|
||||
style: string = 'apa',
|
||||
format: 'html' | 'text' = 'text'
|
||||
): string => {
|
||||
try {
|
||||
const cite = new Cite(ref);
|
||||
return cite.format('bibliography', {
|
||||
format,
|
||||
template: style,
|
||||
lang: 'en-US',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Formatting error:', error);
|
||||
return `[Error formatting reference: ${ref.title}]`;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Format short citation for lists (Author, Year)
|
||||
* Uses citation.js citation format
|
||||
*/
|
||||
export const formatShortCitation = (
|
||||
ref: CSLReference,
|
||||
style: string = 'apa'
|
||||
): string => {
|
||||
try {
|
||||
const cite = new Cite(ref);
|
||||
// Use citation format (in-text) instead of bibliography
|
||||
return cite.format('citation', {
|
||||
format: 'text',
|
||||
template: style,
|
||||
lang: 'en-US',
|
||||
});
|
||||
} catch (error) {
|
||||
// Fallback to simple format
|
||||
const author = ref.author?.[0];
|
||||
const authorStr = author?.family || author?.literal || 'Unknown';
|
||||
const year = ref.issued?.['date-parts']?.[0]?.[0] || 'n.d.';
|
||||
return `${authorStr} (${year})`;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Format full bibliography for multiple references
|
||||
*/
|
||||
export const formatBibliography = (
|
||||
refs: CSLReference[],
|
||||
style: string = 'apa',
|
||||
format: 'html' | 'text' = 'html'
|
||||
): string => {
|
||||
try {
|
||||
const cite = new Cite(refs);
|
||||
return cite.format('bibliography', {
|
||||
format,
|
||||
template: style,
|
||||
lang: 'en-US',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Bibliography formatting error:', error);
|
||||
return '[Error formatting bibliography]';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get list of available citation styles
|
||||
* Citation.js supports 10,000+ styles via CSL
|
||||
*/
|
||||
export const getAvailableStyles = (): Array<{ id: string; label: string }> => {
|
||||
// Common styles for social sciences
|
||||
return [
|
||||
{ id: 'apa', label: 'APA 7th Edition' },
|
||||
{ id: 'chicago-author-date', label: 'Chicago Author-Date' },
|
||||
{ id: 'chicago-note-bibliography', label: 'Chicago Notes' },
|
||||
{ id: 'mla', label: 'MLA 9th Edition' },
|
||||
{ id: 'harvard1', label: 'Harvard' },
|
||||
{ id: 'vancouver', label: 'Vancouver' },
|
||||
{ id: 'american-sociological-association', label: 'ASA' },
|
||||
{ id: 'american-political-science-association', label: 'APSA' },
|
||||
];
|
||||
};
|
||||
108
src/utils/bibliography/import-export.ts
Normal file
108
src/utils/bibliography/import-export.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
// @ts-expect-error - citation.js doesn't have TypeScript definitions
|
||||
import { Cite } from '@citation-js/core';
|
||||
import type { CSLReference } from '../../types/bibliography';
|
||||
|
||||
/**
|
||||
* Import from various formats using citation.js
|
||||
*/
|
||||
export const importFromFile = async (
|
||||
content: string,
|
||||
format: 'bibtex' | 'ris' | 'json'
|
||||
): Promise<CSLReference[]> => {
|
||||
try {
|
||||
const cite = await Cite.async(content);
|
||||
return cite.data as CSLReference[];
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to import ${format}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Export to various formats using citation.js
|
||||
*/
|
||||
export const exportToFormat = (
|
||||
refs: CSLReference[],
|
||||
format: 'bibtex' | 'ris' | 'json'
|
||||
): string => {
|
||||
try {
|
||||
const cite = new Cite(refs);
|
||||
|
||||
switch (format) {
|
||||
case 'bibtex':
|
||||
return cite.format('bibtex');
|
||||
case 'ris':
|
||||
return cite.format('ris');
|
||||
case 'json':
|
||||
return JSON.stringify(cite.data, null, 2);
|
||||
default:
|
||||
throw new Error(`Unsupported format: ${format}`);
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to export: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Export formatted bibliography as HTML
|
||||
*/
|
||||
export const exportFormattedBibliography = (
|
||||
refs: CSLReference[],
|
||||
style: string = 'apa'
|
||||
): string => {
|
||||
try {
|
||||
const cite = new Cite(refs);
|
||||
const bibliography = cite.format('bibliography', {
|
||||
format: 'html',
|
||||
template: style,
|
||||
lang: 'en-US',
|
||||
});
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Bibliography</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Times New Roman', serif;
|
||||
max-width: 800px;
|
||||
margin: 2em auto;
|
||||
line-height: 1.6;
|
||||
}
|
||||
h1 { text-align: center; }
|
||||
.csl-entry {
|
||||
margin-bottom: 1em;
|
||||
padding-left: 2em;
|
||||
text-indent: -2em;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Bibliography</h1>
|
||||
${bibliography}
|
||||
</body>
|
||||
</html>`;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to generate HTML: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Detect format of input string
|
||||
*/
|
||||
export const detectFormat = (content: string): 'bibtex' | 'ris' | 'json' | 'unknown' => {
|
||||
const trimmed = content.trim();
|
||||
|
||||
if (trimmed.startsWith('@')) return 'bibtex';
|
||||
if (trimmed.startsWith('TY -')) return 'ris';
|
||||
if (trimmed.startsWith('[') || trimmed.startsWith('{')) {
|
||||
try {
|
||||
JSON.parse(trimmed);
|
||||
return 'json';
|
||||
} catch {
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
};
|
||||
56
src/utils/bibliography/smart-parser.ts
Normal file
56
src/utils/bibliography/smart-parser.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
// @ts-expect-error - citation.js doesn't have TypeScript definitions
|
||||
import { Cite } from '@citation-js/core';
|
||||
import type { CSLReference } from '../../types/bibliography';
|
||||
|
||||
/**
|
||||
* Parse any citation input using citation.js
|
||||
* Handles: DOI, URL, BibTeX, RIS, ISBN, PubMed ID, etc.
|
||||
*/
|
||||
export const parseSmartInput = async (input: string): Promise<CSLReference[]> => {
|
||||
try {
|
||||
const cite = await Cite.async(input);
|
||||
return cite.data as CSLReference[];
|
||||
} catch (error) {
|
||||
throw new Error(`Could not parse input: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if input looks like it can be parsed
|
||||
*/
|
||||
export const isValidCitationInput = (input: string): boolean => {
|
||||
const trimmed = input.trim();
|
||||
|
||||
// DOI patterns
|
||||
if (/^(https?:\/\/)?(dx\.)?doi\.org\/10\.\d{4,}/i.test(trimmed)) return true;
|
||||
if (/^10\.\d{4,}\/.+/.test(trimmed)) return true;
|
||||
|
||||
// URL patterns
|
||||
if (/^https?:\/\/.+/.test(trimmed)) return true;
|
||||
|
||||
// BibTeX patterns
|
||||
if (/^@\w+\{/.test(trimmed)) return true;
|
||||
|
||||
// PubMed ID
|
||||
if (/^PMID:\s*\d+/i.test(trimmed)) return true;
|
||||
|
||||
// ISBN
|
||||
if (/^ISBN[:\s]*[\d-]+/i.test(trimmed)) return true;
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get input type hint for user
|
||||
*/
|
||||
export const getInputTypeHint = (input: string): string => {
|
||||
const trimmed = input.trim();
|
||||
|
||||
if (/^10\.\d{4,}\//.test(trimmed)) return 'DOI';
|
||||
if (/^https?:\/\//.test(trimmed)) return 'URL';
|
||||
if (/^@\w+\{/.test(trimmed)) return 'BibTeX';
|
||||
if (/^PMID:/i.test(trimmed)) return 'PubMed ID';
|
||||
if (/^ISBN/i.test(trimmed)) return 'ISBN';
|
||||
|
||||
return 'Unknown';
|
||||
};
|
||||
Loading…
Reference in a new issue