commit
82017525d3
|
|
@ -1006,24 +1006,103 @@ async def search(q: str):
|
||||||
|
|
||||||
@api_router.get("/graph")
|
@api_router.get("/graph")
|
||||||
async def get_graph():
|
async def get_graph():
|
||||||
"""Get graph data for note visualization (currently only returns nodes, link detection not implemented)"""
|
"""Get graph data for note visualization with wikilink detection"""
|
||||||
try:
|
try:
|
||||||
|
import re
|
||||||
notes = get_all_notes(config['storage']['notes_dir'])
|
notes = get_all_notes(config['storage']['notes_dir'])
|
||||||
nodes = []
|
nodes = []
|
||||||
edges = []
|
edges = []
|
||||||
|
|
||||||
# Build graph structure - only nodes for now
|
# Build set of valid note names/paths for matching
|
||||||
|
note_paths = set()
|
||||||
|
note_paths_lower = {} # Map lowercase path -> actual path for case-insensitive matching
|
||||||
|
note_names = {} # Map name -> path for quick lookup
|
||||||
|
|
||||||
for note in notes:
|
for note in notes:
|
||||||
if note.get('type') == 'note': # Only include actual notes
|
if note.get('type') == 'note':
|
||||||
|
note_paths.add(note['path'])
|
||||||
|
note_paths.add(note['path'].replace('.md', ''))
|
||||||
|
# Store lowercase path -> actual path mapping for case-insensitive matching
|
||||||
|
note_paths_lower[note['path'].lower()] = note['path']
|
||||||
|
note_paths_lower[note['path'].replace('.md', '').lower()] = note['path']
|
||||||
|
# Store name -> path mapping (without extension)
|
||||||
|
name = note['name'].replace('.md', '')
|
||||||
|
note_names[name.lower()] = note['path']
|
||||||
|
note_names[note['name'].lower()] = note['path']
|
||||||
|
|
||||||
|
# Build graph structure with link detection
|
||||||
|
for note in notes:
|
||||||
|
if note.get('type') == 'note':
|
||||||
nodes.append({
|
nodes.append({
|
||||||
"id": note['path'],
|
"id": note['path'],
|
||||||
"label": note['name']
|
"label": note['name'].replace('.md', '')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Read note content to find links
|
||||||
|
content = get_note_content(config['storage']['notes_dir'], note['path'])
|
||||||
|
if content:
|
||||||
|
# Find wikilinks: [[target]] or [[target|display]]
|
||||||
|
wikilinks = re.findall(r'\[\[([^\]|]+)(?:\|[^\]]+)?\]\]', content)
|
||||||
|
|
||||||
|
# Find standard markdown internal links: [text](path.md)
|
||||||
|
markdown_links = re.findall(r'\[([^\]]+)\]\(([^\)]+\.md)\)', content)
|
||||||
|
|
||||||
|
# Process wikilinks
|
||||||
|
for target in wikilinks:
|
||||||
|
target = target.strip()
|
||||||
|
target_lower = target.lower()
|
||||||
|
|
||||||
|
# Try to match target to an existing note
|
||||||
|
target_path = None
|
||||||
|
|
||||||
|
# 1. Exact path match
|
||||||
|
if target in note_paths:
|
||||||
|
target_path = target if target.endswith('.md') else target + '.md'
|
||||||
|
# 2. Path with .md extension
|
||||||
|
elif target + '.md' in note_paths:
|
||||||
|
target_path = target + '.md'
|
||||||
|
# 3. Case-insensitive path match (e.g., [[Folder/Note]] -> folder/note.md)
|
||||||
|
elif target_lower in note_paths_lower:
|
||||||
|
target_path = note_paths_lower[target_lower]
|
||||||
|
elif target_lower + '.md' in note_paths_lower:
|
||||||
|
target_path = note_paths_lower[target_lower + '.md']
|
||||||
|
# 4. Just note name (case-insensitive)
|
||||||
|
elif target_lower in note_names:
|
||||||
|
target_path = note_names[target_lower]
|
||||||
|
|
||||||
|
if target_path and target_path != note['path']:
|
||||||
|
edges.append({
|
||||||
|
"source": note['path'],
|
||||||
|
"target": target_path,
|
||||||
|
"type": "wikilink"
|
||||||
|
})
|
||||||
|
|
||||||
|
# Process markdown links
|
||||||
|
for _, link_path in markdown_links:
|
||||||
|
# Try exact match first, then case-insensitive
|
||||||
|
target_path = None
|
||||||
|
if link_path in note_paths:
|
||||||
|
target_path = link_path
|
||||||
|
elif link_path.lower() in note_paths_lower:
|
||||||
|
target_path = note_paths_lower[link_path.lower()]
|
||||||
|
|
||||||
|
if target_path and target_path != note['path']:
|
||||||
|
edges.append({
|
||||||
|
"source": note['path'],
|
||||||
|
"target": target_path,
|
||||||
|
"type": "markdown"
|
||||||
|
})
|
||||||
|
|
||||||
# Note: Link detection between notes could be implemented here using markdown link parsing
|
# Remove duplicate edges
|
||||||
# For now, returning empty edges
|
seen = set()
|
||||||
|
unique_edges = []
|
||||||
|
for edge in edges:
|
||||||
|
key = (edge['source'], edge['target'])
|
||||||
|
if key not in seen:
|
||||||
|
seen.add(key)
|
||||||
|
unique_edges.append(edge)
|
||||||
|
|
||||||
return {"nodes": nodes, "edges": edges}
|
return {"nodes": nodes, "edges": unique_edges}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to generate graph data"))
|
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to generate graph data"))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -271,7 +271,25 @@ GET /api/plugins/note_stats/calculate?content={markdown_content}
|
||||||
```http
|
```http
|
||||||
GET /api/graph
|
GET /api/graph
|
||||||
```
|
```
|
||||||
Returns the relationship graph between notes (internal links).
|
Returns the relationship graph between notes with link detection.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"nodes": [
|
||||||
|
{ "id": "folder/note.md", "label": "note" },
|
||||||
|
{ "id": "another.md", "label": "another" }
|
||||||
|
],
|
||||||
|
"edges": [
|
||||||
|
{ "source": "folder/note.md", "target": "another.md", "type": "wikilink" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Link Detection:**
|
||||||
|
- **Wikilinks** - `[[note]]` or `[[note|display text]]` syntax (Obsidian-style)
|
||||||
|
- **Markdown links** - `[text](note.md)` standard internal links
|
||||||
|
- **Edge types** - `"wikilink"` or `"markdown"` to distinguish link source
|
||||||
|
|
||||||
## ⚙️ System
|
## ⚙️ System
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,10 @@
|
||||||
## 🔗 Linking & Discovery
|
## 🔗 Linking & Discovery
|
||||||
|
|
||||||
### Internal Links
|
### Internal Links
|
||||||
- **Markdown links** - `[Note Name](note.md)` syntax
|
- **Wikilinks** - `[[Note Name]]` Obsidian-style syntax for quick linking
|
||||||
|
- **Wikilinks with display text** - `[[Note Name|Click here]]` to customize link text
|
||||||
|
- **Broken link detection** - Non-existent note links shown dimmed
|
||||||
|
- **Markdown links** - `[Note Name](note.md)` standard syntax also supported
|
||||||
- **Drag to link** - Drag notes or images into the editor to insert links
|
- **Drag to link** - Drag notes or images into the editor to insert links
|
||||||
- **Click to navigate** - Jump between notes seamlessly
|
- **Click to navigate** - Jump between notes seamlessly
|
||||||
- **External links** - Open in new tabs automatically
|
- **External links** - Open in new tabs automatically
|
||||||
|
|
@ -62,7 +65,8 @@
|
||||||
- **Reading time** - Estimated minutes to read
|
- **Reading time** - Estimated minutes to read
|
||||||
- **Line count** - Total lines in note
|
- **Line count** - Total lines in note
|
||||||
- **Image count** - Track embedded images
|
- **Image count** - Track embedded images
|
||||||
- **Link count** - Internal and external links
|
- **Link count** - Internal and external links (includes wikilinks)
|
||||||
|
- **Wikilink count** - Separate count for `[[wikilinks]]`
|
||||||
- **Expandable panel** - Toggle stats visibility
|
- **Expandable panel** - Toggle stats visibility
|
||||||
|
|
||||||
## 🔌 Plugin System
|
## 🔌 Plugin System
|
||||||
|
|
|
||||||
|
|
@ -54,9 +54,10 @@ curl -X POST http://localhost:8000/api/plugins/note_stats/toggle \
|
||||||
- Displayed in minutes
|
- Displayed in minutes
|
||||||
|
|
||||||
### 🔗 Links & References
|
### 🔗 Links & References
|
||||||
- **Total Links** - All `[text](url)` links
|
- **Total Links** - All links (markdown + wikilinks)
|
||||||
- **Internal Links** - Links to other notes (`.md` files)
|
- **Internal Links** - Links to other notes (`.md` files and wikilinks)
|
||||||
- **External Links** - HTTP/HTTPS URLs
|
- **External Links** - HTTP/HTTPS URLs
|
||||||
|
- **Wikilinks** - `[[note]]` and `[[note|display]]` count
|
||||||
- **Images** - `` count
|
- **Images** - `` count
|
||||||
|
|
||||||
### 💻 Code Blocks
|
### 💻 Code Blocks
|
||||||
|
|
@ -191,7 +192,8 @@ Find orphaned notes or track reference density.
|
||||||
|
|
||||||
**Words:** Split on whitespace, excluding code blocks
|
**Words:** Split on whitespace, excluding code blocks
|
||||||
**Reading Time:** `words / 200 minutes`
|
**Reading Time:** `words / 200 minutes`
|
||||||
**Links:** Regex match `[text](url)` markdown links
|
**Links:** Regex match `[text](url)` markdown links + `[[wikilinks]]`
|
||||||
|
**Wikilinks:** Regex match `[[target]]` and `[[target|display]]` (Obsidian-style)
|
||||||
**Tasks:** Match `- [ ]` and `- [x]`
|
**Tasks:** Match `- [ ]` and `- [x]`
|
||||||
**Code Blocks:** Match ` ```language ` ` fences
|
**Code Blocks:** Match ` ```language ` ` fences
|
||||||
**Headings:** Match `#`, `##`, `###` at line start
|
**Headings:** Match `#`, `##`, `###` at line start
|
||||||
|
|
|
||||||
|
|
@ -1495,17 +1495,35 @@ function noteApp() {
|
||||||
// Skip if it's just an anchor link
|
// Skip if it's just an anchor link
|
||||||
if (!notePath) return;
|
if (!notePath) return;
|
||||||
|
|
||||||
// Find the note by path
|
// Find the note by path (try exact match first, then with .md extension)
|
||||||
const note = this.notes.find(n => n.path === notePath);
|
const note = this.notes.find(n =>
|
||||||
|
n.path === notePath ||
|
||||||
|
n.path === notePath + '.md'
|
||||||
|
);
|
||||||
if (note) {
|
if (note) {
|
||||||
this.loadNote(notePath);
|
this.loadNote(note.path);
|
||||||
} else {
|
} else {
|
||||||
// Try to find by name (in case link uses just the note name)
|
// Try to find by name (in case link uses just the note name without path)
|
||||||
const noteByName = this.notes.find(n => n.name === notePath || n.name === notePath + '.md');
|
const noteByName = this.notes.find(n =>
|
||||||
|
n.name === notePath ||
|
||||||
|
n.name === notePath + '.md' ||
|
||||||
|
// Also match by filename at end of path (case-insensitive)
|
||||||
|
n.name.toLowerCase() === notePath.toLowerCase() ||
|
||||||
|
n.name.toLowerCase() === (notePath + '.md').toLowerCase()
|
||||||
|
);
|
||||||
if (noteByName) {
|
if (noteByName) {
|
||||||
this.loadNote(noteByName.path);
|
this.loadNote(noteByName.path);
|
||||||
} else {
|
} else {
|
||||||
alert(`Note not found: ${notePath}`);
|
// Last resort: case-insensitive path matching
|
||||||
|
const noteByPathCI = this.notes.find(n =>
|
||||||
|
n.path.toLowerCase() === notePath.toLowerCase() ||
|
||||||
|
n.path.toLowerCase() === (notePath + '.md').toLowerCase()
|
||||||
|
);
|
||||||
|
if (noteByPathCI) {
|
||||||
|
this.loadNote(noteByPathCI.path);
|
||||||
|
} else {
|
||||||
|
alert(`Note not found: ${notePath}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -2638,6 +2656,50 @@ function noteApp() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convert Obsidian-style wikilinks: [[note]] or [[note|display text]]
|
||||||
|
// Must be done before marked.parse() to avoid conflicts with markdown syntax
|
||||||
|
const notes = this.notes; // Reference for closure
|
||||||
|
contentToRender = contentToRender.replace(
|
||||||
|
/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g,
|
||||||
|
(match, target, displayText) => {
|
||||||
|
const linkTarget = target.trim();
|
||||||
|
const linkText = displayText ? displayText.trim() : linkTarget;
|
||||||
|
const linkTargetLower = linkTarget.toLowerCase();
|
||||||
|
|
||||||
|
// Check if note exists (by path or by name, case-insensitive)
|
||||||
|
const noteExists = notes.some(n => {
|
||||||
|
const pathLower = n.path.toLowerCase();
|
||||||
|
const nameLower = n.name.toLowerCase();
|
||||||
|
return (
|
||||||
|
// Exact path match
|
||||||
|
n.path === linkTarget ||
|
||||||
|
n.path === linkTarget + '.md' ||
|
||||||
|
// Case-insensitive path match
|
||||||
|
pathLower === linkTargetLower ||
|
||||||
|
pathLower === linkTargetLower + '.md' ||
|
||||||
|
// Name match (with or without .md)
|
||||||
|
n.name === linkTarget ||
|
||||||
|
n.name === linkTarget + '.md' ||
|
||||||
|
nameLower === linkTargetLower ||
|
||||||
|
nameLower === linkTargetLower + '.md' ||
|
||||||
|
// Match by filename at end of path
|
||||||
|
n.path.endsWith('/' + linkTarget) ||
|
||||||
|
n.path.endsWith('/' + linkTarget + '.md') ||
|
||||||
|
pathLower.endsWith('/' + linkTargetLower) ||
|
||||||
|
pathLower.endsWith('/' + linkTargetLower + '.md')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Escape special chars: href needs quote escaping, text needs HTML escaping
|
||||||
|
const safeHref = linkTarget.replace(/"/g, '%22');
|
||||||
|
const safeText = linkText.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
|
||||||
|
// Return link with data attribute for styling broken links
|
||||||
|
const brokenClass = noteExists ? '' : ' class="wikilink-broken"';
|
||||||
|
return `<a href="${safeHref}"${brokenClass} data-wikilink="true">${safeText}</a>`;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// Configure marked with syntax highlighting
|
// Configure marked with syntax highlighting
|
||||||
marked.setOptions({
|
marked.setOptions({
|
||||||
breaks: true,
|
breaks: true,
|
||||||
|
|
@ -2971,10 +3033,17 @@ function noteApp() {
|
||||||
// Tables: markdown table separator rows (| --- | --- |)
|
// Tables: markdown table separator rows (| --- | --- |)
|
||||||
const tables = (content.match(/^\s*\|(?:\s*:?-+:?\s*\|){1,}\s*$/gm) || []).length;
|
const tables = (content.match(/^\s*\|(?:\s*:?-+:?\s*\|){1,}\s*$/gm) || []).length;
|
||||||
|
|
||||||
// Link count
|
// Link count (standard markdown links)
|
||||||
const linkMatches = content.match(/\[([^\]]+)\]\(([^\)]+)\)/g) || [];
|
const markdownLinkMatches = content.match(/\[([^\]]+)\]\(([^\)]+)\)/g) || [];
|
||||||
const links = linkMatches.length;
|
const markdownLinks = markdownLinkMatches.length;
|
||||||
const internalLinks = linkMatches.filter(l => l.includes('.md')).length;
|
const markdownInternalLinks = markdownLinkMatches.filter(l => l.includes('.md')).length;
|
||||||
|
|
||||||
|
// Wikilink count ([[note]] or [[note|display text]] format)
|
||||||
|
const wikilinks = (content.match(/\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g) || []).length;
|
||||||
|
|
||||||
|
// Total links (markdown + wikilinks)
|
||||||
|
const links = markdownLinks + wikilinks;
|
||||||
|
const internalLinks = markdownInternalLinks + wikilinks; // All wikilinks are internal
|
||||||
|
|
||||||
// Code blocks
|
// Code blocks
|
||||||
const codeBlocks = (content.match(/```[\s\S]*?```/g) || []).length;
|
const codeBlocks = (content.match(/```[\s\S]*?```/g) || []).length;
|
||||||
|
|
@ -3010,6 +3079,7 @@ function noteApp() {
|
||||||
links,
|
links,
|
||||||
internal_links: internalLinks,
|
internal_links: internalLinks,
|
||||||
external_links: links - internalLinks,
|
external_links: links - internalLinks,
|
||||||
|
wikilinks,
|
||||||
code_blocks: codeBlocks,
|
code_blocks: codeBlocks,
|
||||||
inline_code: inlineCode,
|
inline_code: inlineCode,
|
||||||
headings: {
|
headings: {
|
||||||
|
|
|
||||||
|
|
@ -266,16 +266,34 @@
|
||||||
text-decoration-color: var(--accent-primary);
|
text-decoration-color: var(--accent-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Internal links (not starting with http/https) get a special style */
|
/* Wikilinks - Obsidian style internal links */
|
||||||
.markdown-preview a:not([href^="http"]):not([href^="https"]):not([href^="//"]):not([href^="mailto:"]) {
|
.markdown-preview a[data-wikilink] {
|
||||||
color: var(--accent-primary);
|
color: var(--link-internal, var(--accent-primary));
|
||||||
|
text-decoration: none;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
background-color: var(--link-internal-bg, transparent);
|
||||||
|
padding: 0 2px;
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: background-color 0.15s, color 0.15s;
|
||||||
}
|
}
|
||||||
.markdown-preview a:not([href^="http"]):not([href^="https"]):not([href^="//"]):not([href^="mailto:"]):before {
|
.markdown-preview a[data-wikilink]:hover {
|
||||||
content: "→ ";
|
background-color: var(--link-internal-hover-bg, var(--bg-tertiary));
|
||||||
font-size: 0.9em;
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Broken wikilinks - note doesn't exist (Obsidian shows these dimmed) */
|
||||||
|
.markdown-preview a[data-wikilink].wikilink-broken {
|
||||||
|
color: var(--link-broken, var(--text-tertiary));
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
.markdown-preview a[data-wikilink].wikilink-broken:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Standard internal links (non-wikilink, e.g. [text](path)) */
|
||||||
|
.markdown-preview a:not([href^="http"]):not([href^="https"]):not([href^="//"]):not([href^="mailto:"]):not([data-wikilink]) {
|
||||||
|
color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
/* Tables */
|
/* Tables */
|
||||||
.markdown-preview table {
|
.markdown-preview table {
|
||||||
|
|
|
||||||
|
|
@ -54,11 +54,18 @@ class Plugin:
|
||||||
re.findall(r'^\s*\|(?:\s*:?-+:?\s*\|){1,}\s*$', content, re.MULTILINE)
|
re.findall(r'^\s*\|(?:\s*:?-+:?\s*\|){1,}\s*$', content, re.MULTILINE)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Markdown link count
|
# Markdown link count (standard [text](url) format)
|
||||||
links = len(re.findall(r'\[([^\]]+)\]\(([^\)]+)\)', content))
|
markdown_links = len(re.findall(r'\[([^\]]+)\]\(([^\)]+)\)', content))
|
||||||
|
|
||||||
# Internal link count (links to .md files)
|
# Internal link count (standard markdown links to .md files)
|
||||||
internal_links = len(re.findall(r'\[([^\]]+)\]\(([^\)]+\.md)\)', content))
|
markdown_internal_links = len(re.findall(r'\[([^\]]+)\]\(([^\)]+\.md)\)', content))
|
||||||
|
|
||||||
|
# Wikilink count ([[note]] or [[note|display text]] format - Obsidian style)
|
||||||
|
wikilinks = len(re.findall(r'\[\[([^\]|]+)(?:\|[^\]]+)?\]\]', content))
|
||||||
|
|
||||||
|
# Total links and internal links
|
||||||
|
links = markdown_links + wikilinks
|
||||||
|
internal_links = markdown_internal_links + wikilinks # All wikilinks are internal
|
||||||
|
|
||||||
# Code block count
|
# Code block count
|
||||||
code_blocks = len(re.findall(r'```[\s\S]*?```', content))
|
code_blocks = len(re.findall(r'```[\s\S]*?```', content))
|
||||||
|
|
@ -95,6 +102,7 @@ class Plugin:
|
||||||
'links': links,
|
'links': links,
|
||||||
'internal_links': internal_links,
|
'internal_links': internal_links,
|
||||||
'external_links': links - internal_links,
|
'external_links': links - internal_links,
|
||||||
|
'wikilinks': wikilinks,
|
||||||
'code_blocks': code_blocks,
|
'code_blocks': code_blocks,
|
||||||
'inline_code': inline_code,
|
'inline_code': inline_code,
|
||||||
'headings': {
|
'headings': {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue