# migrate.nu โ€” Convert Logseq graph to pure markdown
# Usage: nu nu/migrate.nu ~/git/cyber
#
# What it does:
# 1. Converts property:: value โ†’ YAML frontmatter
# 2. Normalizes outliner bullets โ†’ standard markdown
# 3. Strips Logseq block-level metadata from body
# 4. Renames ___-encoded files to directory namespaces
# 5. Decodes percent-encoded filenames

def main [graph_path: string] {
    let graph = ($graph_path | path expand)
    let pages_dir = ($graph | path join "pages")
    let journals_dir = ($graph | path join "journals")

    print $"Migrating graph at ($graph)"
    print ""

    # Step 1+2+3: Convert pages
    let page_files = (glob ($pages_dir | path join "*.md"))
    let page_count = ($page_files | length)
    print $"Found ($page_count) pages"

    mut converted = 0
    mut skipped = 0
    for file in $page_files {
        let result = (convert_file $file)
        if $result == "converted" {
            $converted = $converted + 1
        } else {
            $skipped = $skipped + 1
        }
    }
    print $"  Converted: ($converted), Already migrated: ($skipped)"

    # Convert journals
    if ($journals_dir | path exists) {
        let journal_files = (glob ($journals_dir | path join "*.md"))
        let journal_count = ($journal_files | length)
        print $"\nFound ($journal_count) journals"

        mut j_converted = 0
        mut j_skipped = 0
        for file in $journal_files {
            let result = (convert_file $file)
            if $result == "converted" {
                $j_converted = $j_converted + 1
            } else {
                $j_skipped = $j_skipped + 1
            }
        }
        print $"  Converted: ($j_converted), Already migrated: ($j_skipped)"
    }

    # Step 4: Move ___-encoded files to directories
    print "\nMoving ___-encoded files to directories..."
    let ns_files = (glob ($pages_dir | path join "*___*"))
    let ns_count = ($ns_files | length)
    print $"  Found ($ns_count) namespaced files"

    for file in $ns_files {
        move_namespaced_file $file $pages_dir
    }

    # Step 5: Decode percent-encoded filenames
    print "\nDecoding percent-encoded filenames..."
    let pct_files = (glob ($pages_dir | path join "*%*"))
    let pct_count = ($pct_files | length)
    print $"  Found ($pct_count) percent-encoded files"

    for file in $pct_files {
        decode_filename $file
    }

    print "\nDone!"
}

# Convert a single file: properties โ†’ frontmatter, outliner โ†’ prose
def convert_file [file: string]: nothing -> string {
    let content = (open --raw $file)

    # Skip if already migrated (starts with ---)
    if ($content | str starts-with "---\n") or ($content | str starts-with "---\r\n") {
        return "skipped"
    }

    let lines = ($content | lines)

    # Extract properties from top of file
    let extract = (extract_properties $lines)
    let props = $extract.properties
    let body_lines = $extract.body

    # Build YAML frontmatter
    let frontmatter = (build_frontmatter $props)

    # Normalize body (outliner โ†’ prose)
    let body = (normalize_body $body_lines)

    # Write result
    let result = if ($frontmatter | is-empty) {
        $body
    } else {
        $"---\n($frontmatter)---\n($body)"
    }

    $result | save --force --raw $file
    return "converted"
}

# Extract property:: value lines from top of file
# Returns: { properties: list<{key, value}>, body: list<string> }
def extract_properties [lines: list<string>]: nothing -> record {
    mut props = []
    mut body = []
    mut in_props = true
    mut found_any = false

    for line in $lines {
        if $in_props {
            # Strip leading "- " if present (Logseq wraps properties in bullets)
            let check = if ($line | str starts-with "- ") {
                $line | str substring 2..
            } else {
                $line
            }
            let trimmed = ($check | str trim)

            if ($trimmed | is-empty) {
                if $found_any {
                    $in_props = false
                }
                # Don't add empty line between props and body
            } else if ($trimmed =~ '^[a-zA-Z_-]+::\s*') {
                # It's a property line
                let parts = ($trimmed | split row "::" | each { str trim })
                let key = ($parts | first)
                let value = if ($parts | length) > 1 {
                    $parts | skip 1 | str join ":: "
                } else {
                    ""
                }
                $props = ($props | append {key: $key, value: $value})
                $found_any = true
            } else {
                # Non-property line โ€” end of properties
                $in_props = false
                $body = ($body | append $line)
            }
        } else {
            $body = ($body | append $line)
        }
    }

    {properties: $props, body: $body}
}

