commit
82017525d3
|
|
@ -1006,24 +1006,103 @@ async def search(q: str):
|
|||
|
||||
@api_router.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:
|
||||
import re
|
||||
notes = get_all_notes(config['storage']['notes_dir'])
|
||||
nodes = []
|
||||
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:
|
||||
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({
|
||||
"id": note['path'],
|
||||
"label": note['name']
|
||||
"label": note['name'].replace('.md', '')
|
||||
})
|
||||
|
||||
# Note: Link detection between notes could be implemented here using markdown link parsing
|
||||
# For now, returning empty edges
|
||||
# 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)
|
||||
|
||||
return {"nodes": nodes, "edges": edges}
|
||||
# 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"
|
||||
})
|
||||
|
||||
# Remove duplicate 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": unique_edges}
|
||||
except Exception as e:
|
||||
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
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,10 @@
|
|||
## 🔗 Linking & Discovery
|
||||
|
||||
### 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
|
||||
- **Click to navigate** - Jump between notes seamlessly
|
||||
- **External links** - Open in new tabs automatically
|
||||
|
|
@ -62,7 +65,8 @@
|
|||
- **Reading time** - Estimated minutes to read
|
||||
- **Line count** - Total lines in note
|
||||
- **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
|
||||
|
||||
## 🔌 Plugin System
|
||||
|
|
|
|||
|
|
@ -54,9 +54,10 @@ curl -X POST http://localhost:8000/api/plugins/note_stats/toggle \
|
|||
- Displayed in minutes
|
||||
|
||||
### 🔗 Links & References
|
||||
- **Total Links** - All `[text](url)` links
|
||||
- **Internal Links** - Links to other notes (`.md` files)
|
||||
- **Total Links** - All links (markdown + wikilinks)
|
||||
- **Internal Links** - Links to other notes (`.md` files and wikilinks)
|
||||
- **External Links** - HTTP/HTTPS URLs
|
||||
- **Wikilinks** - `[[note]]` and `[[note|display]]` count
|
||||
- **Images** - `` count
|
||||
|
||||
### 💻 Code Blocks
|
||||
|
|
@ -191,7 +192,8 @@ Find orphaned notes or track reference density.
|
|||
|
||||
**Words:** Split on whitespace, excluding code blocks
|
||||
**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]`
|
||||
**Code Blocks:** Match ` ```language ` ` fences
|
||||
**Headings:** Match `#`, `##`, `###` at line start
|
||||
|
|
|
|||
|
|
@ -1495,19 +1495,37 @@ function noteApp() {
|
|||
// Skip if it's just an anchor link
|
||||
if (!notePath) return;
|
||||
|
||||
// Find the note by path
|
||||
const note = this.notes.find(n => n.path === notePath);
|
||||
// Find the note by path (try exact match first, then with .md extension)
|
||||
const note = this.notes.find(n =>
|
||||
n.path === notePath ||
|
||||
n.path === notePath + '.md'
|
||||
);
|
||||
if (note) {
|
||||
this.loadNote(notePath);
|
||||
this.loadNote(note.path);
|
||||
} else {
|
||||
// Try to find by name (in case link uses just the note name)
|
||||
const noteByName = this.notes.find(n => n.name === notePath || n.name === notePath + '.md');
|
||||
// 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' ||
|
||||
// Also match by filename at end of path (case-insensitive)
|
||||
n.name.toLowerCase() === notePath.toLowerCase() ||
|
||||
n.name.toLowerCase() === (notePath + '.md').toLowerCase()
|
||||
);
|
||||
if (noteByName) {
|
||||
this.loadNote(noteByName.path);
|
||||
} else {
|
||||
// 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Folder drag handlers
|
||||
|
|
@ -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
|
||||
marked.setOptions({
|
||||
breaks: true,
|
||||
|
|
@ -2971,10 +3033,17 @@ function noteApp() {
|
|||
// Tables: markdown table separator rows (| --- | --- |)
|
||||
const tables = (content.match(/^\s*\|(?:\s*:?-+:?\s*\|){1,}\s*$/gm) || []).length;
|
||||
|
||||
// Link count
|
||||
const linkMatches = content.match(/\[([^\]]+)\]\(([^\)]+)\)/g) || [];
|
||||
const links = linkMatches.length;
|
||||
const internalLinks = linkMatches.filter(l => l.includes('.md')).length;
|
||||
// Link count (standard markdown links)
|
||||
const markdownLinkMatches = content.match(/\[([^\]]+)\]\(([^\)]+)\)/g) || [];
|
||||
const markdownLinks = markdownLinkMatches.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
|
||||
const codeBlocks = (content.match(/```[\s\S]*?```/g) || []).length;
|
||||
|
|
@ -3010,6 +3079,7 @@ function noteApp() {
|
|||
links,
|
||||
internal_links: internalLinks,
|
||||
external_links: links - internalLinks,
|
||||
wikilinks,
|
||||
code_blocks: codeBlocks,
|
||||
inline_code: inlineCode,
|
||||
headings: {
|
||||
|
|
|
|||
|
|
@ -266,16 +266,34 @@
|
|||
text-decoration-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
/* Internal links (not starting with http/https) get a special style */
|
||||
.markdown-preview a:not([href^="http"]):not([href^="https"]):not([href^="//"]):not([href^="mailto:"]) {
|
||||
color: var(--accent-primary);
|
||||
/* Wikilinks - Obsidian style internal links */
|
||||
.markdown-preview a[data-wikilink] {
|
||||
color: var(--link-internal, var(--accent-primary));
|
||||
text-decoration: none;
|
||||
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 {
|
||||
content: "→ ";
|
||||
font-size: 0.9em;
|
||||
.markdown-preview a[data-wikilink]:hover {
|
||||
background-color: var(--link-internal-hover-bg, var(--bg-tertiary));
|
||||
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;
|
||||
}
|
||||
.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 */
|
||||
.markdown-preview table {
|
||||
|
|
|
|||
|
|
@ -54,11 +54,18 @@ class Plugin:
|
|||
re.findall(r'^\s*\|(?:\s*:?-+:?\s*\|){1,}\s*$', content, re.MULTILINE)
|
||||
)
|
||||
|
||||
# Markdown link count
|
||||
links = len(re.findall(r'\[([^\]]+)\]\(([^\)]+)\)', content))
|
||||
# Markdown link count (standard [text](url) format)
|
||||
markdown_links = len(re.findall(r'\[([^\]]+)\]\(([^\)]+)\)', content))
|
||||
|
||||
# Internal link count (links to .md files)
|
||||
internal_links = len(re.findall(r'\[([^\]]+)\]\(([^\)]+\.md)\)', content))
|
||||
# Internal link count (standard markdown links to .md files)
|
||||
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_blocks = len(re.findall(r'```[\s\S]*?```', content))
|
||||
|
|
@ -95,6 +102,7 @@ class Plugin:
|
|||
'links': links,
|
||||
'internal_links': internal_links,
|
||||
'external_links': links - internal_links,
|
||||
'wikilinks': wikilinks,
|
||||
'code_blocks': code_blocks,
|
||||
'inline_code': inline_code,
|
||||
'headings': {
|
||||
|
|
|
|||
Loading…
Reference in New Issue