logseq-to-obsidian 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ Copyright 2025 Georg Lutz
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,143 @@
1
+ Metadata-Version: 2.3
2
+ Name: logseq-to-obsidian
3
+ Version: 0.1.0
4
+ Summary: Convert a Logseq vault to Obsidian-friendly Markdown
5
+ License: MIT
6
+ Author: Georg Lutz
7
+ Author-email: georg@georglutz.de
8
+ Requires-Python: >=3.9,<4.0
9
+ Classifier: Development Status :: 5 - Production/Stable
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Environment :: Console
13
+ Classifier: Programming Language :: Python
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Text Processing :: Markup :: Markdown
20
+ Classifier: Topic :: Utilities
21
+ Project-URL: Homepage, https://github.com/sercxanto/logseq-to-obsidian
22
+ Project-URL: Repository, https://github.com/sercxanto/logseq-to-obsidian.git
23
+ Description-Content-Type: text/markdown
24
+
25
+ # Logseq → Obsidian Converter
26
+
27
+ ![tests](https://github.com/sercxanto/logseq_to_obsidian/actions/workflows/tests.yml/badge.svg)
28
+ ![lint](https://github.com/sercxanto/logseq_to_obsidian/actions/workflows/lint.yml/badge.svg)
29
+ [![codecov](https://codecov.io/gh/sercxanto/logseq_to_obsidian/graph/badge.svg?token=bTE5A3niNf)](https://codecov.io/gh/sercxanto/logseq_to_obsidian)
30
+
31
+ ## Overview
32
+
33
+ - Converts a Logseq vault (Markdown flavor) to Obsidian-friendly Markdown.
34
+ - Handles page properties → YAML front matter, task statuses, block IDs, and block references.
35
+ - Preserves non-page folders; moves `pages/` content to the vault root.
36
+
37
+ ## Features
38
+
39
+ - Page properties (`key:: value`) at top → YAML front matter.
40
+ - Special mappings:
41
+ - `alias::` or `aliases::` → `aliases: []` (array)
42
+ - `tags::` → `tags: []` (array, without `#`)
43
+ - `title::` → omitted (Obsidian only uses the filename as the note name). If the Logseq title mismatches the vault-relative output path, a warning is emitted so you can tidy it up.
44
+ - Task markers:
45
+ - Recognized at the start of a list item (`- STATE ...`, uppercase only):
46
+ - `TODO`, `DOING`, `LATER`, `NOW`, `WAIT`, `WAITING`, `IN-PROGRESS` → `- [ ] ...`
47
+ - `DONE`, `CANCELED`, `CANCELLED` → `- [x] ...`
48
+ - Priorities right after state (`[#A|#B|#C]`):
49
+ - Emoji: A→`⏫`, B→`🔼`, C→`🔽` (appended at end)
50
+ - Dataview: `[priority::high|medium|low]` (appended at end; omitted if none)
51
+ - Dates anywhere after the state:
52
+ - `SCHEDULED: <YYYY-MM-DD [Dow] [HH:MM] [repeater]>` → Emoji: `⏳ YYYY-MM-DD[ HH:MM]`; Dataview: `[scheduled::YYYY-MM-DD[ HH:MM]]`
53
+ - `DEADLINE: <YYYY-MM-DD [Dow] [HH:MM] [repeater]>` → Emoji: `📅 YYYY-MM-DD[ HH:MM]`; Dataview: `[due::YYYY-MM-DD[ HH:MM]]`
54
+ - Repeaters: `. +N<u>` or `++N<u>` → “every N <unit> when done”; `+N<u>` → “every N <unit>”
55
+ - Units: `y`=year(s), `m`=month(s), `w`=week(s), `d`=day(s), `h`=hour(s); pluralized when N ≠ 1
56
+ - Ordering: append priority, then scheduled, then due, then repeat; block anchors (e.g., `^id`) remain last
57
+ - Block IDs:
58
+ - `id:: <uuid>` lines are converted to Obsidian block anchors by appending `^<uuid>` to the owning block line.
59
+ - Block references:
60
+ - `((<uuid>))` → `[[<FileName>#^<uuid>]]` (resolved by scanning all files first).
61
+ - Optional: convert configured wikilinks `[[key/value]]` to Dataview inline fields `[key::value]` (non-embed, no alias, not inside code blocks).
62
+ - Embeds:
63
+ - `{{embed ((<uuid>))}}` → `![[<FileName>#^<uuid>]]`
64
+ - `{{embed [[Some Page]]}}` → `![[Some Page]]`
65
+ - Images in assets:
66
+ - `![alt](../assets/image.png)` or `![alt](assets/image.png)` → `![[image.png]]` (alt text is not preserved)
67
+ - With size attributes: `![alt](../assets/image.png){:height H, :width W}` → `![[image.png|WxH]]` (width × height)
68
+ - Headings followed by indented lists:
69
+ - If a heading line is immediately followed by an indented list (≥4 spaces or tabs), prefix the heading with `- ` (i.e., `- # Heading`).
70
+ - Rationale: Logseq treats such lists as children of the heading; Obsidian otherwise renders them as quoted/code blocks. This keeps folding behavior aligned.
71
+ - Journals:
72
+ - Renames `YYYY_MM_DD.md` → `YYYY-MM-DD.md` and can move journals to a specific folder.
73
+ - Assets and other files are copied as-is.
74
+
75
+ ## Installation
76
+
77
+ - pipx (recommended): `pipx install logseq-to-obsidian`
78
+ - pip: `pip install logseq-to-obsidian`
79
+ - From Git (development version): `pipx install "git+https://github.com/sercxanto/logseq_to_obsidian.git@main"`
80
+
81
+ ## Usage
82
+
83
+ ```
84
+ logseq-to-obsidian \
85
+ --input /path/to/logseq-vault \
86
+ --output /path/to/obsidian-vault \
87
+ --daily-folder "Daily Notes" \
88
+ --tasks-format emoji \
89
+ --field-key project \
90
+ --field-key topic \
91
+ --dry-run
92
+ ```
93
+
94
+ Alternatively (no console script), you can run:
95
+
96
+ ```
97
+ python -m logseq_to_obsidian \
98
+ --input /path/to/logseq-vault \
99
+ --output /path/to/obsidian-vault \
100
+ --dry-run
101
+ ```
102
+
103
+ ## Options
104
+
105
+ - `--input`: Path to the Logseq vault root (folder containing `pages/`, `journals/`, etc.).
106
+ - `--output`: Destination Obsidian vault directory (created if not exists).
107
+ - `--daily-folder <name>`: Move `journals/` into this folder in the output. If omitted, keeps `journals/`.
108
+ - `--tasks-format {emoji|dataview}`: Choose output format for Tasks metadata (priorities now; dates later). Default: `emoji`.
109
+ - `--field-key <key>`: Convert wikilinks of the form `[[key/value]]` to Dataview inline fields `[key::value]`. Repeatable for multiple keys.
110
+ - Pages are always flattened to the vault root; see "File placement rules" below.
111
+ - `--dry-run`: Print planned changes without writing files.
112
+
113
+ ## Notes and assumptions
114
+
115
+ - Only Markdown (`.md`) files are transformed; other files are copied.
116
+ - Block reference resolution requires that `id::` appears for referenced blocks in the source files.
117
+ - This tool is conservative: it preserves unknown page properties in YAML.
118
+ - Block-level `collapsed::` properties are ignored; Obsidian stores list collapse state outside Markdown.
119
+
120
+ ## Limitations
121
+
122
+ - Does not parse or migrate the Logseq database; operates purely on Markdown files.
123
+ - Complex block property drawers beyond `id::` are not transformed (left in place).
124
+ - Skips Logseq's internal `logseq/` metadata folder.
125
+ - Skips Logseq whiteboards (`whiteboards/`); a warning is emitted since Obsidian cannot read Logseq's whiteboard format.
126
+ - Only normalizes heading + indented list patterns outside fenced code blocks; headings already inside list items are left unchanged.
127
+ - File times are preserved: output files keep the original source modification time (mtime) for both transformed Markdown and copied assets.
128
+
129
+ ## File placement rules
130
+
131
+ - Pages: All files from Logseq's `pages/` are placed at the root of the Obsidian vault.
132
+ - Nested paths: Logseq encodes subfolders in page filenames using three underscores `___`.
133
+ - Example: `pages/a___b.md` becomes `a/b.md`.
134
+
135
+ ## Journals
136
+
137
+ - Journal filenames are always renamed from `YYYY_MM_DD.md` to `YYYY-MM-DD.md`.
138
+ - Logseq already displays journal page links using dashes (e.g., `[[2024-08-30]]`), so link text does not need conversion.
139
+
140
+ ## Development / Contributing
141
+
142
+ See [CONTRIBUTING.md](CONTRIBUTING.md)
143
+
@@ -0,0 +1,118 @@
1
+ # Logseq → Obsidian Converter
2
+
3
+ ![tests](https://github.com/sercxanto/logseq_to_obsidian/actions/workflows/tests.yml/badge.svg)
4
+ ![lint](https://github.com/sercxanto/logseq_to_obsidian/actions/workflows/lint.yml/badge.svg)
5
+ [![codecov](https://codecov.io/gh/sercxanto/logseq_to_obsidian/graph/badge.svg?token=bTE5A3niNf)](https://codecov.io/gh/sercxanto/logseq_to_obsidian)
6
+
7
+ ## Overview
8
+
9
+ - Converts a Logseq vault (Markdown flavor) to Obsidian-friendly Markdown.
10
+ - Handles page properties → YAML front matter, task statuses, block IDs, and block references.
11
+ - Preserves non-page folders; moves `pages/` content to the vault root.
12
+
13
+ ## Features
14
+
15
+ - Page properties (`key:: value`) at top → YAML front matter.
16
+ - Special mappings:
17
+ - `alias::` or `aliases::` → `aliases: []` (array)
18
+ - `tags::` → `tags: []` (array, without `#`)
19
+ - `title::` → omitted (Obsidian only uses the filename as the note name). If the Logseq title mismatches the vault-relative output path, a warning is emitted so you can tidy it up.
20
+ - Task markers:
21
+ - Recognized at the start of a list item (`- STATE ...`, uppercase only):
22
+ - `TODO`, `DOING`, `LATER`, `NOW`, `WAIT`, `WAITING`, `IN-PROGRESS` → `- [ ] ...`
23
+ - `DONE`, `CANCELED`, `CANCELLED` → `- [x] ...`
24
+ - Priorities right after state (`[#A|#B|#C]`):
25
+ - Emoji: A→`⏫`, B→`🔼`, C→`🔽` (appended at end)
26
+ - Dataview: `[priority::high|medium|low]` (appended at end; omitted if none)
27
+ - Dates anywhere after the state:
28
+ - `SCHEDULED: <YYYY-MM-DD [Dow] [HH:MM] [repeater]>` → Emoji: `⏳ YYYY-MM-DD[ HH:MM]`; Dataview: `[scheduled::YYYY-MM-DD[ HH:MM]]`
29
+ - `DEADLINE: <YYYY-MM-DD [Dow] [HH:MM] [repeater]>` → Emoji: `📅 YYYY-MM-DD[ HH:MM]`; Dataview: `[due::YYYY-MM-DD[ HH:MM]]`
30
+ - Repeaters: `. +N<u>` or `++N<u>` → “every N <unit> when done”; `+N<u>` → “every N <unit>”
31
+ - Units: `y`=year(s), `m`=month(s), `w`=week(s), `d`=day(s), `h`=hour(s); pluralized when N ≠ 1
32
+ - Ordering: append priority, then scheduled, then due, then repeat; block anchors (e.g., `^id`) remain last
33
+ - Block IDs:
34
+ - `id:: <uuid>` lines are converted to Obsidian block anchors by appending `^<uuid>` to the owning block line.
35
+ - Block references:
36
+ - `((<uuid>))` → `[[<FileName>#^<uuid>]]` (resolved by scanning all files first).
37
+ - Optional: convert configured wikilinks `[[key/value]]` to Dataview inline fields `[key::value]` (non-embed, no alias, not inside code blocks).
38
+ - Embeds:
39
+ - `{{embed ((<uuid>))}}` → `![[<FileName>#^<uuid>]]`
40
+ - `{{embed [[Some Page]]}}` → `![[Some Page]]`
41
+ - Images in assets:
42
+ - `![alt](../assets/image.png)` or `![alt](assets/image.png)` → `![[image.png]]` (alt text is not preserved)
43
+ - With size attributes: `![alt](../assets/image.png){:height H, :width W}` → `![[image.png|WxH]]` (width × height)
44
+ - Headings followed by indented lists:
45
+ - If a heading line is immediately followed by an indented list (≥4 spaces or tabs), prefix the heading with `- ` (i.e., `- # Heading`).
46
+ - Rationale: Logseq treats such lists as children of the heading; Obsidian otherwise renders them as quoted/code blocks. This keeps folding behavior aligned.
47
+ - Journals:
48
+ - Renames `YYYY_MM_DD.md` → `YYYY-MM-DD.md` and can move journals to a specific folder.
49
+ - Assets and other files are copied as-is.
50
+
51
+ ## Installation
52
+
53
+ - pipx (recommended): `pipx install logseq-to-obsidian`
54
+ - pip: `pip install logseq-to-obsidian`
55
+ - From Git (development version): `pipx install "git+https://github.com/sercxanto/logseq_to_obsidian.git@main"`
56
+
57
+ ## Usage
58
+
59
+ ```
60
+ logseq-to-obsidian \
61
+ --input /path/to/logseq-vault \
62
+ --output /path/to/obsidian-vault \
63
+ --daily-folder "Daily Notes" \
64
+ --tasks-format emoji \
65
+ --field-key project \
66
+ --field-key topic \
67
+ --dry-run
68
+ ```
69
+
70
+ Alternatively (no console script), you can run:
71
+
72
+ ```
73
+ python -m logseq_to_obsidian \
74
+ --input /path/to/logseq-vault \
75
+ --output /path/to/obsidian-vault \
76
+ --dry-run
77
+ ```
78
+
79
+ ## Options
80
+
81
+ - `--input`: Path to the Logseq vault root (folder containing `pages/`, `journals/`, etc.).
82
+ - `--output`: Destination Obsidian vault directory (created if not exists).
83
+ - `--daily-folder <name>`: Move `journals/` into this folder in the output. If omitted, keeps `journals/`.
84
+ - `--tasks-format {emoji|dataview}`: Choose output format for Tasks metadata (priorities now; dates later). Default: `emoji`.
85
+ - `--field-key <key>`: Convert wikilinks of the form `[[key/value]]` to Dataview inline fields `[key::value]`. Repeatable for multiple keys.
86
+ - Pages are always flattened to the vault root; see "File placement rules" below.
87
+ - `--dry-run`: Print planned changes without writing files.
88
+
89
+ ## Notes and assumptions
90
+
91
+ - Only Markdown (`.md`) files are transformed; other files are copied.
92
+ - Block reference resolution requires that `id::` appears for referenced blocks in the source files.
93
+ - This tool is conservative: it preserves unknown page properties in YAML.
94
+ - Block-level `collapsed::` properties are ignored; Obsidian stores list collapse state outside Markdown.
95
+
96
+ ## Limitations
97
+
98
+ - Does not parse or migrate the Logseq database; operates purely on Markdown files.
99
+ - Complex block property drawers beyond `id::` are not transformed (left in place).
100
+ - Skips Logseq's internal `logseq/` metadata folder.
101
+ - Skips Logseq whiteboards (`whiteboards/`); a warning is emitted since Obsidian cannot read Logseq's whiteboard format.
102
+ - Only normalizes heading + indented list patterns outside fenced code blocks; headings already inside list items are left unchanged.
103
+ - File times are preserved: output files keep the original source modification time (mtime) for both transformed Markdown and copied assets.
104
+
105
+ ## File placement rules
106
+
107
+ - Pages: All files from Logseq's `pages/` are placed at the root of the Obsidian vault.
108
+ - Nested paths: Logseq encodes subfolders in page filenames using three underscores `___`.
109
+ - Example: `pages/a___b.md` becomes `a/b.md`.
110
+
111
+ ## Journals
112
+
113
+ - Journal filenames are always renamed from `YYYY_MM_DD.md` to `YYYY-MM-DD.md`.
114
+ - Logseq already displays journal page links using dashes (e.g., `[[2024-08-30]]`), so link text does not need conversion.
115
+
116
+ ## Development / Contributing
117
+
118
+ See [CONTRIBUTING.md](CONTRIBUTING.md)
@@ -0,0 +1,148 @@
1
+ [project]
2
+ name = "logseq-to-obsidian"
3
+ version = "0.1.0"
4
+ description = "Convert a Logseq vault to Obsidian-friendly Markdown"
5
+ license = "MIT"
6
+ readme = "README.md"
7
+ requires-python = ">=3.9,<4.0"
8
+ classifiers = [
9
+ "Development Status :: 5 - Production/Stable",
10
+ "License :: OSI Approved :: MIT License",
11
+ "Operating System :: OS Independent",
12
+ "Environment :: Console",
13
+ "Programming Language :: Python",
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.9",
16
+ "Programming Language :: Python :: 3.10",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Topic :: Text Processing :: Markup :: Markdown",
20
+ "Topic :: Utilities",
21
+ ]
22
+ authors = [
23
+ {name = "Georg Lutz", email="georg@georglutz.de"},
24
+ ]
25
+
26
+ [project.urls]
27
+ homepage = "https://github.com/sercxanto/logseq-to-obsidian"
28
+ repository = "https://github.com/sercxanto/logseq-to-obsidian.git"
29
+
30
+
31
+
32
+ [project.scripts]
33
+ logseq-to-obsidian = "logseq_to_obsidian.__main__:main"
34
+
35
+ [tool.poetry]
36
+
37
+ package-mode = true
38
+ packages = [
39
+ { include = "logseq_to_obsidian", from = "src" },
40
+ ]
41
+
42
+ [tool.poetry.group.dev.dependencies]
43
+ ruff = "^0.13.3"
44
+ pytest = "^8.4.2"
45
+ pytest-cov = "^7.0.0"
46
+ pyyaml = "^6.0.3"
47
+ towncrier = "^25.8.0"
48
+ poethepoet = "^0.37.0"
49
+
50
+ [tool.ruff]
51
+ target-version = "py39"
52
+ line-length = 120
53
+
54
+ [tool.ruff.lint]
55
+ select = [
56
+ "E", # pycodestyle
57
+ "F", # pyflakes
58
+ "I", # isort (imports)
59
+ "N", # pep8-naming
60
+ "B", # flake8-bugbear
61
+ "C4", # flake8-comprehensions
62
+ ]
63
+ ignore = [
64
+ "E501", # line length handled by formatter/line-length
65
+ "F841", # allow assigned-but-unused (keeps script unchanged)
66
+ ]
67
+
68
+ [tool.pytest.ini_options]
69
+ addopts = "-q --cov=logseq_to_obsidian --cov-branch --cov-report=term-missing --cov-report=xml --cov-fail-under=80"
70
+ testpaths = [
71
+ "tests",
72
+ ]
73
+
74
+ [tool.coverage.run]
75
+ omit = [
76
+ "src/logseq_to_obsidian/__main__.py",
77
+ ]
78
+
79
+ [tool.towncrier]
80
+ package = "logseq_to_obsidian"
81
+ package_dir = "src"
82
+ filename = "CHANGELOG.md"
83
+ directory = ".changelog"
84
+ title_format = "## [{version}] - {project_date}"
85
+ issue_format = "#{issue}"
86
+ underlines = ["", "", ""]
87
+
88
+ [tool.towncrier.fragment.added]
89
+ [tool.towncrier.fragment.changed]
90
+ [tool.towncrier.fragment.deprecated]
91
+ [tool.towncrier.fragment.removed]
92
+ [tool.towncrier.fragment.fixed]
93
+ [tool.towncrier.fragment.security]
94
+
95
+ [tool.poe.tasks]
96
+
97
+ [tool.poe.tasks.lint]
98
+ cmd = "ruff check ."
99
+ help = "Run static analysis."
100
+
101
+ [tool.poe.tasks.fixlint]
102
+ cmd = "ruff check . --fix"
103
+ help = "Run automatic fixes."
104
+
105
+ [tool.poe.tasks.format]
106
+ cmd = "ruff format ."
107
+ help = "Format source code."
108
+
109
+ [tool.poe.tasks.test]
110
+ cmd = "pytest"
111
+ help = "Run the test suite."
112
+
113
+ [tool.poe.tasks.testandlint]
114
+ sequence = ["test", "lint"]
115
+ help = "Run test an lint in one step."
116
+
117
+ [tool.poe.tasks.bumpversion]
118
+ cmd = "poetry version $increment"
119
+ help = "Bump version in pyproject.toml"
120
+ args = [{ name="increment", positional=true, required=true, help="Either 'major', minor'or 'patch'." }]
121
+
122
+ [tool.poe.tasks._get_version]
123
+ cmd = "poetry version -s"
124
+
125
+ [tool.poe.tasks.draftchangelog]
126
+ cmd = "towncrier build --draft"
127
+ help = "List pending news fragments without consuming them."
128
+
129
+ [tool.poe.tasks.changelog]
130
+ cmd = "towncrier build --yes"
131
+ help = "Build the changelog for the configured version number."
132
+
133
+ [tool.poe.tasks.commitandtagrelease]
134
+ shell = """
135
+ git commit -am "Release $VERSION"
136
+ git tag v$VERSION
137
+ """
138
+ help = "Commits the changes for the new version"
139
+ uses = { VERSION = "_get_version" }
140
+
141
+ [tool.poe.tasks.release]
142
+ sequence = ["lint", "test", "bumpversion $increment", "changelog", "commitandtagrelease"]
143
+ args = [{ name="increment", positional=true, required=true, help="Either 'major', minor'or 'patch'." }]
144
+ help = "runs lint + tests, bumps the version, updates 'CHANGELOG.md', commits and tags 'v<version>'"
145
+
146
+ [build-system]
147
+ requires = ["poetry-core>=1.8.0"]
148
+ build-backend = "poetry.core.masonry.api"
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ from .cli import main, parse_args
4
+ from .planner import FilePlan, Options, collect_files, copy_or_write, plan_output_path
5
+ from .transformer import (
6
+ attach_block_ids,
7
+ build_block_index,
8
+ emit_yaml_frontmatter,
9
+ fix_heading_child_lists,
10
+ normalize_aliases,
11
+ normalize_tags,
12
+ parse_page_properties,
13
+ replace_asset_images,
14
+ replace_block_refs,
15
+ replace_embeds,
16
+ replace_wikilinks_to_dv_fields,
17
+ transform_markdown,
18
+ transform_tasks,
19
+ )
20
+ from .version import __version__
21
+
22
+ __all__ = [
23
+ "__version__",
24
+ "FilePlan",
25
+ "Options",
26
+ "attach_block_ids",
27
+ "build_block_index",
28
+ "collect_files",
29
+ "copy_or_write",
30
+ "emit_yaml_frontmatter",
31
+ "fix_heading_child_lists",
32
+ "main",
33
+ "normalize_aliases",
34
+ "normalize_tags",
35
+ "parse_args",
36
+ "parse_page_properties",
37
+ "plan_output_path",
38
+ "replace_asset_images",
39
+ "replace_block_refs",
40
+ "replace_embeds",
41
+ "replace_wikilinks_to_dv_fields",
42
+ "transform_markdown",
43
+ "transform_tasks",
44
+ ]
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+
5
+ from . import main as _main
6
+
7
+
8
+ def main() -> int:
9
+ """Console entry point: dispatch to package `main(argv)`.
10
+
11
+ Returns process exit code.
12
+ """
13
+ return _main(sys.argv[1:])
14
+
15
+
16
+ if __name__ == "__main__":
17
+ raise SystemExit(main())
@@ -0,0 +1,129 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+ from pathlib import Path
6
+ from typing import Dict, List
7
+
8
+ from .planner import Options, collect_files, copy_or_write
9
+ from .transformer import (
10
+ build_block_index,
11
+ replace_asset_images,
12
+ replace_block_refs,
13
+ replace_embeds,
14
+ replace_wikilinks_to_dv_fields,
15
+ transform_markdown,
16
+ )
17
+ from .version import __version__
18
+
19
+ __all__ = ["main", "parse_args"]
20
+
21
+
22
+ def parse_args(argv: List[str]) -> Options:
23
+ p = argparse.ArgumentParser(description="Convert a Logseq vault to Obsidian-friendly Markdown.")
24
+ p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
25
+ p.add_argument("--input", required=True, help="Path to Logseq vault root")
26
+ p.add_argument("--output", required=True, help="Path to destination Obsidian vault root")
27
+ p.add_argument("--daily-folder", default=None, help="Move journals into this folder name in output")
28
+ p.add_argument(
29
+ "--tasks-format",
30
+ choices=["emoji", "dataview"],
31
+ default="emoji",
32
+ help="Format for Tasks plugin metadata (priority, dates)",
33
+ )
34
+ p.add_argument(
35
+ "--field-key",
36
+ action="append",
37
+ default=[],
38
+ help="Convert wikilinks of the form [[key/value]] to Dataview inline fields [key::value] for the given key(s).",
39
+ )
40
+ p.add_argument("--dry-run", action="store_true", help="Do not write files; print plan only")
41
+ args = p.parse_args(argv)
42
+ return Options(
43
+ input_dir=Path(args.input).resolve(),
44
+ output_dir=Path(args.output).resolve(),
45
+ daily_folder=args.daily_folder,
46
+ dry_run=bool(args.dry_run),
47
+ tasks_format=str(args.tasks_format),
48
+ field_keys=list(args.field_key or []),
49
+ )
50
+
51
+
52
+ def main(argv: List[str]) -> int:
53
+ opt = parse_args(argv)
54
+ if not opt.input_dir.exists():
55
+ print(f"Input directory not found: {opt.input_dir}", file=sys.stderr)
56
+ return 1
57
+ opt.output_dir.mkdir(parents=True, exist_ok=True)
58
+
59
+ print("[START] Logseq → Obsidian conversion")
60
+ print(f"[CONFIG] input={opt.input_dir}")
61
+ print(f"[CONFIG] output={opt.output_dir}")
62
+ print(
63
+ f"[CONFIG] daily_folder={opt.daily_folder or '-'} tasks_format={opt.tasks_format} field_keys={','.join(opt.field_keys) or '-'} dry_run={opt.dry_run}"
64
+ )
65
+
66
+ plans = collect_files(opt)
67
+ total = len(plans)
68
+ md_total = sum(1 for p in plans if p.is_markdown)
69
+ other_total = total - md_total
70
+ print(f"[SCAN] Discovered {total} files ({md_total} markdown, {other_total} other)")
71
+
72
+ # First pass: read markdown files and pre-transform to attach ids, etc., to allow building index
73
+ pre_texts: Dict[Path, str] = {}
74
+ in_to_out: Dict[Path, Path] = {pl.in_path: pl.out_path for pl in plans}
75
+
76
+ warn_messages: List[str] = []
77
+ for pl in plans:
78
+ if not pl.is_markdown:
79
+ continue
80
+ try:
81
+ rel = pl.in_path.relative_to(opt.input_dir)
82
+ except ValueError:
83
+ rel = pl.in_path
84
+ print(f"[TRANSFORM] {rel}")
85
+ raw = pl.in_path.read_text(encoding="utf-8")
86
+ # Compute the expected title as the vault-relative output path without extension
87
+ try:
88
+ rel_out = pl.out_path.relative_to(opt.output_dir)
89
+ except ValueError:
90
+ rel_out = pl.out_path
91
+ expected_title = rel_out.with_suffix("").as_posix()
92
+ transformed = transform_markdown(
93
+ raw,
94
+ expected_title_path=expected_title,
95
+ rel_path_for_warn=rel,
96
+ warn_collector=warn_messages,
97
+ tasks_format=opt.tasks_format,
98
+ )
99
+ pre_texts[pl.in_path] = transformed
100
+
101
+ block_index = build_block_index(pre_texts)
102
+ print(f"[INDEX] Resolved {len(block_index)} block id(s)")
103
+
104
+ # Second pass: write files, replacing block refs using the index
105
+ writes = 0
106
+ copies = 0
107
+ for pl in plans:
108
+ if pl.is_markdown:
109
+ text = pre_texts.get(pl.in_path, pl.in_path.read_text(encoding="utf-8"))
110
+ text = replace_block_refs(text, block_index, in_to_out, opt.output_dir)
111
+ text = replace_embeds(text)
112
+ text = replace_wikilinks_to_dv_fields(text, opt.field_keys)
113
+ text = replace_asset_images(text)
114
+ # Ensure a trailing newline at EOF to match golden outputs
115
+ if not text.endswith("\n"):
116
+ text += "\n"
117
+ copy_or_write(pl.out_path, text, pl.in_path, opt.dry_run)
118
+ writes += 1
119
+ else:
120
+ # Copy assets and others
121
+ copy_or_write(pl.out_path, None, pl.in_path, opt.dry_run)
122
+ copies += 1
123
+
124
+ print(f"[DONE] Wrote {writes} markdown file(s), copied {copies} other file(s)")
125
+ if warn_messages:
126
+ print(f"[WARN] Conversion completed with {len(warn_messages)} warning(s). Review the messages above.")
127
+ if opt.dry_run:
128
+ print("[INFO] Dry run complete. No files were written.")
129
+ return 0