# Build YAML frontmatter string from property list
def build_frontmatter [props: list<record<key: string, value: string>>]: nothing -> string {
    if ($props | is-empty) {
        return ""
    }

    mut lines = []
    for prop in $props {
        let key = $prop.key
        mut value = $prop.value

        # Strip [[wikilinks]] from tag/alias values
        $value = ($value | str replace --all "[[" "" | str replace --all "]]" "")

        # Determine if value needs quoting
        let needs_quote = (value_needs_yaml_quoting $value)

        let yaml_line = if ($value | is-empty) {
            $"($key):"
        } else if $needs_quote {
            # Use double quotes, escape internal quotes
            let escaped = ($value | str replace --all '"' '\"')
            $"($key): \"($escaped)\""
        } else {
            $"($key): ($value)"
        }

        $lines = ($lines | append $yaml_line)
    }

    ($lines | str join "\n") + "\n"
}

# Check if a YAML value needs quoting
def value_needs_yaml_quoting [value: string]: nothing -> bool {
    if ($value | is-empty) { return false }

    # Values that look like YAML special types
    let lower = ($value | str downcase)
    if $lower in ["true", "false", "yes", "no", "on", "off", "null", "~"] {
        return true
    }

    # Values that start with special YAML chars
    let first = ($value | str substring 0..1)
    if $first in ["{", "}", "[", "]", "&", "*", "?", "|", "-", "<", ">", "=", "!", "%", "@", "`", "'", '"'] {
        return true
    }

    # Values containing : followed by space, or # preceded by space
    if ($value =~ ': ') or ($value =~ ' #') {
        return true
    }

    # Values that are pure numbers (would be parsed as int/float)
    if ($value =~ '^\d+$') or ($value =~ '^\d+\.\d+$') {
        return true
    }

    # Values starting with 0x, 0o, 0b (YAML numeric literals)
    if ($value =~ '^0[xXoObB]') {
        return true
    }

    false
}

# Normalize outliner body content to standard markdown
def normalize_body [lines: list<string>]: nothing -> string {
    # Check if this is already prose (not outliner format)
    let non_empty = ($lines | where { |l| ($l | str trim) != "" } | first 10)
    let bullet_count = ($non_empty | where { |l| ($l =~ '^\s*- ') } | length)
    let total = ($non_empty | length)

    let is_outliner = if $total == 0 {
        false
    } else {
        ($bullet_count / $total) > 0.5
    }

    if not $is_outliner {
        # Already prose โ€” just strip block-level metadata
        let cleaned = (strip_block_metadata $lines)
        return ($cleaned | str join "\n")
    }

    # Outliner normalization
    mut output = []
    mut i = 0
    mut in_code_block = false
    let line_count = ($lines | length)

    while $i < $line_count {
        let line = ($lines | get $i)

        # Track code blocks โ€” preserve verbatim
        if ($line =~ '^\s*-?\s*```') or (not $in_code_block and ($line =~ '```')) {
            if $in_code_block {
                $in_code_block = false
                $output = ($output | append $line)
            } else {
                $in_code_block = true
                # Strip bullet prefix from code fence
                let stripped = ($line | str replace --regex '^\s*- ' '')
                $output = ($output | append $stripped)
            }
            $i = $i + 1
            continue
        }

        if $in_code_block {
            $output = ($output | append $line)
            $i = $i + 1
            continue
        }

        # Skip block-level metadata lines
        if (is_block_metadata $line) {
            $i = $i + 1
            continue
        }

        # Skip empty bullets "- " or "-"
        if ($line | str trim) in ["- ", "-"] {
            $i = $i + 1
            continue
        }

        # Empty line
        if ($line | str trim | is-empty) {
            $output = ($output | append "")
            $i = $i + 1
            continue
        }

        # Top-level bullet: starts with "- " (no leading whitespace)
        if ($line =~ '^- ') {
            let content = ($line | str substring 2..)

            # Heading: - ## Title โ†’ ## Title
            if ($content =~ '^#{1,6}\s') {
                $output = ($output | append "")
                $output = ($output | append $content)
                $output = ($output | append "")
                $i = $i + 1
                continue
            }

            # Check if next line is a sub-bullet (indented bullet)
            let has_children = if ($i + 1) < $line_count {
                let next = ($lines | get ($i + 1))
                ($next =~ '^\s+- ') or ($next =~ '^\t- ')
            } else {
                false
            }

            if $has_children {
                # Parent with children: parent becomes paragraph, children become list
                $output = ($output | append "")
                $output = ($output | append $content)
                $output = ($output | append "")
                $i = $i + 1

                # Collect children
                while $i < $line_count {
                    let child = ($lines | get $i)
                    if ($child =~ '^\s+- ') or ($child =~ '^\t+- ') {
                        # Reduce indent by one level (strip first tab or 2 spaces)
                        let stripped = if ($child | str starts-with "\t") {
                            $child | str substring 1..
                        } else if ($child | str starts-with "  ") {
                            $child | str substring 2..
                        } else {
                            $child
                        }
                        # Skip metadata in children too
                        if not (is_block_metadata $stripped) {
                            $output = ($output | append $stripped)
                        }
                    } else if ($child | str trim | is-empty) {
                        $output = ($output | append "")
                        $i = $i + 1
                        continue
                    } else if ($child =~ '^\s+[^-]') {
                        # Continuation line (indented but not a bullet)
                        # Reduce indent
                        let stripped = if ($child | str starts-with "\t") {
                            $child | str substring 1..
                        } else if ($child | str starts-with "  ") {
                            $child | str substring 2..
                        } else {
                            $child
                        }
                        if not (is_block_metadata $stripped) {
                            $output = ($output | append $stripped)
                        }
                    } else {
                        break
                    }
                    $i = $i + 1
                }
            } else {
                # Single top-level bullet โ†’ paragraph
                $output = ($output | append "")
                $output = ($output | append $content)
                $i = $i + 1
            }
        } else if ($line =~ '^\s+- ') or ($line =~ '^\t+- ') {
            # Orphan sub-bullet (no parent) โ€” reduce indent and keep as list
            let stripped = if ($line | str starts-with "\t") {
                $line | str substring 1..
            } else if ($line | str starts-with "  ") {
                $line | str substring 2..
            } else {
                $line
            }
            if not (is_block_metadata $stripped) {
                $output = ($output | append $stripped)
            }
            $i = $i + 1
        } else {
            # Non-bullet line (continuation, table row, etc.)
            if not (is_block_metadata $line) {
                $output = ($output | append $line)
            }
            $i = $i + 1
        }
    }

    # Collapse multiple blank lines
    let result = ($output | str join "\n")
    $result | str replace --all --regex '\n{3,}' "\n\n" | str trim
}

