-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathdynalist2markdown.py
executable file
·161 lines (136 loc) · 4.79 KB
/
dynalist2markdown.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
#!/usr/bin/env python3
from typing import TextIO
import os
import sys
import argparse
import json
import textwrap
import dataclasses
import requests
def get_document(token: str, file_id: str) -> list:
endpoint = 'https://dynalist.io/api/v1/doc/read'
headers = {
'Content-Type': 'application/json',
# urllib.request's default User-Agent is blacklisted;
# preemptively use our own in case requests's gets blacklisted too
'User-Agent': 'dyanlist-export',
}
params = {'token': token, 'file_id': file_id}
res = requests.post(endpoint, json=params, headers=headers).json()
# API doc says _code will be 'OK' but the actual response says 'Ok'.
# Normalize to futureproof.
if res['_code'].lower() != 'ok':
print(res['_msg'], file=sys.stderr)
sys.exit(1)
return res
@dataclasses.dataclass
class RenderState:
nodes_by_id: dict
under_checkbox: bool
under_heading: bool
depth: int
index: int
def render_node(fp: TextIO, node: dict, state: RenderState):
is_heading = state.depth == 0 and node.get('heading') is not None
if is_heading:
if state.index != 0:
fp.write('\n')
fp.write('#' * (node['heading']) + ' ')
text = ''
if state.under_checkbox:
if node.get('checked'):
text += '[x] '
else:
text += '[ ] '
if state.depth != 0 and node.get('heading') is not None:
if node.get('content'):
text += '__' + node.get('content') + '__'
else:
text += node.get('content', '')
if node.get('note'):
text += '\n' + node['note']
# The read API doesn't export whether a list is numbered. Hardcode for now
numbered = False
if is_heading:
fp.write(text)
fp.write('\n')
else:
indent_level = state.depth - 1 if state.under_heading else state.depth
bulleted = (numbered and '1. ' or '* ') + ' '.join(text.splitlines(True))
fp.write(textwrap.indent(bulleted, ' ' * indent_level))
fp.write('\n')
for i, child_id in enumerate(node.get('children', [])):
new_state = dataclasses.replace(
state,
under_heading=state.under_heading or is_heading,
under_checkbox=state.under_checkbox or bool(node.get('checkbox')),
depth=state.depth + 1,
index=i,
)
render_node(fp, state.nodes_by_id[child_id], new_state)
def render(fp: TextIO, dynalist_nodes: list):
by_id = {node['id']: node for node in dynalist_nodes}
root = by_id['root']
state = RenderState(
nodes_by_id=by_id,
under_checkbox=bool(root.get('checkbox')),
under_heading=False,
depth=0,
index=0,
)
for i, child_id in enumerate(root['children']):
render_node(fp, by_id[child_id], dataclasses.replace(state, index=i))
def main(token: str, args: argparse.Namespace):
if args.read_raw_from:
if not os.path.exists(args.read_raw_from):
print(
"Specified --read-raw-from path not found:",
args.read_raw_from,
file=sys.stderr)
sys.exit(1)
with open(args.read_raw_from) as f:
doc = json.load(f)
else:
doc = get_document(token, args.file_id)
if args.save_raw_to:
with open(args.save_raw_to, 'w') as f:
json.dump(doc, f, indent=2)
with open(args.output, 'w') as f:
render(f, doc['nodes'])
def ensure_token() -> str:
secret_token = os.environ.get('DYNALIST_SECRET_TOKEN')
if not secret_token:
print("Expected to find your API token in the env var DYNALIST_SECRET_TOKEN", file=sys.stderr)
sys.exit(1)
return secret_token
def stripped_string(s: str) -> str:
'''
file_id may start with a hyphen '-'. In which case, argparse won't recognize
it as a valid positional argument: https://bugs.python.org/issue9334
Workaround is to wrap it in quotes and prefix with a whitespace.
.strip() will remove the whitespace.
'''
return s.strip()
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument(
'file_id',
type=stripped_string,
help='The ID of the document to export. This is the last part of the URL')
parser.add_argument(
'--output', '-o',
metavar='PATH',
help='Path to write the output to')
parser.add_argument(
'--save-raw-to',
metavar='PATH',
help='Save the raw response to the specified path')
parser.add_argument(
'--read-raw-from',
metavar='PATH',
help='Read the raw response from the specified path instead of hitting the API')
args = parser.parse_args()
secret_token = None
if not args.read_raw_from:
secret_token = ensure_token()
main(secret_token, args)