Merge pull request #58 from gamosoft/features/wikilinks

wikilinks
This commit is contained in:
Gamosoft 2025-12-03 16:02:04 +01:00 committed by GitHub
commit 82017525d3
7 changed files with 232 additions and 33 deletions

View File

@ -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"))

View File

@ -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

View File

@ -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

View File

@ -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** - `![alt](image.png)` 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

View File

@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
// 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: {

View File

@ -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 {

View File

@ -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': {