|
1 # -*- coding: utf-8 -*- |
|
2 """Anki card writer from gaphrase format""" |
|
3 |
|
4 import re |
|
5 import hashlib |
|
6 |
|
7 import os |
|
8 import io |
|
9 import sys |
|
10 import tempfile |
|
11 import shutil |
|
12 |
|
13 from gadict_util import ParseException |
|
14 |
|
15 import anki |
|
16 from anki.exporting import AnkiPackageExporter |
|
17 |
|
18 FINAME = None |
|
19 FONAME = None |
|
20 FDELNAME = None |
|
21 |
|
22 ARG_NAME_RE = re.compile("-name=(.+)") |
|
23 ARG_DELFILE_RE = re.compile("-delfile=(.+)") |
|
24 |
|
25 look_for_files = False |
|
26 for idx in range(1, len(sys.argv)): |
|
27 arg = sys.argv[idx] |
|
28 if arg == "--": |
|
29 look_for_files = True |
|
30 continue |
|
31 if not look_for_files: |
|
32 m = ARG_NAME_RE.match(arg) |
|
33 if m: |
|
34 NAME = m.group(1) |
|
35 continue |
|
36 m = ARG_DELFILE_RE.match(arg) |
|
37 if m: |
|
38 FDELNAME = m.group(1) |
|
39 continue |
|
40 if arg.startswith("-"): |
|
41 raise Exception("Unsupported option format: '{:s}'".format(arg)) |
|
42 if not FINAME: |
|
43 FINAME = arg |
|
44 continue |
|
45 if not FONAME: |
|
46 FONAME = arg |
|
47 continue |
|
48 raise Exception("Unnecessary argument: '{:s}'".format(arg)) |
|
49 |
|
50 if not FINAME: |
|
51 raise Exception("Input file name is not passed...") |
|
52 if FONAME is None: |
|
53 raise Exception("Output file name is not passed...") |
|
54 if not NAME: |
|
55 NAME, _ = os.path.splitext(os.path.basename(FINAME)) |
|
56 |
|
57 # if FDELNAME: |
|
58 # FDEL = io.open(FDELNAME, mode='r', buffering=1, encoding="utf-8") |
|
59 # else: |
|
60 # FDEL = None |
|
61 |
|
62 ################################################################ |
|
63 |
|
64 class Parser: |
|
65 |
|
66 COMMENT_RE = re.compile("^; ") |
|
67 NUM_RE = re.compile(u"^# ([1-9][0-9]*)$") |
|
68 PHRASE_START_RE = re.compile(u"^- (.*)") |
|
69 |
|
70 def __init__(self): |
|
71 pass |
|
72 |
|
73 def readline(self): |
|
74 while True: |
|
75 self.line = self.stream.readline() |
|
76 self.eof = len(self.line) == 0 |
|
77 if self.eof: |
|
78 break |
|
79 self.lineno += 1 |
|
80 if self.COMMENT_RE.search(self.line): |
|
81 continue |
|
82 self.line = self.line.strip(' \n\t') |
|
83 if len(self.line) > 0: |
|
84 break |
|
85 |
|
86 def parse(self, stream): |
|
87 self.lineno = 0 |
|
88 self.stream = stream |
|
89 self.dom = dict() |
|
90 self.eof = False |
|
91 try: |
|
92 self.parse_prelude() |
|
93 while not self.eof: |
|
94 self.parse_article() |
|
95 except ParseException as ex: |
|
96 if sys.version_info.major == 2: |
|
97 import traceback |
|
98 traceback.print_exc() |
|
99 raise ParseException(ex.msg, self.lineno, self.line) |
|
100 return self.dom |
|
101 |
|
102 def parse_prelude(self): |
|
103 while True: |
|
104 self.readline() |
|
105 if self.eof: |
|
106 return |
|
107 m = self.NUM_RE.match(self.line) |
|
108 if m: |
|
109 self.num = m.group(1) |
|
110 break |
|
111 |
|
112 def parse_article(self): |
|
113 """Assume we are at ``# NUM`` line.""" |
|
114 num = self.num |
|
115 phrase_buf = [] |
|
116 phrases = [] |
|
117 while True: |
|
118 self.readline() |
|
119 if self.eof: |
|
120 if len(phrase_buf) > 0: |
|
121 phrases.append(" ".join(phrase_buf)) |
|
122 break |
|
123 m = self.NUM_RE.match(self.line) |
|
124 if m: |
|
125 if len(phrase_buf) > 0: |
|
126 phrases.append(" ".join(phrase_buf)) |
|
127 self.num = m.group(1) |
|
128 break |
|
129 m = self.PHRASE_START_RE.match(self.line) |
|
130 if m: |
|
131 if len(phrase_buf) > 0: |
|
132 phrases.append(" ".join(phrase_buf)) |
|
133 phrase_buf = [m.group(1)] |
|
134 else: |
|
135 phrase_buf.append(self.line) |
|
136 if len(phrases) == 0: |
|
137 raise ParseException("""There are no any phrases...""") |
|
138 if num in self.dom: |
|
139 raise ParseException("""Conflicting key: {}...""".format(num)) |
|
140 self.dom[num] = phrases |
|
141 |
|
142 FIN = io.open(FINAME, mode='r', buffering=1, encoding="utf-8") |
|
143 |
|
144 PARSER = Parser() |
|
145 try: |
|
146 DOM = PARSER.parse(FIN) |
|
147 finally: |
|
148 FIN.close() |
|
149 |
|
150 ################################################################ |
|
151 |
|
152 MODEL_CSS = """ |
|
153 .card { |
|
154 font-family: arial; |
|
155 font-size: 20px; |
|
156 text-align: left; |
|
157 color: black; |
|
158 background-color: white; |
|
159 } |
|
160 .line { |
|
161 margin-bottom: 0.5em; |
|
162 } |
|
163 .odd { |
|
164 color: #004000; |
|
165 } |
|
166 .even { |
|
167 color: #000080; |
|
168 } |
|
169 """ |
|
170 |
|
171 class AnkiDbBuilder: |
|
172 |
|
173 def __init__(self, tmpdir, name): |
|
174 self.tmpdir = tmpdir |
|
175 self.name = name |
|
176 |
|
177 self.collection = collection = anki.Collection(os.path.join(self.tmpdir, 'collection.anki2')) |
|
178 |
|
179 deck_id = collection.decks.id(self.name) |
|
180 |
|
181 # It is essential to keep model['id'] unchanged between upgrades!! |
|
182 model_id = int(hashlib.sha1(self.name.encode('utf-8')).hexdigest(), 16) % (2**63) |
|
183 |
|
184 ################################################################ |
|
185 # One face card model. |
|
186 model = collection.models.new(self.name + "_front") |
|
187 model['did'] = deck_id |
|
188 model['css'] = MODEL_CSS |
|
189 |
|
190 collection.models.addField(model, collection.models.newField('Front')) |
|
191 |
|
192 tmpl = collection.models.newTemplate('Front') |
|
193 tmpl['qfmt'] = '<div class="front">{{Front}}</div>' |
|
194 tmpl['afmt'] = '{{FrontSide}}' |
|
195 collection.models.addTemplate(model, tmpl) |
|
196 |
|
197 model['id'] = model_id |
|
198 collection.models.update(model) |
|
199 collection.models.save(model) |
|
200 self.model = model |
|
201 |
|
202 def guid(self, num): |
|
203 h = hashlib.md5(":".join((self.name, str(num))).encode('utf-8')) |
|
204 return h.hexdigest() |
|
205 |
|
206 def add_note(self, num, front): |
|
207 note = anki.notes.Note(self.collection, self.model) |
|
208 note['Front'] = front |
|
209 note.guid = self.guid(num) |
|
210 self.collection.addNote(note) |
|
211 |
|
212 def export(self, fname): |
|
213 export = AnkiPackageExporter(self.collection) |
|
214 export.exportInto(fname) |
|
215 |
|
216 def close(self): |
|
217 self.collection.close() |
|
218 |
|
219 def write_lines(buf, lines): |
|
220 odd = True |
|
221 for line in lines: |
|
222 if odd: |
|
223 buf.append("<div class='line odd'>") |
|
224 else: |
|
225 buf.append("<div class='line even'>") |
|
226 buf.append("- ") |
|
227 buf.append(line) |
|
228 buf.append("</div>") |
|
229 odd = not odd |
|
230 |
|
231 # Looks like anki libs change working directory to media directory of current deck |
|
232 # Therefore absolute path should be stored before creating temporary deck |
|
233 FONAME = os.path.abspath(FONAME) |
|
234 TMPDIR = tempfile.mkdtemp(dir=os.path.dirname(FONAME)) |
|
235 |
|
236 try: |
|
237 BUILDER = AnkiDbBuilder(TMPDIR, NAME) |
|
238 |
|
239 for num, lines in DOM.items(): |
|
240 buf = [] |
|
241 write_lines(buf, lines) |
|
242 front = "".join(buf) |
|
243 BUILDER.add_note(num, front) |
|
244 BUILDER.export(FONAME) |
|
245 finally: |
|
246 BUILDER.close() |
|
247 shutil.rmtree(TMPDIR, ignore_errors=True) |
|
248 |