Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -152,3 +152,7 @@ Thumbs.db

# Pycharm
.idea/
.env*


tmp/
127 changes: 109 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,13 @@ If you are not familiar with command line programs, take a look at these step-by
### With [Homebrew](https://brew.sh/) (Recommended for macOS)

```bash
$ brew install enex2notion
brew install enex2notion
```

### With [**PIPX**](https://github.com/pipxproject/pipx) (Recommended for Linux & Windows)

```shell
$ pipx install enex2notion
pipx install enex2notion
```

### With [**Docker**](https://docs.docker.com/)
Expand All @@ -62,13 +62,13 @@ $ pipx install enex2notion
This command maps current directory `$PWD` to the `/input` directory in the container. You can replace `$PWD` with a directory that contains your `*.enex` files. When running commands like `enex2notion /input` refer to your local mapped directory as `/input`.

```shell
$ docker run --rm -t -v "$PWD":/input vzhd1701/enex2notion:latest
docker run --rm -t -v "$PWD":/input vzhd1701/enex2notion:latest
```

### With PIP

```bash
$ pip install --user enex2notion
pip install --user enex2notion
```

**Python 3.8 or later required.**
Expand All @@ -78,17 +78,97 @@ $ pip install --user enex2notion
This project uses [poetry](https://python-poetry.org/) for dependency management and packaging. You will have to install it first. See [poetry official documentation](https://python-poetry.org/docs/) for instructions.

```shell
$ git clone https://github.com/vzhd1701/enex2notion.git
$ cd enex2notion/
$ poetry install
$ poetry run enex2notion
git clone https://github.com/vzhd1701/enex2notion.git
cd enex2notion/
poetry install
poetry run enex2notion
```

### Python 3.13 Compatibility

If you're using Python 3.13, you may encounter build issues with some dependencies (particularly PyMuPDF and lxml). Here's the recommended installation approach:

1. **Install Poetry** (if not already installed):

```shell
# Via the official installer
$ curl -sSL https://install.python-poetry.org | python3 -

# Or with Homebrew on macOS
$ brew install poetry
```

2. **Clone and setup the project**:

```shell
git clone https://github.com/subimage/enex2notion.git
cd enex2notion/
```

3. **Install compatible dependencies manually**:

```shell
# First, install these compatible versions directly
$ poetry run pip install PyMuPDF==1.26.0 lxml==5.2.2 --force-reinstall

# Then install remaining dependencies
$ poetry run pip install beautifulsoup4 python-dateutil requests w3lib tinycss2 pdfkit "notion-vzhd1701-fork==0.0.37" tqdm

# Finally, install the project itself
$ poetry run pip install -e .
```

4. **Verify installation**:

```shell
poetry run enex2notion --help
```

This approach bypasses the Poetry dependency resolution issues while ensuring all packages work with Python 3.13.

## Setup

### 1. Create a Notion Integration

1. Go to [Notion Integrations](https://www.notion.so/my-integrations)
2. Click **"+ New integration"**
3. Give your integration a name (e.g., "ENEX Importer")
4. Select the workspace you want to import to
5. Click **"Submit"**
6. Copy the **"Integration Token"** (starts with `secret_`)

### 2. Create a Parent Page

1. In your Notion workspace, create a new page where you want the imported notes to appear
2. This will be the parent page for all your imported notebooks

### 3. Give Integration Access to Parent Page

1. Go to **[Notion Integrations](https://www.notion.so/my-integrations)**
2. Click on your integration name
3. Go to the **"Capabilities"** tab and ensure these are enabled:
- Read content
- Update content
- Insert content
4. Go to the **"Content capabilities"** section:
- Enable **"Read pages"**
- Enable **"Update pages"**
- Enable **"Create pages"**
5. Go to the **"Access"** tab
6. Find your parent page and grant access to it

### 4. Get Parent Page ID

The parent page ID is the long string in your page URL after the last dash:

- URL: `https://notion.so/workspace/My-Page-209ffa4c3a38803ab96ed05860fdafe9`
- Page ID: `209ffa4c3a38803ab96ed05860fdafe9`

## Usage

```shell
$ enex2notion --help
usage: enex2notion [-h] [--token TOKEN] [OPTION ...] FILE/DIR [FILE/DIR ...]
usage: enex2notion [-h] [--token TOKEN] [--pageid PAGE_ID] [OPTION ...] FILE/DIR [FILE/DIR ...]

Uploads ENEX files to Notion

Expand All @@ -97,8 +177,9 @@ positional arguments:

optional arguments:
-h, --help show this help message and exit
--token TOKEN Notion token, stored in token_v2 cookie for notion.so [NEEDED FOR UPLOAD]
--root-page NAME root page name for the imported notebooks, it will be created if it does not exist (default: "Evernote ENEX Import")
--token TOKEN Notion integration token [NEEDED FOR UPLOAD]
--pageid PAGE_ID Parent page ID where imported notebooks will be created [NEEDED FOR UPLOAD]

--mode {DB,PAGE} upload each ENEX as database (DB) or page with children (PAGE) (default: DB)
--mode-webclips {TXT,PDF} convert web clips to text (TXT) or pdf (PDF) before upload (default: TXT)
--retry N retry N times on note upload error before giving up, 0 for infinite retries (default: 5)
Expand All @@ -119,17 +200,27 @@ optional arguments:

You can pass single `*.enex` files or directories. The program will recursively scan directories for `*.enex` files.

### Token & dry run mode
### Token & Setup

The upload requires a Notion integration token (see [Setup](#setup) section above for detailed instructions).

The program can run without `--token` and `--pageid` provided though. It will not make any network requests without them. Executing a dry run with `--verbose` is an excellent way to check if your `*.enex` files are parsed correctly before uploading.

The upload requires you to have a `token_v2` cookie for the Notion website. For information on how to get it, see [this article](https://vzhd1701.notion.site/Find-Your-Notion-Token-5f57951434c1414d84ac72f88226eede).
**Example commands:**

The program can run without `--token` provided though. It will not make any network requests without it. Executing a dry run with `--verbose` is an excellent way to check if your `*.enex` files are parsed correctly before uploading.
```shell
# Dry run to check parsing
enex2notion --verbose my_notebooks/

# Upload with integration token and parent page ID
enex2notion --token secret_YOUR_TOKEN_HERE --pageid YOUR_PAGE_ID my_notebooks/
```

### Upload continuation

The upload will take some time since each note is uploaded block-by-block, so you'll probably need some way of resuming it. `--done-file` is precisely for that. All uploaded note hashes will be stored there, so the next time you start, the upload will continue from where you left off.

All uploaded notebooks will appear under the automatically created `Evernote ENEX Import` page. You can change that name with the `--root-page` option. The program will mark unfinished notes with `[UNFINISHED UPLOAD]` text in the title. After successful upload, the mark will be removed.
All uploaded notebooks will appear directly under the page specified by `--pageid`. The program will mark unfinished notes with `[UNFINISHED UPLOAD]` text in the title. After successful upload, the mark will be removed.

### Upload modes

Expand Down Expand Up @@ -168,19 +259,19 @@ The `--tag` option allows you to add a custom tag to all uploaded notes. It will
### Checking notes before upload

```shell
$ enex2notion --verbose my_notebooks/
enex2notion --verbose my_notebooks/
```

### Uploading notes from a single notebook

```shell
$ enex2notion --token <YOUR_TOKEN_HERE> "notebook.enex"
enex2notion --token secret_YOUR_TOKEN_HERE --pageid YOUR_PAGE_ID "notebook.enex"
```

### Uploading with the option to continue later

```shell
$ enex2notion --token <YOUR_TOKEN_HERE> --done-file done.txt "notebook.enex"
enex2notion --token secret_YOUR_TOKEN_HERE --pageid YOUR_PAGE_ID --done-file done.txt "notebook.enex"
```

## Getting help
Expand Down
2 changes: 1 addition & 1 deletion enex2notion/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ def cli(argv):
if rules.mode_webclips == "PDF":
ensure_wkhtmltopdf()

root = get_root(args.token, args.root_page)
root = get_root(args.token, args.pageid)

enex_uploader = EnexUploader(
import_root=root, mode=args.mode, done_file=args.done_file, rules=rules
Expand Down
11 changes: 5 additions & 6 deletions enex2notion/cli_args.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,14 @@ def parse_args(argv):
" [NEEDED FOR UPLOAD]"
),
},
"--root-page": {
"default": "Evernote ENEX Import",
"--pageid": {
"help": (
"root page name for the imported notebooks,"
" it will be created if it does not exist"
' (default: "Evernote ENEX Import")'
"Parent page ID where imported notebooks will be created"
" [NEEDED FOR UPLOAD]"
),
"metavar": "NAME",
"metavar": "PAGE_ID",
},

"--mode": {
"choices": ["DB", "PAGE"],
"default": "DB",
Expand Down
58 changes: 37 additions & 21 deletions enex2notion/cli_notion.py
Original file line number Diff line number Diff line change
@@ -1,51 +1,67 @@
import logging
import sys

from notion.block import PageBlock
from notion.client import NotionClient
from requests import HTTPError, codes
from notion_client import Client
from notion_client.errors import APIResponseError

from enex2notion.utils_exceptions import BadTokenException

logger = logging.getLogger(__name__)


def get_root(token, name):
def get_root(token, pageid=None):
if not token:
logger.warning(
"No token provided, dry run mode. Nothing will be uploaded to Notion!"
)
return None

if not pageid:
logger.warning(
"No parent page ID provided! Please use --pageid to specify where to create pages."
)
return None

try:
client = get_notion_client(token)
except BadTokenException:
logger.error("Invalid token provided!")
sys.exit(1)

return get_import_root(client, name)
return get_import_root(client, pageid)


def get_notion_client(token):
try:
return NotionClient(token_v2=token)
except HTTPError as e: # pragma: no cover
if e.response.status_code == codes["unauthorized"]:
client = Client(auth=token)
# Test the client by trying to list users
client.users.list()
# Make token discoverable
client.auth = token
client._auth = token
client._token = token
return client
except APIResponseError as e:
if e.status == 401:
raise BadTokenException
raise
except Exception:
raise BadTokenException


def get_import_root(client, title):
def get_import_root(client, pageid):
"""
Get the page specified by pageid to use as the import root.

This will be the direct parent for all imported notebooks.
"""
try:
top_pages = client.get_top_level_pages()
except KeyError: # pragma: no cover
# Need empty account to test
top_pages = []

for page in top_pages:
if isinstance(page, PageBlock) and page.title == title:
logger.info(f"'{title}' page found")
return page

logger.info(f"Creating '{title}' page...")
return client.current_space.add_page(title)
# Get the page specified by pageid
page = client.pages.retrieve(page_id=pageid)
page["_client"] = client
logger.info(f"Using page as import root: {pageid}")
return page

except APIResponseError as e:
logger.error(f"Failed to retrieve import root page {pageid}: {e}")
raise
Loading