# Check if a line is Logseq block-level metadata that should be stripped
def is_block_metadata [line: string]: nothing -> bool {
    let trimmed = ($line | str trim)
    if ($trimmed =~ '^collapsed::\s') { return true }
    if ($trimmed =~ '^id::\s[0-9a-f]') { return true }
    if ($trimmed =~ '^query-properties::') { return true }
    if ($trimmed =~ '^query-table::') { return true }
    if ($trimmed =~ '^query-sort-by::') { return true }
    if ($trimmed =~ '^query-sort-desc::') { return true }
    false
}

# Strip block-level metadata from lines (for prose pages)
def strip_block_metadata [lines: list<string>]: nothing -> list<string> {
    $lines | where { |l| not (is_block_metadata $l) }
}

# Move a ___-encoded filename to directory structure
def move_namespaced_file [file: string, pages_dir: string] {
    let basename = ($file | path basename)
    let name = ($basename | str replace '.md' '')

    # Split on ___ to get path segments
    let segments = ($name | split row "___")

    if ($segments | length) < 2 {
        return
    }

    # Build target path
    let dir_parts = ($segments | drop 1 | length)
    let parent_segments = ($segments | drop nth (($segments | length) - 1))
    let leaf = ($segments | last)

    let target_dir = ($parent_segments | each { |s| $s | str trim } | path join)
    let target_dir_full = ($pages_dir | path join $target_dir)
    let target_file = ($target_dir_full | path join $"($leaf | str trim).md")

    # Create directory
    mkdir $target_dir_full

    # Move file
    mv $file $target_file
    print $"  ($basename) โ†’ ($target_dir)/($leaf | str trim).md"
}

# Decode percent-encoded filename
def decode_filename [file: string] {
    let dir = ($file | path dirname)
    let basename = ($file | path basename)

    # Simple percent decoding for common cases
    mut decoded = $basename
    $decoded = ($decoded | str replace --all "%2E" ".")
    $decoded = ($decoded | str replace --all "%2e" ".")
    $decoded = ($decoded | str replace --all "%3A" ":")
    $decoded = ($decoded | str replace --all "%3a" ":")
    $decoded = ($decoded | str replace --all "%3F" "?")
    $decoded = ($decoded | str replace --all "%3f" "?")
    $decoded = ($decoded | str replace --all "%23" "#")
    $decoded = ($decoded | str replace --all "%2F" "/")
    $decoded = ($decoded | str replace --all "%2f" "/")

    if $decoded != $basename {
        let target = ($dir | path join $decoded)
        mv $file $target
        print $"  ($basename) โ†’ ($decoded)"
    }
}

Local Graph