389 lines
11 KiB
Plaintext
389 lines
11 KiB
Plaintext
|
|
#!/usr/bin/env nu
|
||
|
|
# Import from Logseq graph to KOGRAL
|
||
|
|
#
|
||
|
|
# Usage: nu kogral-import-logseq.nu <logseq-path> [--kogral-dir <path>] [--dry-run]
|
||
|
|
|
||
|
|
def main [
|
||
|
|
logseq_path: string # Path to Logseq graph directory
|
||
|
|
--kogral-dir: string = ".kogral" # KOGRAL directory
|
||
|
|
--dry-run # Show what would be imported without making changes
|
||
|
|
--skip-journals # Skip importing journal entries
|
||
|
|
--skip-pages # Skip importing pages
|
||
|
|
] {
|
||
|
|
print $"(ansi green_bold)Logseq Import(ansi reset)"
|
||
|
|
print $"Source: ($logseq_path)"
|
||
|
|
print $"Target: ($kogral_dir)"
|
||
|
|
|
||
|
|
if $dry_run {
|
||
|
|
print $"(ansi yellow)DRY RUN MODE - No changes will be made(ansi reset)"
|
||
|
|
}
|
||
|
|
|
||
|
|
# Validate Logseq directory
|
||
|
|
if not ($logseq_path | path exists) {
|
||
|
|
print $"(ansi red)Error: Logseq path not found: ($logseq_path)(ansi reset)"
|
||
|
|
exit 1
|
||
|
|
}
|
||
|
|
|
||
|
|
let pages_dir = $"($logseq_path)/pages"
|
||
|
|
let journals_dir = $"($logseq_path)/journals"
|
||
|
|
|
||
|
|
if not ($pages_dir | path exists) and not ($journals_dir | path exists) {
|
||
|
|
print $"(ansi red)Error: Not a valid Logseq graph (missing pages or journals directory)(ansi reset)"
|
||
|
|
exit 1
|
||
|
|
}
|
||
|
|
|
||
|
|
# Validate KOGRAL directory
|
||
|
|
if not ($kogral_dir | path exists) {
|
||
|
|
print $"(ansi yellow)KOGRAL directory doesn't exist. Creating...(ansi reset)"
|
||
|
|
if not $dry_run {
|
||
|
|
mkdir $kogral_dir
|
||
|
|
mkdir $"($kogral_dir)/notes"
|
||
|
|
mkdir $"($kogral_dir)/journal"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
# Count files to import
|
||
|
|
let pages_count = if ($pages_dir | path exists) and not $skip_pages {
|
||
|
|
glob $"($pages_dir)/**/*.md" | length
|
||
|
|
} else { 0 }
|
||
|
|
|
||
|
|
let journals_count = if ($journals_dir | path exists) and not $skip_journals {
|
||
|
|
glob $"($journals_dir)/**/*.md" | length
|
||
|
|
} else { 0 }
|
||
|
|
|
||
|
|
print $"\n(ansi cyan_bold)Files to import:(ansi reset)"
|
||
|
|
print $" Pages: ($pages_count)"
|
||
|
|
print $" Journals: ($journals_count)"
|
||
|
|
print $" Total: ($pages_count + $journals_count)"
|
||
|
|
|
||
|
|
if ($pages_count + $journals_count) == 0 {
|
||
|
|
print $"\n(ansi yellow)No files to import(ansi reset)"
|
||
|
|
exit 0
|
||
|
|
}
|
||
|
|
|
||
|
|
if $dry_run {
|
||
|
|
print $"\n(ansi yellow)[DRY RUN] Would import ($pages_count + $journals_count) files(ansi reset)"
|
||
|
|
exit 0
|
||
|
|
}
|
||
|
|
|
||
|
|
# Import pages
|
||
|
|
if not $skip_pages and $pages_count > 0 {
|
||
|
|
print $"\n(ansi cyan_bold)Importing pages...(ansi reset)"
|
||
|
|
import_pages $pages_dir $kogral_dir
|
||
|
|
}
|
||
|
|
|
||
|
|
# Import journals
|
||
|
|
if not $skip_journals and $journals_count > 0 {
|
||
|
|
print $"\n(ansi cyan_bold)Importing journals...(ansi reset)"
|
||
|
|
import_journals $journals_dir $kogral_dir
|
||
|
|
}
|
||
|
|
|
||
|
|
print $"\n(ansi green_bold)✓ Import completed(ansi reset)"
|
||
|
|
print $"Imported ($pages_count + $journals_count) files"
|
||
|
|
}
|
||
|
|
|
||
|
|
def import_pages [pages_dir: string, kogral_dir: string] {
|
||
|
|
let files = glob $"($pages_dir)/**/*.md"
|
||
|
|
let total = $files | length
|
||
|
|
|
||
|
|
mut imported = 0
|
||
|
|
mut decisions = 0
|
||
|
|
mut guidelines = 0
|
||
|
|
mut patterns = 0
|
||
|
|
mut notes = 0
|
||
|
|
|
||
|
|
for file in $files {
|
||
|
|
let filename = $file | path basename
|
||
|
|
print $" Importing ($filename)..."
|
||
|
|
|
||
|
|
# Phase 1: Read and parse Logseq format
|
||
|
|
let content = open $file
|
||
|
|
|
||
|
|
# Phase 2: Detect node type from properties/content
|
||
|
|
let node_type = detect_node_type $content
|
||
|
|
|
||
|
|
# Phase 3: Convert to KOGRAL format
|
||
|
|
let kogral_content = convert_logseq_to_kogral $content $node_type
|
||
|
|
|
||
|
|
# Phase 4: Determine target directory
|
||
|
|
let target_dir = match $node_type {
|
||
|
|
"decision" => {
|
||
|
|
$decisions = $decisions + 1
|
||
|
|
$"($kogral_dir)/decisions"
|
||
|
|
},
|
||
|
|
"guideline" => {
|
||
|
|
$guidelines = $guidelines + 1
|
||
|
|
$"($kogral_dir)/guidelines"
|
||
|
|
},
|
||
|
|
"pattern" => {
|
||
|
|
$patterns = $patterns + 1
|
||
|
|
$"($kogral_dir)/patterns"
|
||
|
|
},
|
||
|
|
_ => {
|
||
|
|
$notes = $notes + 1
|
||
|
|
$"($kogral_dir)/notes"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
# Phase 5: Save to KOGRAL
|
||
|
|
mkdir $target_dir
|
||
|
|
$kogral_content | save $"($target_dir)/($filename)"
|
||
|
|
|
||
|
|
$imported = $imported + 1
|
||
|
|
print $" (ansi green)✓ Imported as ($node_type)(ansi reset)"
|
||
|
|
}
|
||
|
|
|
||
|
|
print $"\n(ansi green)Pages summary:(ansi reset)"
|
||
|
|
print $" Notes: ($notes)"
|
||
|
|
print $" Decisions: ($decisions)"
|
||
|
|
print $" Guidelines: ($guidelines)"
|
||
|
|
print $" Patterns: ($patterns)"
|
||
|
|
print $" Total: ($imported)/($total) imported"
|
||
|
|
}
|
||
|
|
|
||
|
|
def import_journals [journals_dir: string, kogral_dir: string] {
|
||
|
|
let files = glob $"($journals_dir)/**/*.md"
|
||
|
|
let total = $files | length
|
||
|
|
|
||
|
|
mkdir $"($kogral_dir)/journal"
|
||
|
|
|
||
|
|
mut imported = 0
|
||
|
|
|
||
|
|
for file in $files {
|
||
|
|
let filename = $file | path basename
|
||
|
|
print $" Importing ($filename)..."
|
||
|
|
|
||
|
|
# Phase 1: Read Logseq journal format
|
||
|
|
let content = open $file
|
||
|
|
|
||
|
|
# Phase 2: Convert to KOGRAL journal format
|
||
|
|
let kogral_content = convert_logseq_to_kogral $content "journal"
|
||
|
|
|
||
|
|
# Phase 3: Save to journal directory
|
||
|
|
$kogral_content | save $"($kogral_dir)/journal/($filename)"
|
||
|
|
|
||
|
|
$imported = $imported + 1
|
||
|
|
print $" (ansi green)✓ Imported(ansi reset)"
|
||
|
|
}
|
||
|
|
|
||
|
|
print $"\n(ansi green)Journals imported: ($imported)/($total)(ansi reset)"
|
||
|
|
}
|
||
|
|
|
||
|
|
def detect_node_type [content: string] {
|
||
|
|
# Check for type hints in properties or content
|
||
|
|
if ($content | str contains "type:: decision") or ($content | str contains "# Decision") {
|
||
|
|
"decision"
|
||
|
|
} else if ($content | str contains "type:: guideline") or ($content | str contains "# Guideline") {
|
||
|
|
"guideline"
|
||
|
|
} else if ($content | str contains "type:: pattern") or ($content | str contains "# Pattern") {
|
||
|
|
"pattern"
|
||
|
|
} else {
|
||
|
|
"note"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
def convert_logseq_to_kogral [content: string, node_type: string] {
|
||
|
|
let lines = $content | lines
|
||
|
|
|
||
|
|
# Phase 1: Parse Logseq properties (key:: value format)
|
||
|
|
let props = parse_logseq_properties $lines
|
||
|
|
let body_start = get_body_start_index $lines
|
||
|
|
|
||
|
|
# Phase 2: Extract metadata from properties
|
||
|
|
let title = $props.title? | default (extract_title_from_lines $lines)
|
||
|
|
let created = $props.created? | default (date now | format date "%Y-%m-%dT%H:%M:%SZ")
|
||
|
|
let modified = $props.modified? | default (date now | format date "%Y-%m-%dT%H:%M:%SZ")
|
||
|
|
let status = match ($node_type) {
|
||
|
|
"journal" => "draft",
|
||
|
|
_ => "active"
|
||
|
|
}
|
||
|
|
|
||
|
|
# Phase 3: Extract tags and relationships from properties
|
||
|
|
let tags = parse_tags_from_properties $props
|
||
|
|
let relates_to = parse_references_from_property ($props.relates_to? | default "")
|
||
|
|
let depends_on = parse_references_from_property ($props.depends_on? | default "")
|
||
|
|
let implements = parse_references_from_property ($props.implements? | default "")
|
||
|
|
let extends = parse_references_from_property ($props.extends? | default "")
|
||
|
|
|
||
|
|
# Phase 4: Build YAML frontmatter
|
||
|
|
let frontmatter = build_yaml_frontmatter {
|
||
|
|
type: $node_type,
|
||
|
|
title: $title,
|
||
|
|
created: $created,
|
||
|
|
modified: $modified,
|
||
|
|
status: $status,
|
||
|
|
tags: $tags,
|
||
|
|
relates_to: $relates_to,
|
||
|
|
depends_on: $depends_on,
|
||
|
|
implements: $implements,
|
||
|
|
extends: $extends
|
||
|
|
}
|
||
|
|
|
||
|
|
# Phase 5: Extract body and preserve wikilinks
|
||
|
|
let body = if $body_start < ($lines | length) {
|
||
|
|
$lines | skip $body_start | str join "\n"
|
||
|
|
} else {
|
||
|
|
""
|
||
|
|
}
|
||
|
|
|
||
|
|
# Phase 6: Convert Logseq-specific syntax
|
||
|
|
let converted_body = convert_logseq_syntax $body
|
||
|
|
|
||
|
|
$"$frontmatter\n$converted_body"
|
||
|
|
}
|
||
|
|
|
||
|
|
def parse_logseq_properties [lines: list] {
|
||
|
|
mut props = {}
|
||
|
|
|
||
|
|
# Parse properties until blank line or content starts
|
||
|
|
for line in $lines {
|
||
|
|
if ($line | str trim | is-empty) {
|
||
|
|
break
|
||
|
|
}
|
||
|
|
|
||
|
|
# Match pattern: key:: value
|
||
|
|
if ($line =~ '^[\w-]+::') {
|
||
|
|
let key = $line | str replace '^(\w[\w-]*)::.*' '$1'
|
||
|
|
let value = $line | str replace '^[\w-]+::\s*' '' | str trim
|
||
|
|
$props = ($props | insert $key $value)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
$props
|
||
|
|
}
|
||
|
|
|
||
|
|
def get_body_start_index [lines: list] {
|
||
|
|
# Find where properties end (first blank line or non-property line)
|
||
|
|
mut idx = 0
|
||
|
|
|
||
|
|
for line in $lines {
|
||
|
|
if ($line | str trim | is-empty) {
|
||
|
|
return ($idx + 1)
|
||
|
|
}
|
||
|
|
|
||
|
|
if not ($line =~ '^[\w-]+::') and ($line | str trim | length) > 0 {
|
||
|
|
return $idx
|
||
|
|
}
|
||
|
|
|
||
|
|
$idx = $idx + 1
|
||
|
|
}
|
||
|
|
|
||
|
|
$idx
|
||
|
|
}
|
||
|
|
|
||
|
|
def extract_title_from_lines [lines: list] {
|
||
|
|
# Extract from first heading or property
|
||
|
|
for line in $lines {
|
||
|
|
if ($line =~ '^#+ ') {
|
||
|
|
return ($line | str replace '^#+\s+' '')
|
||
|
|
}
|
||
|
|
|
||
|
|
if ($line =~ '^title::') {
|
||
|
|
return ($line | str replace '^title::\s*' '')
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
"Untitled"
|
||
|
|
}
|
||
|
|
|
||
|
|
def parse_tags_from_properties [props: record] {
|
||
|
|
mut tags = []
|
||
|
|
|
||
|
|
# Check tags property
|
||
|
|
if ($props.tags? | is-empty) {
|
||
|
|
return $tags
|
||
|
|
}
|
||
|
|
|
||
|
|
let tags_str = $props.tags?
|
||
|
|
|
||
|
|
# Extract [[tag]] format using split
|
||
|
|
if ($tags_str | str contains "[[") {
|
||
|
|
let parts = $tags_str | split row "[[" | skip 1
|
||
|
|
for part in $parts {
|
||
|
|
let tag = $part | split row "]]" | get 0
|
||
|
|
if ($tag | str length) > 0 {
|
||
|
|
$tags = ($tags | append $tag)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
# Extract comma-separated format
|
||
|
|
$tags = ($tags_str | split row ',' | each { |t| $t | str trim })
|
||
|
|
}
|
||
|
|
|
||
|
|
$tags
|
||
|
|
}
|
||
|
|
|
||
|
|
def parse_references_from_property [prop: string] {
|
||
|
|
if ($prop | str length) == 0 {
|
||
|
|
return []
|
||
|
|
}
|
||
|
|
|
||
|
|
# Extract [[ref]] format using split
|
||
|
|
mut refs = []
|
||
|
|
if ($prop | str contains "[[") {
|
||
|
|
let parts = $prop | split row "[[" | skip 1
|
||
|
|
for part in $parts {
|
||
|
|
let ref = $part | split row "]]" | get 0
|
||
|
|
if ($ref | str length) > 0 {
|
||
|
|
$refs = ($refs | append $ref)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
$refs
|
||
|
|
}
|
||
|
|
|
||
|
|
def build_yaml_frontmatter [data: record] {
|
||
|
|
mut fm = "---\n"
|
||
|
|
$fm = $fm + $"type: ($data.type)\n"
|
||
|
|
$fm = $fm + $"title: ($data.title)\n"
|
||
|
|
$fm = $fm + $"created: ($data.created)\n"
|
||
|
|
$fm = $fm + $"modified: ($data.modified)\n"
|
||
|
|
$fm = $fm + $"status: ($data.status)\n"
|
||
|
|
|
||
|
|
if ($data.tags | length) > 0 {
|
||
|
|
let quoted_tags = $data.tags | each { |t| $'"($t)"' }
|
||
|
|
let tags_str = $quoted_tags | str join ", "
|
||
|
|
$fm = $fm + $"tags: [$tags_str]\n"
|
||
|
|
}
|
||
|
|
|
||
|
|
if ($data.relates_to | length) > 0 {
|
||
|
|
$fm = $fm + "relates_to:\n"
|
||
|
|
for ref in $data.relates_to {
|
||
|
|
$fm = $fm + $" - $ref\n"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if ($data.depends_on | length) > 0 {
|
||
|
|
$fm = $fm + "depends_on:\n"
|
||
|
|
for ref in $data.depends_on {
|
||
|
|
$fm = $fm + $" - $ref\n"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if ($data.implements | length) > 0 {
|
||
|
|
$fm = $fm + "implements:\n"
|
||
|
|
for ref in $data.implements {
|
||
|
|
$fm = $fm + $" - $ref\n"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if ($data.extends | length) > 0 {
|
||
|
|
$fm = $fm + "extends:\n"
|
||
|
|
for ref in $data.extends {
|
||
|
|
$fm = $fm + $" - $ref\n"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
$fm + "---"
|
||
|
|
}
|
||
|
|
|
||
|
|
def convert_logseq_syntax [body: string] {
|
||
|
|
# Phase 1: Convert Logseq task markers to standard markdown
|
||
|
|
mut converted = $body | str replace -a 'LATER ' ''
|
||
|
|
$converted = $converted | str replace -a 'NOW ' ''
|
||
|
|
$converted = $converted | str replace -a 'WAITING ' ''
|
||
|
|
$converted = $converted | str replace -a 'CANCELLED ' ''
|
||
|
|
|
||
|
|
$converted
|
||
|
|
}
|