I've got a Python script to go out of Joplin and uses your great app

Migrated from Codeberg issue #58.
Originally opened by davi-jorge-art on 2026-05-05T22:39:47+02:00.

I don't know programing...I've just had the basics a long, long time ago...But with the help of ChatGpt I've got a Python Script to migrate from Joplin and It has worked just fine...Until the IA has solved the issue it has made a lot of mistakes , as usual.

I hope it helps on some way...

Here the link for the chat (mostly in Brazilian Portuguese) : https://chatgpt.com/share/69f593c2-b488-83e9-8dfa-e466701c4851

The script :

"""
Joplin → HelixNotes Migration Script (Generic Version)

DESCRIPTION
-----------
Converts a Joplin "Markdown + Front Matter" export into a structure
compatible with HelixNotes.

FEATURES
--------
- Preserves folder structure (notebooks → folders)
- Converts tags to normalized format
- Fixes note titles (Windows-safe filenames)
- Copies attachments to HelixNotes internal folder
- Rewrites image links to absolute paths (Helix-compatible)
- Converts Joplin internal links to wiki-style [[links]]

REQUIREMENTS
------------
- Python 3.8+
- Joplin export in: "Markdown + Front Matter"

HOW TO USE
----------
1. Export your notes from Joplin:
   File → Export All → Markdown + Front Matter

2. Edit the paths below:

   SRC = Path(r"YOUR_JOPLIN_EXPORT_FOLDER")
   DST = Path(r"YOUR_HELIX_VAULT_FOLDER")

   Example:
   SRC = Path(r"C:\Users\YourName\Desktop\JoplinExport")
   DST = Path(r"C:\Users\YourName\Documents\HelixVault")

3. Run:

   python script.py

4. Open HelixNotes and select the DST folder as your vault.

NOTES
-----
- This script assumes HelixNotes stores attachments in:
  .helixnotes/attachments/

- Images are converted to absolute paths because HelixNotes
  may not render relative paths correctly.

- If images do not render, try removing "|size=small" in image links.

"""

import re
import shutil
from pathlib import Path

# ===== CONFIGURE HERE =====
SRC = Path(r"C:\PATH\TO\JOPLIN_EXPORT")
DST = Path(r"C:\PATH\TO\HELIX_VAULT")

ASSETS = DST / ".helixnotes" / "attachments"

DST.mkdir(parents=True, exist_ok=True)
ASSETS.mkdir(parents=True, exist_ok=True)


def slug_tag(tag):
    tag = tag.strip().lower()
    tag = re.sub(r"\s+", "-", tag)
    return tag


def safe_name(name):
    name = re.sub(r'[\\/:*?"<>|]', "-", name)
    return name.strip()


def parse_frontmatter(text):
    if not text.startswith("---"):
        return {}, text

    parts = text.split("---", 2)
    if len(parts) < 3:
        return {}, text

    raw = parts[1]
    body = parts[2].lstrip()
    meta = {}
    key = None

    for line in raw.splitlines():
        if re.match(r"^\s*-\s+", line) and key:
            meta.setdefault(key, [])
            meta[key].append(re.sub(r"^\s*-\s+", "", line).strip())

        elif ":" in line:
            k, v = line.split(":", 1)
            key = k.strip()
            v = v.strip()
            meta[key] = v if v else []

    return meta, body


def build_frontmatter(meta):
    out = ["---"]

    for k in ["title", "created", "updated"]:
        if meta.get(k):
            out.append(f"{k}: {meta[k]}")

    tags = meta.get("tags", [])
    if isinstance(tags, str):
        tags = [tags]

    out.append("tags:")
    for t in sorted(set(slug_tag(x) for x in tags if x.strip())):
        out.append(f"  - {t}")

    out.append("---")
    out.append("")
    return "\n".join(out)


def copy_resources():
    res = SRC / "_resources"
    if not res.exists():
        print("No _resources folder found.")
        return

    for f in res.iterdir():
        if f.is_file():
            shutil.copy2(f, ASSETS / f.name)


def convert_image_links(txt):
    def repl(m):
        fname = m.group(1)
        full = str(ASSETS / fname).replace("\\", "/")
        return f"![|size=small]({full})"

    txt = re.sub(
        r'!\[\]\((?:.*?/)?_resources/([^)]+)\)',
        repl,
        txt
    )

    txt = re.sub(
        r'!\[[^\]]*\]\((?:.*?/)?_resources/([^)]+)\)',
        repl,
        txt
    )

    return txt


def convert_note_links(txt):
    txt = re.sub(
        r'\[([^\]]+)\]\(:/[a-f0-9]{32}\)',
        r'[[\1]]',
        txt,
        flags=re.I
    )
    return txt


def process_md(mdfile):
    rel = mdfile.relative_to(SRC)

    txt = mdfile.read_text(encoding="utf-8", errors="ignore")
    meta, body = parse_frontmatter(txt)

    title = safe_name(meta.get("title", mdfile.stem))
    meta["title"] = title

    body = convert_image_links(body)
    body = convert_note_links(body)

    final = build_frontmatter(meta) + body.strip() + "\n"

    out_dir = DST / rel.parent
    out_dir.mkdir(parents=True, exist_ok=True)

    (out_dir / f"{title}.md").write_text(final, encoding="utf-8")


def main():
    print("Copying attachments...")
    copy_resources()

    print("Processing notes...")
    for md in SRC.rglob("*.md"):
        if "_resources" not in str(md):
            process_md(md)

    print("DONE")


if __name__ == "__main__":
    main()