├── .gitignore
├── LICENSE
├── README.md
├── const.py
├── diffhandler.py
├── grampswebsync.gpr.py
├── grampswebsync.py
├── po
├── de-local.po
└── de.po
└── webapihandler.py
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | lib/
18 | lib64/
19 | parts/
20 | sdist/
21 | var/
22 | wheels/
23 | pip-wheel-metadata/
24 | share/python-wheels/
25 | *.egg-info/
26 | .installed.cfg
27 | *.egg
28 | MANIFEST
29 |
30 | # PyInstaller
31 | # Usually these files are written by a python script from a template
32 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
33 | *.manifest
34 | *.spec
35 |
36 | # Installer logs
37 | pip-log.txt
38 | pip-delete-this-directory.txt
39 |
40 | # Unit test / coverage reports
41 | htmlcov/
42 | .tox/
43 | .nox/
44 | .coverage
45 | .coverage.*
46 | .cache
47 | nosetests.xml
48 | coverage.xml
49 | *.cover
50 | *.py,cover
51 | .hypothesis/
52 | .pytest_cache/
53 |
54 | # Translations
55 | *.mo
56 | *.pot
57 |
58 | # Django stuff:
59 | *.log
60 | local_settings.py
61 | db.sqlite3
62 | db.sqlite3-journal
63 |
64 | # Flask stuff:
65 | instance/
66 | .webassets-cache
67 |
68 | # Scrapy stuff:
69 | .scrapy
70 |
71 | # Sphinx documentation
72 | docs/_build/
73 |
74 | # PyBuilder
75 | target/
76 |
77 | # Jupyter Notebook
78 | .ipynb_checkpoints
79 |
80 | # IPython
81 | profile_default/
82 | ipython_config.py
83 |
84 | # pyenv
85 | .python-version
86 |
87 | # pipenv
88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
91 | # install all needed dependencies.
92 | #Pipfile.lock
93 |
94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow
95 | __pypackages__/
96 |
97 | # Celery stuff
98 | celerybeat-schedule
99 | celerybeat.pid
100 |
101 | # SageMath parsed files
102 | *.sage.py
103 |
104 | # Environments
105 | .env
106 | .venv
107 | env/
108 | venv/
109 | ENV/
110 | env.bak/
111 | venv.bak/
112 |
113 | # Spyder project settings
114 | .spyderproject
115 | .spyproject
116 |
117 | # Rope project settings
118 | .ropeproject
119 |
120 | # mkdocs documentation
121 | /site
122 |
123 | # mypy
124 | .mypy_cache/
125 | .dmypy.json
126 | dmypy.json
127 |
128 | # Pyre type checker
129 | .pyre/
130 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | GNU GENERAL PUBLIC LICENSE
2 | Version 3, 29 June 2007
3 |
4 | Copyright (C) 2007 Free Software Foundation, Inc.
5 | Everyone is permitted to copy and distribute verbatim copies
6 | of this license document, but changing it is not allowed.
7 |
8 | Preamble
9 |
10 | The GNU General Public License is a free, copyleft license for
11 | software and other kinds of works.
12 |
13 | The licenses for most software and other practical works are designed
14 | to take away your freedom to share and change the works. By contrast,
15 | the GNU General Public License is intended to guarantee your freedom to
16 | share and change all versions of a program--to make sure it remains free
17 | software for all its users. We, the Free Software Foundation, use the
18 | GNU General Public License for most of our software; it applies also to
19 | any other work released this way by its authors. You can apply it to
20 | your programs, too.
21 |
22 | When we speak of free software, we are referring to freedom, not
23 | price. Our General Public Licenses are designed to make sure that you
24 | have the freedom to distribute copies of free software (and charge for
25 | them if you wish), that you receive source code or can get it if you
26 | want it, that you can change the software or use pieces of it in new
27 | free programs, and that you know you can do these things.
28 |
29 | To protect your rights, we need to prevent others from denying you
30 | these rights or asking you to surrender the rights. Therefore, you have
31 | certain responsibilities if you distribute copies of the software, or if
32 | you modify it: responsibilities to respect the freedom of others.
33 |
34 | For example, if you distribute copies of such a program, whether
35 | gratis or for a fee, you must pass on to the recipients the same
36 | freedoms that you received. You must make sure that they, too, receive
37 | or can get the source code. And you must show them these terms so they
38 | know their rights.
39 |
40 | Developers that use the GNU GPL protect your rights with two steps:
41 | (1) assert copyright on the software, and (2) offer you this License
42 | giving you legal permission to copy, distribute and/or modify it.
43 |
44 | For the developers' and authors' protection, the GPL clearly explains
45 | that there is no warranty for this free software. For both users' and
46 | authors' sake, the GPL requires that modified versions be marked as
47 | changed, so that their problems will not be attributed erroneously to
48 | authors of previous versions.
49 |
50 | Some devices are designed to deny users access to install or run
51 | modified versions of the software inside them, although the manufacturer
52 | can do so. This is fundamentally incompatible with the aim of
53 | protecting users' freedom to change the software. The systematic
54 | pattern of such abuse occurs in the area of products for individuals to
55 | use, which is precisely where it is most unacceptable. Therefore, we
56 | have designed this version of the GPL to prohibit the practice for those
57 | products. If such problems arise substantially in other domains, we
58 | stand ready to extend this provision to those domains in future versions
59 | of the GPL, as needed to protect the freedom of users.
60 |
61 | Finally, every program is threatened constantly by software patents.
62 | States should not allow patents to restrict development and use of
63 | software on general-purpose computers, but in those that do, we wish to
64 | avoid the special danger that patents applied to a free program could
65 | make it effectively proprietary. To prevent this, the GPL assures that
66 | patents cannot be used to render the program non-free.
67 |
68 | The precise terms and conditions for copying, distribution and
69 | modification follow.
70 |
71 | TERMS AND CONDITIONS
72 |
73 | 0. Definitions.
74 |
75 | "This License" refers to version 3 of the GNU General Public License.
76 |
77 | "Copyright" also means copyright-like laws that apply to other kinds of
78 | works, such as semiconductor masks.
79 |
80 | "The Program" refers to any copyrightable work licensed under this
81 | License. Each licensee is addressed as "you". "Licensees" and
82 | "recipients" may be individuals or organizations.
83 |
84 | To "modify" a work means to copy from or adapt all or part of the work
85 | in a fashion requiring copyright permission, other than the making of an
86 | exact copy. The resulting work is called a "modified version" of the
87 | earlier work or a work "based on" the earlier work.
88 |
89 | A "covered work" means either the unmodified Program or a work based
90 | on the Program.
91 |
92 | To "propagate" a work means to do anything with it that, without
93 | permission, would make you directly or secondarily liable for
94 | infringement under applicable copyright law, except executing it on a
95 | computer or modifying a private copy. Propagation includes copying,
96 | distribution (with or without modification), making available to the
97 | public, and in some countries other activities as well.
98 |
99 | To "convey" a work means any kind of propagation that enables other
100 | parties to make or receive copies. Mere interaction with a user through
101 | a computer network, with no transfer of a copy, is not conveying.
102 |
103 | An interactive user interface displays "Appropriate Legal Notices"
104 | to the extent that it includes a convenient and prominently visible
105 | feature that (1) displays an appropriate copyright notice, and (2)
106 | tells the user that there is no warranty for the work (except to the
107 | extent that warranties are provided), that licensees may convey the
108 | work under this License, and how to view a copy of this License. If
109 | the interface presents a list of user commands or options, such as a
110 | menu, a prominent item in the list meets this criterion.
111 |
112 | 1. Source Code.
113 |
114 | The "source code" for a work means the preferred form of the work
115 | for making modifications to it. "Object code" means any non-source
116 | form of a work.
117 |
118 | A "Standard Interface" means an interface that either is an official
119 | standard defined by a recognized standards body, or, in the case of
120 | interfaces specified for a particular programming language, one that
121 | is widely used among developers working in that language.
122 |
123 | The "System Libraries" of an executable work include anything, other
124 | than the work as a whole, that (a) is included in the normal form of
125 | packaging a Major Component, but which is not part of that Major
126 | Component, and (b) serves only to enable use of the work with that
127 | Major Component, or to implement a Standard Interface for which an
128 | implementation is available to the public in source code form. A
129 | "Major Component", in this context, means a major essential component
130 | (kernel, window system, and so on) of the specific operating system
131 | (if any) on which the executable work runs, or a compiler used to
132 | produce the work, or an object code interpreter used to run it.
133 |
134 | The "Corresponding Source" for a work in object code form means all
135 | the source code needed to generate, install, and (for an executable
136 | work) run the object code and to modify the work, including scripts to
137 | control those activities. However, it does not include the work's
138 | System Libraries, or general-purpose tools or generally available free
139 | programs which are used unmodified in performing those activities but
140 | which are not part of the work. For example, Corresponding Source
141 | includes interface definition files associated with source files for
142 | the work, and the source code for shared libraries and dynamically
143 | linked subprograms that the work is specifically designed to require,
144 | such as by intimate data communication or control flow between those
145 | subprograms and other parts of the work.
146 |
147 | The Corresponding Source need not include anything that users
148 | can regenerate automatically from other parts of the Corresponding
149 | Source.
150 |
151 | The Corresponding Source for a work in source code form is that
152 | same work.
153 |
154 | 2. Basic Permissions.
155 |
156 | All rights granted under this License are granted for the term of
157 | copyright on the Program, and are irrevocable provided the stated
158 | conditions are met. This License explicitly affirms your unlimited
159 | permission to run the unmodified Program. The output from running a
160 | covered work is covered by this License only if the output, given its
161 | content, constitutes a covered work. This License acknowledges your
162 | rights of fair use or other equivalent, as provided by copyright law.
163 |
164 | You may make, run and propagate covered works that you do not
165 | convey, without conditions so long as your license otherwise remains
166 | in force. You may convey covered works to others for the sole purpose
167 | of having them make modifications exclusively for you, or provide you
168 | with facilities for running those works, provided that you comply with
169 | the terms of this License in conveying all material for which you do
170 | not control copyright. Those thus making or running the covered works
171 | for you must do so exclusively on your behalf, under your direction
172 | and control, on terms that prohibit them from making any copies of
173 | your copyrighted material outside their relationship with you.
174 |
175 | Conveying under any other circumstances is permitted solely under
176 | the conditions stated below. Sublicensing is not allowed; section 10
177 | makes it unnecessary.
178 |
179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
180 |
181 | No covered work shall be deemed part of an effective technological
182 | measure under any applicable law fulfilling obligations under article
183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or
184 | similar laws prohibiting or restricting circumvention of such
185 | measures.
186 |
187 | When you convey a covered work, you waive any legal power to forbid
188 | circumvention of technological measures to the extent such circumvention
189 | is effected by exercising rights under this License with respect to
190 | the covered work, and you disclaim any intention to limit operation or
191 | modification of the work as a means of enforcing, against the work's
192 | users, your or third parties' legal rights to forbid circumvention of
193 | technological measures.
194 |
195 | 4. Conveying Verbatim Copies.
196 |
197 | You may convey verbatim copies of the Program's source code as you
198 | receive it, in any medium, provided that you conspicuously and
199 | appropriately publish on each copy an appropriate copyright notice;
200 | keep intact all notices stating that this License and any
201 | non-permissive terms added in accord with section 7 apply to the code;
202 | keep intact all notices of the absence of any warranty; and give all
203 | recipients a copy of this License along with the Program.
204 |
205 | You may charge any price or no price for each copy that you convey,
206 | and you may offer support or warranty protection for a fee.
207 |
208 | 5. Conveying Modified Source Versions.
209 |
210 | You may convey a work based on the Program, or the modifications to
211 | produce it from the Program, in the form of source code under the
212 | terms of section 4, provided that you also meet all of these conditions:
213 |
214 | a) The work must carry prominent notices stating that you modified
215 | it, and giving a relevant date.
216 |
217 | b) The work must carry prominent notices stating that it is
218 | released under this License and any conditions added under section
219 | 7. This requirement modifies the requirement in section 4 to
220 | "keep intact all notices".
221 |
222 | c) You must license the entire work, as a whole, under this
223 | License to anyone who comes into possession of a copy. This
224 | License will therefore apply, along with any applicable section 7
225 | additional terms, to the whole of the work, and all its parts,
226 | regardless of how they are packaged. This License gives no
227 | permission to license the work in any other way, but it does not
228 | invalidate such permission if you have separately received it.
229 |
230 | d) If the work has interactive user interfaces, each must display
231 | Appropriate Legal Notices; however, if the Program has interactive
232 | interfaces that do not display Appropriate Legal Notices, your
233 | work need not make them do so.
234 |
235 | A compilation of a covered work with other separate and independent
236 | works, which are not by their nature extensions of the covered work,
237 | and which are not combined with it such as to form a larger program,
238 | in or on a volume of a storage or distribution medium, is called an
239 | "aggregate" if the compilation and its resulting copyright are not
240 | used to limit the access or legal rights of the compilation's users
241 | beyond what the individual works permit. Inclusion of a covered work
242 | in an aggregate does not cause this License to apply to the other
243 | parts of the aggregate.
244 |
245 | 6. Conveying Non-Source Forms.
246 |
247 | You may convey a covered work in object code form under the terms
248 | of sections 4 and 5, provided that you also convey the
249 | machine-readable Corresponding Source under the terms of this License,
250 | in one of these ways:
251 |
252 | a) Convey the object code in, or embodied in, a physical product
253 | (including a physical distribution medium), accompanied by the
254 | Corresponding Source fixed on a durable physical medium
255 | customarily used for software interchange.
256 |
257 | b) Convey the object code in, or embodied in, a physical product
258 | (including a physical distribution medium), accompanied by a
259 | written offer, valid for at least three years and valid for as
260 | long as you offer spare parts or customer support for that product
261 | model, to give anyone who possesses the object code either (1) a
262 | copy of the Corresponding Source for all the software in the
263 | product that is covered by this License, on a durable physical
264 | medium customarily used for software interchange, for a price no
265 | more than your reasonable cost of physically performing this
266 | conveying of source, or (2) access to copy the
267 | Corresponding Source from a network server at no charge.
268 |
269 | c) Convey individual copies of the object code with a copy of the
270 | written offer to provide the Corresponding Source. This
271 | alternative is allowed only occasionally and noncommercially, and
272 | only if you received the object code with such an offer, in accord
273 | with subsection 6b.
274 |
275 | d) Convey the object code by offering access from a designated
276 | place (gratis or for a charge), and offer equivalent access to the
277 | Corresponding Source in the same way through the same place at no
278 | further charge. You need not require recipients to copy the
279 | Corresponding Source along with the object code. If the place to
280 | copy the object code is a network server, the Corresponding Source
281 | may be on a different server (operated by you or a third party)
282 | that supports equivalent copying facilities, provided you maintain
283 | clear directions next to the object code saying where to find the
284 | Corresponding Source. Regardless of what server hosts the
285 | Corresponding Source, you remain obligated to ensure that it is
286 | available for as long as needed to satisfy these requirements.
287 |
288 | e) Convey the object code using peer-to-peer transmission, provided
289 | you inform other peers where the object code and Corresponding
290 | Source of the work are being offered to the general public at no
291 | charge under subsection 6d.
292 |
293 | A separable portion of the object code, whose source code is excluded
294 | from the Corresponding Source as a System Library, need not be
295 | included in conveying the object code work.
296 |
297 | A "User Product" is either (1) a "consumer product", which means any
298 | tangible personal property which is normally used for personal, family,
299 | or household purposes, or (2) anything designed or sold for incorporation
300 | into a dwelling. In determining whether a product is a consumer product,
301 | doubtful cases shall be resolved in favor of coverage. For a particular
302 | product received by a particular user, "normally used" refers to a
303 | typical or common use of that class of product, regardless of the status
304 | of the particular user or of the way in which the particular user
305 | actually uses, or expects or is expected to use, the product. A product
306 | is a consumer product regardless of whether the product has substantial
307 | commercial, industrial or non-consumer uses, unless such uses represent
308 | the only significant mode of use of the product.
309 |
310 | "Installation Information" for a User Product means any methods,
311 | procedures, authorization keys, or other information required to install
312 | and execute modified versions of a covered work in that User Product from
313 | a modified version of its Corresponding Source. The information must
314 | suffice to ensure that the continued functioning of the modified object
315 | code is in no case prevented or interfered with solely because
316 | modification has been made.
317 |
318 | If you convey an object code work under this section in, or with, or
319 | specifically for use in, a User Product, and the conveying occurs as
320 | part of a transaction in which the right of possession and use of the
321 | User Product is transferred to the recipient in perpetuity or for a
322 | fixed term (regardless of how the transaction is characterized), the
323 | Corresponding Source conveyed under this section must be accompanied
324 | by the Installation Information. But this requirement does not apply
325 | if neither you nor any third party retains the ability to install
326 | modified object code on the User Product (for example, the work has
327 | been installed in ROM).
328 |
329 | The requirement to provide Installation Information does not include a
330 | requirement to continue to provide support service, warranty, or updates
331 | for a work that has been modified or installed by the recipient, or for
332 | the User Product in which it has been modified or installed. Access to a
333 | network may be denied when the modification itself materially and
334 | adversely affects the operation of the network or violates the rules and
335 | protocols for communication across the network.
336 |
337 | Corresponding Source conveyed, and Installation Information provided,
338 | in accord with this section must be in a format that is publicly
339 | documented (and with an implementation available to the public in
340 | source code form), and must require no special password or key for
341 | unpacking, reading or copying.
342 |
343 | 7. Additional Terms.
344 |
345 | "Additional permissions" are terms that supplement the terms of this
346 | License by making exceptions from one or more of its conditions.
347 | Additional permissions that are applicable to the entire Program shall
348 | be treated as though they were included in this License, to the extent
349 | that they are valid under applicable law. If additional permissions
350 | apply only to part of the Program, that part may be used separately
351 | under those permissions, but the entire Program remains governed by
352 | this License without regard to the additional permissions.
353 |
354 | When you convey a copy of a covered work, you may at your option
355 | remove any additional permissions from that copy, or from any part of
356 | it. (Additional permissions may be written to require their own
357 | removal in certain cases when you modify the work.) You may place
358 | additional permissions on material, added by you to a covered work,
359 | for which you have or can give appropriate copyright permission.
360 |
361 | Notwithstanding any other provision of this License, for material you
362 | add to a covered work, you may (if authorized by the copyright holders of
363 | that material) supplement the terms of this License with terms:
364 |
365 | a) Disclaiming warranty or limiting liability differently from the
366 | terms of sections 15 and 16 of this License; or
367 |
368 | b) Requiring preservation of specified reasonable legal notices or
369 | author attributions in that material or in the Appropriate Legal
370 | Notices displayed by works containing it; or
371 |
372 | c) Prohibiting misrepresentation of the origin of that material, or
373 | requiring that modified versions of such material be marked in
374 | reasonable ways as different from the original version; or
375 |
376 | d) Limiting the use for publicity purposes of names of licensors or
377 | authors of the material; or
378 |
379 | e) Declining to grant rights under trademark law for use of some
380 | trade names, trademarks, or service marks; or
381 |
382 | f) Requiring indemnification of licensors and authors of that
383 | material by anyone who conveys the material (or modified versions of
384 | it) with contractual assumptions of liability to the recipient, for
385 | any liability that these contractual assumptions directly impose on
386 | those licensors and authors.
387 |
388 | All other non-permissive additional terms are considered "further
389 | restrictions" within the meaning of section 10. If the Program as you
390 | received it, or any part of it, contains a notice stating that it is
391 | governed by this License along with a term that is a further
392 | restriction, you may remove that term. If a license document contains
393 | a further restriction but permits relicensing or conveying under this
394 | License, you may add to a covered work material governed by the terms
395 | of that license document, provided that the further restriction does
396 | not survive such relicensing or conveying.
397 |
398 | If you add terms to a covered work in accord with this section, you
399 | must place, in the relevant source files, a statement of the
400 | additional terms that apply to those files, or a notice indicating
401 | where to find the applicable terms.
402 |
403 | Additional terms, permissive or non-permissive, may be stated in the
404 | form of a separately written license, or stated as exceptions;
405 | the above requirements apply either way.
406 |
407 | 8. Termination.
408 |
409 | You may not propagate or modify a covered work except as expressly
410 | provided under this License. Any attempt otherwise to propagate or
411 | modify it is void, and will automatically terminate your rights under
412 | this License (including any patent licenses granted under the third
413 | paragraph of section 11).
414 |
415 | However, if you cease all violation of this License, then your
416 | license from a particular copyright holder is reinstated (a)
417 | provisionally, unless and until the copyright holder explicitly and
418 | finally terminates your license, and (b) permanently, if the copyright
419 | holder fails to notify you of the violation by some reasonable means
420 | prior to 60 days after the cessation.
421 |
422 | Moreover, your license from a particular copyright holder is
423 | reinstated permanently if the copyright holder notifies you of the
424 | violation by some reasonable means, this is the first time you have
425 | received notice of violation of this License (for any work) from that
426 | copyright holder, and you cure the violation prior to 30 days after
427 | your receipt of the notice.
428 |
429 | Termination of your rights under this section does not terminate the
430 | licenses of parties who have received copies or rights from you under
431 | this License. If your rights have been terminated and not permanently
432 | reinstated, you do not qualify to receive new licenses for the same
433 | material under section 10.
434 |
435 | 9. Acceptance Not Required for Having Copies.
436 |
437 | You are not required to accept this License in order to receive or
438 | run a copy of the Program. Ancillary propagation of a covered work
439 | occurring solely as a consequence of using peer-to-peer transmission
440 | to receive a copy likewise does not require acceptance. However,
441 | nothing other than this License grants you permission to propagate or
442 | modify any covered work. These actions infringe copyright if you do
443 | not accept this License. Therefore, by modifying or propagating a
444 | covered work, you indicate your acceptance of this License to do so.
445 |
446 | 10. Automatic Licensing of Downstream Recipients.
447 |
448 | Each time you convey a covered work, the recipient automatically
449 | receives a license from the original licensors, to run, modify and
450 | propagate that work, subject to this License. You are not responsible
451 | for enforcing compliance by third parties with this License.
452 |
453 | An "entity transaction" is a transaction transferring control of an
454 | organization, or substantially all assets of one, or subdividing an
455 | organization, or merging organizations. If propagation of a covered
456 | work results from an entity transaction, each party to that
457 | transaction who receives a copy of the work also receives whatever
458 | licenses to the work the party's predecessor in interest had or could
459 | give under the previous paragraph, plus a right to possession of the
460 | Corresponding Source of the work from the predecessor in interest, if
461 | the predecessor has it or can get it with reasonable efforts.
462 |
463 | You may not impose any further restrictions on the exercise of the
464 | rights granted or affirmed under this License. For example, you may
465 | not impose a license fee, royalty, or other charge for exercise of
466 | rights granted under this License, and you may not initiate litigation
467 | (including a cross-claim or counterclaim in a lawsuit) alleging that
468 | any patent claim is infringed by making, using, selling, offering for
469 | sale, or importing the Program or any portion of it.
470 |
471 | 11. Patents.
472 |
473 | A "contributor" is a copyright holder who authorizes use under this
474 | License of the Program or a work on which the Program is based. The
475 | work thus licensed is called the contributor's "contributor version".
476 |
477 | A contributor's "essential patent claims" are all patent claims
478 | owned or controlled by the contributor, whether already acquired or
479 | hereafter acquired, that would be infringed by some manner, permitted
480 | by this License, of making, using, or selling its contributor version,
481 | but do not include claims that would be infringed only as a
482 | consequence of further modification of the contributor version. For
483 | purposes of this definition, "control" includes the right to grant
484 | patent sublicenses in a manner consistent with the requirements of
485 | this License.
486 |
487 | Each contributor grants you a non-exclusive, worldwide, royalty-free
488 | patent license under the contributor's essential patent claims, to
489 | make, use, sell, offer for sale, import and otherwise run, modify and
490 | propagate the contents of its contributor version.
491 |
492 | In the following three paragraphs, a "patent license" is any express
493 | agreement or commitment, however denominated, not to enforce a patent
494 | (such as an express permission to practice a patent or covenant not to
495 | sue for patent infringement). To "grant" such a patent license to a
496 | party means to make such an agreement or commitment not to enforce a
497 | patent against the party.
498 |
499 | If you convey a covered work, knowingly relying on a patent license,
500 | and the Corresponding Source of the work is not available for anyone
501 | to copy, free of charge and under the terms of this License, through a
502 | publicly available network server or other readily accessible means,
503 | then you must either (1) cause the Corresponding Source to be so
504 | available, or (2) arrange to deprive yourself of the benefit of the
505 | patent license for this particular work, or (3) arrange, in a manner
506 | consistent with the requirements of this License, to extend the patent
507 | license to downstream recipients. "Knowingly relying" means you have
508 | actual knowledge that, but for the patent license, your conveying the
509 | covered work in a country, or your recipient's use of the covered work
510 | in a country, would infringe one or more identifiable patents in that
511 | country that you have reason to believe are valid.
512 |
513 | If, pursuant to or in connection with a single transaction or
514 | arrangement, you convey, or propagate by procuring conveyance of, a
515 | covered work, and grant a patent license to some of the parties
516 | receiving the covered work authorizing them to use, propagate, modify
517 | or convey a specific copy of the covered work, then the patent license
518 | you grant is automatically extended to all recipients of the covered
519 | work and works based on it.
520 |
521 | A patent license is "discriminatory" if it does not include within
522 | the scope of its coverage, prohibits the exercise of, or is
523 | conditioned on the non-exercise of one or more of the rights that are
524 | specifically granted under this License. You may not convey a covered
525 | work if you are a party to an arrangement with a third party that is
526 | in the business of distributing software, under which you make payment
527 | to the third party based on the extent of your activity of conveying
528 | the work, and under which the third party grants, to any of the
529 | parties who would receive the covered work from you, a discriminatory
530 | patent license (a) in connection with copies of the covered work
531 | conveyed by you (or copies made from those copies), or (b) primarily
532 | for and in connection with specific products or compilations that
533 | contain the covered work, unless you entered into that arrangement,
534 | or that patent license was granted, prior to 28 March 2007.
535 |
536 | Nothing in this License shall be construed as excluding or limiting
537 | any implied license or other defenses to infringement that may
538 | otherwise be available to you under applicable patent law.
539 |
540 | 12. No Surrender of Others' Freedom.
541 |
542 | If conditions are imposed on you (whether by court order, agreement or
543 | otherwise) that contradict the conditions of this License, they do not
544 | excuse you from the conditions of this License. If you cannot convey a
545 | covered work so as to satisfy simultaneously your obligations under this
546 | License and any other pertinent obligations, then as a consequence you may
547 | not convey it at all. For example, if you agree to terms that obligate you
548 | to collect a royalty for further conveying from those to whom you convey
549 | the Program, the only way you could satisfy both those terms and this
550 | License would be to refrain entirely from conveying the Program.
551 |
552 | 13. Use with the GNU Affero General Public License.
553 |
554 | Notwithstanding any other provision of this License, you have
555 | permission to link or combine any covered work with a work licensed
556 | under version 3 of the GNU Affero General Public License into a single
557 | combined work, and to convey the resulting work. The terms of this
558 | License will continue to apply to the part which is the covered work,
559 | but the special requirements of the GNU Affero General Public License,
560 | section 13, concerning interaction through a network will apply to the
561 | combination as such.
562 |
563 | 14. Revised Versions of this License.
564 |
565 | The Free Software Foundation may publish revised and/or new versions of
566 | the GNU General Public License from time to time. Such new versions will
567 | be similar in spirit to the present version, but may differ in detail to
568 | address new problems or concerns.
569 |
570 | Each version is given a distinguishing version number. If the
571 | Program specifies that a certain numbered version of the GNU General
572 | Public License "or any later version" applies to it, you have the
573 | option of following the terms and conditions either of that numbered
574 | version or of any later version published by the Free Software
575 | Foundation. If the Program does not specify a version number of the
576 | GNU General Public License, you may choose any version ever published
577 | by the Free Software Foundation.
578 |
579 | If the Program specifies that a proxy can decide which future
580 | versions of the GNU General Public License can be used, that proxy's
581 | public statement of acceptance of a version permanently authorizes you
582 | to choose that version for the Program.
583 |
584 | Later license versions may give you additional or different
585 | permissions. However, no additional obligations are imposed on any
586 | author or copyright holder as a result of your choosing to follow a
587 | later version.
588 |
589 | 15. Disclaimer of Warranty.
590 |
591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
599 |
600 | 16. Limitation of Liability.
601 |
602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
610 | SUCH DAMAGES.
611 |
612 | 17. Interpretation of Sections 15 and 16.
613 |
614 | If the disclaimer of warranty and limitation of liability provided
615 | above cannot be given local legal effect according to their terms,
616 | reviewing courts shall apply local law that most closely approximates
617 | an absolute waiver of all civil liability in connection with the
618 | Program, unless a warranty or assumption of liability accompanies a
619 | copy of the Program in return for a fee.
620 |
621 | END OF TERMS AND CONDITIONS
622 |
623 | How to Apply These Terms to Your New Programs
624 |
625 | If you develop a new program, and you want it to be of the greatest
626 | possible use to the public, the best way to achieve this is to make it
627 | free software which everyone can redistribute and change under these terms.
628 |
629 | To do so, attach the following notices to the program. It is safest
630 | to attach them to the start of each source file to most effectively
631 | state the exclusion of warranty; and each file should have at least
632 | the "copyright" line and a pointer to where the full notice is found.
633 |
634 |
635 | Copyright (C)
636 |
637 | This program is free software: you can redistribute it and/or modify
638 | it under the terms of the GNU General Public License as published by
639 | the Free Software Foundation, either version 3 of the License, or
640 | (at your option) any later version.
641 |
642 | This program is distributed in the hope that it will be useful,
643 | but WITHOUT ANY WARRANTY; without even the implied warranty of
644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
645 | GNU General Public License for more details.
646 |
647 | You should have received a copy of the GNU General Public License
648 | along with this program. If not, see .
649 |
650 | Also add information on how to contact you by electronic and paper mail.
651 |
652 | If the program does terminal interaction, make it output a short
653 | notice like this when it starts in an interactive mode:
654 |
655 | Copyright (C)
656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
657 | This is free software, and you are welcome to redistribute it
658 | under certain conditions; type `show c' for details.
659 |
660 | The hypothetical commands `show w' and `show c' should show the appropriate
661 | parts of the General Public License. Of course, your program's commands
662 | might be different; for a GUI interface, you would use an "about box".
663 |
664 | You should also get your employer (if you work as a programmer) or school,
665 | if any, to sign a "copyright disclaimer" for the program, if necessary.
666 | For more information on this, and how to apply and follow the GNU GPL, see
667 | .
668 |
669 | The GNU General Public License does not permit incorporating your program
670 | into proprietary programs. If your program is a subroutine library, you
671 | may consider it more useful to permit linking proprietary applications with
672 | the library. If this is what you want to do, use the GNU Lesser General
673 | Public License instead of this License. But first, please read
674 | .
675 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ## Gramps Web Sync Addon
2 |
3 | ### Update January 2025
4 |
5 | This repository is no longer used for the development version of the Gramps Web Sync Addon.
6 |
7 | Please use the version from the official Gramps addon repository, installed via the Gramps addon manager.
8 |
9 | See https://www.grampsweb.org/administration/sync/ for the addon documentation.
10 |
--------------------------------------------------------------------------------
/const.py:
--------------------------------------------------------------------------------
1 | """Constants for Gramps Web Sync."""
2 |
3 |
4 | class TypeMeta(type):
5 | """Workaround for missing typing module in Gramps AIO."""
6 |
7 | def __getitem__(self, *args, **kwargs):
8 | return self
9 |
10 |
11 | class Type(metaclass=TypeMeta):
12 | """Workaround for missing typing module in Gramps AIO."""
13 |
14 |
15 | try:
16 | from typing import List, Optional, Tuple
17 | except ImportError:
18 | List = Type
19 | Optional = Type
20 | Tuple = Type
21 | from gramps.gen.lib.primaryobj import BasicPrimaryObject
22 |
23 |
24 | # types
25 | GrampsObject = BasicPrimaryObject
26 | Action = Tuple[str, str, str, Optional[GrampsObject], Optional[GrampsObject]]
27 | Actions = List[Action]
28 |
29 |
30 | # changed: added, deleteed, updated - local/remote/both
31 | C_ADD_LOC = "added_local"
32 | C_ADD_REM = "added_remote"
33 | C_DEL_LOC = "deleted_local"
34 | C_DEL_REM = "deleted_remote"
35 | C_UPD_LOC = "updated_local"
36 | C_UPD_REM = "updated_remote"
37 | C_UPD_BOTH = "updated_both"
38 |
39 | # actions: add, delete, update, merge - local/remote
40 | A_ADD_LOC = "add_local"
41 | A_ADD_REM = "add_remote"
42 | A_DEL_LOC = "del_local"
43 | A_DEL_REM = "del_remote"
44 | A_UPD_LOC = "upd_local"
45 | A_UPD_REM = "upd_remote"
46 | A_MRG_REM = "mrg_remote"
47 |
48 |
49 | OBJ_LST = [
50 | "Family",
51 | "Person",
52 | "Citation",
53 | "Event",
54 | "Media",
55 | "Note",
56 | "Place",
57 | "Repository",
58 | "Source",
59 | "Tag",
60 | ]
61 |
62 | # sync modes
63 | MODE_BIDIRECTIONAL = 0
64 | MODE_RESET_TO_LOCAL = 1
65 | MODE_RESET_TO_REMOTE = 2
66 |
--------------------------------------------------------------------------------
/diffhandler.py:
--------------------------------------------------------------------------------
1 | """Class managing the difference between two databases."""
2 |
3 | from copy import deepcopy
4 |
5 | try:
6 | from typing import List, Optional, Set, Tuple
7 | except ImportError:
8 | from const import Type
9 |
10 | List = Type
11 | Optional = Type
12 | Set = Type
13 | Tuple = Type
14 |
15 | from gramps.gen.db import DbTxn
16 | from gramps.gen.db.base import DbReadBase
17 | from gramps.gen.merge.diff import diff_dbs
18 | from gramps.gen.user import User
19 |
20 | from const import (
21 | A_ADD_LOC,
22 | A_ADD_REM,
23 | A_DEL_LOC,
24 | A_DEL_REM,
25 | A_MRG_REM,
26 | A_UPD_LOC,
27 | A_UPD_REM,
28 | C_ADD_LOC,
29 | C_ADD_REM,
30 | C_DEL_LOC,
31 | C_DEL_REM,
32 | C_UPD_BOTH,
33 | C_UPD_LOC,
34 | C_UPD_REM,
35 | MODE_BIDIRECTIONAL,
36 | MODE_RESET_TO_LOCAL,
37 | MODE_RESET_TO_REMOTE,
38 | OBJ_LST,
39 | Action,
40 | Actions,
41 | GrampsObject,
42 | )
43 |
44 |
45 | class WebApiSyncDiffHandler:
46 | """Class managing the difference between two databases."""
47 |
48 | def __init__(
49 | self,
50 | db1: DbReadBase,
51 | db2: DbReadBase,
52 | user: User,
53 | last_synced: Optional[float] = None,
54 | ) -> None:
55 | """Initialize given the two databases and a User instance."""
56 | self.db1 = db1
57 | self.db2 = db2
58 | self.user = user
59 | self._diff_dbs = self.get_diff_dbs()
60 | self.differences = {
61 | (obj1.handle, obj_type): (obj1, obj2)
62 | for (obj_type, obj1, obj2) in self._diff_dbs[0]
63 | }
64 | self.missing_from_db1 = {
65 | (obj.handle, obj_type): obj for (obj_type, obj) in self._diff_dbs[1]
66 | }
67 | self.missing_from_db2 = {
68 | (obj.handle, obj_type): obj for (obj_type, obj) in self._diff_dbs[2]
69 | }
70 | self._latest_common_timestamp = self.get_latest_common_timestamp()
71 | if last_synced and last_synced > self._latest_common_timestamp:
72 | # if the last sync timestamp in the config is later than
73 | # the latest common timestamp, use it
74 | self._latest_common_timestamp = last_synced
75 |
76 | def get_diff_dbs(
77 | self,
78 | ) -> Tuple[
79 | List[Tuple[str, GrampsObject, GrampsObject]],
80 | List[Tuple[str, GrampsObject]],
81 | List[Tuple[str, GrampsObject]],
82 | ]:
83 | """Return a database diff tuple: changed, missing from 1, missing from 2."""
84 | return diff_dbs(self.db1, self.db2, user=self.user)
85 |
86 | def get_latest_common_timestamp(self) -> int:
87 | """Get the timestamp of the latest common object."""
88 | dates = [
89 | self._get_latest_common_timestamp(class_name) or 0 for class_name in OBJ_LST
90 | ]
91 | return max(dates)
92 |
93 | def _get_latest_common_timestamp(self, class_name: str) -> int:
94 | """Get the timestamp of the latest common object of given type."""
95 | handles_func = self.db1.method("get_%s_handles", class_name)
96 | handle_func = self.db1.method("get_%s_from_handle", class_name)
97 | handle_func_db2 = self.db2.method("get_%s_from_handle", class_name)
98 | # all handles in db1
99 | all_handles = set(handles_func())
100 | # all handles missing in db2
101 | missing_in_db2 = set(
102 | handle
103 | for handle, obj_type in self.missing_from_db2.keys()
104 | if obj_type == class_name
105 | )
106 | # all handles of objects that are different
107 | different = set(
108 | handle
109 | for handle, obj_type in self.differences.keys()
110 | if obj_type == class_name
111 | )
112 | # handles of all objects that are the same
113 | same_handles = all_handles - missing_in_db2 - different
114 | if not same_handles:
115 | return None
116 | date = 0
117 | for handle in same_handles:
118 | obj = handle_func(handle)
119 | obj2 = handle_func_db2(handle)
120 | if obj.change == obj2.change: # make sure last mod dates are equal
121 | date = max(date, obj.change)
122 | return date
123 |
124 | @property
125 | def modified_in_db1(self) -> Set[Tuple[str, str]]:
126 | """Objects that have been modifed in db1."""
127 | return {
128 | k: (obj1, obj2)
129 | for k, (obj1, obj2) in self.differences.items()
130 | if obj1.change > self._latest_common_timestamp
131 | and obj2.change <= self._latest_common_timestamp
132 | }
133 |
134 | @property
135 | def modified_in_db2(self) -> Set[Tuple[GrampsObject, GrampsObject]]:
136 | """Objects that have been modifed in db1."""
137 | return {
138 | k: (obj1, obj2)
139 | for k, (obj1, obj2) in self.differences.items()
140 | if obj1.change <= self._latest_common_timestamp
141 | and obj2.change > self._latest_common_timestamp
142 | }
143 |
144 | @property
145 | def modified_in_both(self) -> Set[Tuple[GrampsObject, GrampsObject]]:
146 | """Objects that have been modifed in both databases."""
147 | return {
148 | k: v
149 | for k, v in self.differences.items()
150 | if k not in self.modified_in_db1 and k not in self.modified_in_db2
151 | }
152 |
153 | @property
154 | def added_to_db1(self) -> Set[GrampsObject]:
155 | """Objects that have been added to db1."""
156 | return {
157 | k: obj
158 | for (k, obj) in self.missing_from_db2.items()
159 | if obj.change > self._latest_common_timestamp
160 | }
161 |
162 | @property
163 | def added_to_db2(self) -> Set[GrampsObject]:
164 | """Objects that have been added to db2."""
165 | return {
166 | k: obj
167 | for (k, obj) in self.missing_from_db1.items()
168 | if obj.change > self._latest_common_timestamp
169 | }
170 |
171 | @property
172 | def deleted_from_db1(self) -> Set[GrampsObject]:
173 | """Objects that have been deleted from db1."""
174 | return {
175 | k: v for k, v in self.missing_from_db1.items() if k not in self.added_to_db2
176 | }
177 |
178 | @property
179 | def deleted_from_db2(self) -> Set[GrampsObject]:
180 | """Objects that have been deleted from db2."""
181 | return {
182 | k: v for k, v in self.missing_from_db2.items() if k not in self.added_to_db1
183 | }
184 |
185 | def get_changes(self) -> Actions:
186 | """Get a list of objects and corresponding changes."""
187 | lst = []
188 | for (handle, obj_type), (obj1, obj2) in self.modified_in_both.items():
189 | lst.append((C_UPD_BOTH, handle, obj_type, obj1, obj2))
190 | for (handle, obj_type), obj in self.added_to_db1.items():
191 | lst.append((C_ADD_LOC, handle, obj_type, obj, None))
192 | for (handle, obj_type), obj in self.added_to_db2.items():
193 | lst.append((C_ADD_REM, handle, obj_type, None, obj))
194 | for (handle, obj_type), obj in self.deleted_from_db1.items():
195 | lst.append((C_DEL_LOC, handle, obj_type, None, obj))
196 | for (handle, obj_type), obj in self.deleted_from_db2.items():
197 | lst.append((C_DEL_REM, handle, obj_type, obj, None))
198 | for (handle, obj_type), (obj1, obj2) in self.modified_in_db1.items():
199 | lst.append((C_UPD_LOC, handle, obj_type, obj1, obj2))
200 | for (handle, obj_type), (obj1, obj2) in self.modified_in_db2.items():
201 | lst.append((C_UPD_REM, handle, obj_type, obj1, obj2))
202 | return lst
203 |
204 | def get_actions(self) -> Actions:
205 | """Get a list of objects and corresponding actions."""
206 | lst = []
207 | for (handle, obj_type), (obj1, obj2) in self.modified_in_both.items():
208 | lst.append((A_MRG_REM, handle, obj_type, obj1, obj2))
209 | for (handle, obj_type), obj in self.added_to_db1.items():
210 | lst.append((A_ADD_REM, handle, obj_type, obj, None))
211 | for (handle, obj_type), obj in self.added_to_db2.items():
212 | lst.append((A_ADD_LOC, handle, obj_type, None, obj))
213 | for (handle, obj_type), obj in self.deleted_from_db1.items():
214 | lst.append((A_DEL_REM, handle, obj_type, None, obj))
215 | for (handle, obj_type), obj in self.deleted_from_db2.items():
216 | lst.append((A_DEL_LOC, handle, obj_type, obj, None))
217 | for (handle, obj_type), (obj1, obj2) in self.modified_in_db1.items():
218 | lst.append((A_UPD_REM, handle, obj_type, obj1, obj2))
219 | for (handle, obj_type), (obj1, obj2) in self.modified_in_db2.items():
220 | lst.append((A_UPD_LOC, handle, obj_type, obj1, obj2))
221 | return lst
222 |
223 | def changes_to_actions(self, changes, sync_mode: int) -> Actions:
224 | """Get actions from changes depending on sync mode."""
225 | if sync_mode == MODE_BIDIRECTIONAL:
226 | change_to_action = {
227 | C_UPD_BOTH: A_MRG_REM,
228 | C_ADD_LOC: A_ADD_REM,
229 | C_ADD_REM: A_ADD_LOC,
230 | C_DEL_LOC: A_DEL_REM,
231 | C_DEL_REM: A_DEL_LOC,
232 | C_UPD_LOC: A_UPD_REM,
233 | C_UPD_REM: A_UPD_LOC,
234 | }
235 | elif sync_mode == MODE_RESET_TO_LOCAL:
236 | change_to_action = {
237 | C_UPD_BOTH: A_UPD_REM,
238 | C_ADD_LOC: A_ADD_REM,
239 | C_ADD_REM: A_DEL_REM,
240 | C_DEL_LOC: A_DEL_REM,
241 | C_DEL_REM: A_ADD_REM,
242 | C_UPD_LOC: A_UPD_REM,
243 | C_UPD_REM: A_UPD_REM,
244 | }
245 | elif sync_mode == MODE_RESET_TO_REMOTE:
246 | change_to_action = {
247 | C_UPD_BOTH: A_UPD_LOC,
248 | C_ADD_LOC: A_DEL_LOC,
249 | C_ADD_REM: A_ADD_LOC,
250 | C_DEL_LOC: A_ADD_LOC,
251 | C_DEL_REM: A_DEL_LOC,
252 | C_UPD_LOC: A_UPD_LOC,
253 | C_UPD_REM: A_UPD_LOC,
254 | }
255 | actions = []
256 | for change in changes:
257 | change_type, handle, obj_type, obj1, obj2 = change
258 | action_type = change_to_action[change_type]
259 | action = action_type, handle, obj_type, obj1, obj2
260 | actions.append(action)
261 | return actions
262 |
263 | def commit_action(self, action: Action, trans1: DbTxn, trans2: DbTxn) -> None:
264 | """Commit an action into local and remote transaction objects."""
265 | typ, handle, obj_type, obj1, obj2 = action
266 | if typ == A_DEL_LOC:
267 | self.db1.method("remove_%s", obj_type)(handle, trans1)
268 | elif typ == A_DEL_REM:
269 | self.db2.method("remove_%s", obj_type)(handle, trans2)
270 | elif typ == A_ADD_LOC:
271 | self.db1.method("add_%s", obj_type)(obj2, trans1)
272 | elif typ == A_ADD_REM:
273 | self.db2.method("add_%s", obj_type)(obj1, trans2)
274 | elif typ == A_UPD_LOC:
275 | self.db1.method("commit_%s", obj_type)(obj2, trans1)
276 | elif typ == A_UPD_REM:
277 | self.db2.method("commit_%s", obj_type)(obj1, trans2)
278 | elif typ == A_MRG_REM:
279 | obj_merged = deepcopy(obj2)
280 | obj1_nogid = deepcopy(obj1)
281 | obj1_nogid.gramps_id = None
282 | obj_merged.merge(obj1_nogid)
283 | self.db1.method("commit_%s", obj_type)(obj_merged, trans1)
284 | self.db2.method("commit_%s", obj_type)(obj_merged, trans2)
285 |
286 | def commit_actions(self, actions: Actions, trans1: DbTxn, trans2: DbTxn) -> None:
287 | """Commit several actions into local and remote transaction objects."""
288 | for action in actions:
289 | self.commit_action(action, trans1, trans2)
290 |
--------------------------------------------------------------------------------
/grampswebsync.gpr.py:
--------------------------------------------------------------------------------
1 | #
2 | # Gramps - a GTK+/GNOME based genealogy program
3 | #
4 | # Copyright (C) 2021-2022 David Straub
5 | #
6 | # This program is free software; you can redistribute it and/or modify
7 | # it under the terms of the GNU General Public License as published by
8 | # the Free Software Foundation; either version 2 of the License, or
9 | # (at your option) any later version.
10 | #
11 | # This program is distributed in the hope that it will be useful,
12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 | # GNU General Public License for more details.
15 | #
16 | # You should have received a copy of the GNU General Public License
17 | # along with this program; if not, write to the Free Software
18 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
19 | #
20 | # from gramps.gen.plug._pluginreg import *
21 | # from gramps.gen.const import GRAMPS_LOCALE as glocale
22 | # _ = glocale.translation.gettext
23 |
24 | """GRAMPS registration file."""
25 |
26 | register(
27 | TOOL,
28 | id="gramps_web_sync",
29 | name=_("Gramps Web Sync"),
30 | description=_("Synchronizes a local database with a Gramps Web instance."),
31 | version="1.1.1",
32 | gramps_target_version="5.2",
33 | status=STABLE,
34 | fname="grampswebsync.py",
35 | authors=["David Straub"],
36 | authors_email=["straub@protonmail.com"],
37 | category=TOOL_DBPROC,
38 | toolclass="GrampsWebSyncTool",
39 | optionclass="GrampsWebSyncOptions",
40 | tool_modes=[TOOL_MODE_GUI],
41 | )
42 |
--------------------------------------------------------------------------------
/grampswebsync.py:
--------------------------------------------------------------------------------
1 | # Gramps - a GTK+/GNOME based genealogy program
2 | #
3 | # Copyright (C) 2021-2022 David Straub
4 | #
5 | # This program is free software; you can redistribute it and/or modify
6 | # it under the terms of the GNU General Public License as published by
7 | # the Free Software Foundation; either version 2 of the License, or
8 | # (at your option) any later version.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program; if not, write to the Free Software
17 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
18 |
19 | """Gramps addon to synchronize with a Gramps Web server."""
20 |
21 | import os
22 | import threading
23 | from datetime import datetime
24 |
25 | try:
26 | from typing import Callable, Optional
27 | except ImportError:
28 | from const import Type
29 |
30 | Callable = Type
31 | Optional = Type
32 | from urllib.error import HTTPError, URLError
33 | from urllib.parse import urlparse
34 |
35 | from gi.repository import GLib, Gtk
36 | from gramps.gen.config import config as configman
37 | from gramps.gen.const import GRAMPS_LOCALE as glocale
38 | from gramps.gen.db import DbTxn
39 | from gramps.gen.db.utils import import_as_dict
40 | from gramps.gen.errors import HandleError
41 | from gramps.gen.utils.file import media_path_full
42 | from gramps.gui.dialog import QuestionDialog2
43 | from gramps.gui.managedwindow import ManagedWindow
44 | from gramps.gui.plug.tool import BatchTool, ToolOptions
45 |
46 | from const import (
47 | A_ADD_LOC,
48 | A_ADD_REM,
49 | A_DEL_LOC,
50 | A_DEL_REM,
51 | A_MRG_REM,
52 | A_UPD_LOC,
53 | A_UPD_REM,
54 | C_ADD_LOC,
55 | C_ADD_REM,
56 | C_DEL_LOC,
57 | C_DEL_REM,
58 | C_UPD_BOTH,
59 | C_UPD_LOC,
60 | C_UPD_REM,
61 | MODE_BIDIRECTIONAL,
62 | MODE_RESET_TO_LOCAL,
63 | MODE_RESET_TO_REMOTE,
64 | Actions,
65 | )
66 | from diffhandler import WebApiSyncDiffHandler
67 | from webapihandler import WebApiHandler
68 |
69 | try:
70 | _trans = glocale.get_addon_translator(__file__)
71 | except ValueError:
72 | _trans = glocale.translation
73 | _ = _trans.gettext
74 | ngettext = _trans.ngettext
75 |
76 |
77 | def get_password(service: str, username: str) -> Optional[str]:
78 | """If keyring is installed, return the user's password or None."""
79 | try:
80 | import keyring
81 | except ImportError:
82 | return None
83 | return keyring.get_password(service, username)
84 |
85 |
86 | def set_password(service: str, username: str, password: str) -> None:
87 | """If keyring is installed, store the user's password."""
88 | try:
89 | import keyring
90 | except ImportError:
91 | return None
92 | keyring.set_password(service, username, password)
93 |
94 |
95 | class GrampsWebSyncTool(BatchTool, ManagedWindow):
96 | """Main class for the Gramps Web Sync tool."""
97 |
98 | def __init__(self, dbstate, user, options_class, name, *args, **kwargs) -> None:
99 | """Initialize GUI."""
100 | BatchTool.__init__(self, dbstate, user, options_class, name)
101 | ManagedWindow.__init__(self, user.uistate, [], self.__class__)
102 |
103 | self.dbstate = dbstate
104 | self.callback = self.uistate.pulse_progressbar
105 |
106 | self.config = configman.register_manager("webapisync")
107 | self.config.register("credentials.url", "")
108 | self.config.register("credentials.username", "")
109 | self.config.register("credentials.timestamp", 0)
110 | self.config.load()
111 |
112 | self.assistant = Gtk.Assistant()
113 | self.set_window(self.assistant, None, _("Gramps Web Sync"))
114 | self.setup_configs("interface.webapisync", 780, 600)
115 |
116 | self.assistant.connect("close", self.do_close)
117 | self.assistant.connect("cancel", self.do_close)
118 | self.assistant.connect("apply", self.apply)
119 | self.assistant.connect("prepare", self.prepare)
120 |
121 | self.intro = IntroductionPage(self.assistant)
122 | self.add_page(self.intro, Gtk.AssistantPageType.INTRO, _("Introduction"))
123 |
124 | self.url = self.config.get("credentials.url")
125 | self.username = self.config.get("credentials.username")
126 | self.password = self.get_password()
127 | self.loginpage = LoginPage(
128 | self.assistant,
129 | url=self.url,
130 | username=self.username,
131 | password=self.password,
132 | )
133 | self.add_page(self.loginpage, Gtk.AssistantPageType.CONTENT, _("Login"))
134 |
135 | self.progress_page = ProgressPage(self.assistant)
136 | self.add_page(
137 | self.progress_page,
138 | Gtk.AssistantPageType.PROGRESS,
139 | _("Progress Information"),
140 | )
141 |
142 | self.confirmation = ConfirmationPage(self.assistant)
143 | self.add_page(
144 | self.confirmation, Gtk.AssistantPageType.CONFIRM, _("Final confirmation")
145 | )
146 |
147 | self.file_sync_page = FileSyncPage(self.assistant)
148 | self.add_page(
149 | self.file_sync_page,
150 | Gtk.AssistantPageType.CONTENT,
151 | _("Summary"),
152 | )
153 |
154 | self.file_confirmation = FileConfirmationPage(self.assistant)
155 | self.add_page(
156 | self.file_confirmation,
157 | Gtk.AssistantPageType.CONFIRM,
158 | _("Media Files"),
159 | )
160 |
161 | self.file_progress_page = FileProgressPage(self.assistant)
162 | self.add_page(
163 | self.file_progress_page,
164 | Gtk.AssistantPageType.PROGRESS,
165 | _("Progress Information"),
166 | )
167 |
168 | self.conclusion = ConclusionPage(self.assistant)
169 | self.add_page(self.conclusion, Gtk.AssistantPageType.SUMMARY, _("Summary"))
170 |
171 | self.show()
172 | self.assistant.set_forward_page_func(self.forward_page, None)
173 |
174 | self.api = None
175 |
176 | self.db1 = dbstate.db
177 | self.db2 = None
178 | self._download_timestamp = 0
179 | self.changes = None
180 | self.sync = None
181 | self.files_missing_local = []
182 | self.files_missing_remote = []
183 | self.uploaded = {}
184 | self.downloaded = {}
185 |
186 | def build_menu_names(self, obj):
187 | """Override :class:`.ManagedWindow` method."""
188 | return (_("Gramps Web Sync"), None)
189 |
190 | def do_close(self, assistant):
191 | """Close the assistant."""
192 | position = self.window.get_position() # crock
193 | self.assistant.hide()
194 | self.window.move(position[0], position[1])
195 | self.close()
196 |
197 | def forward_page(self, page, data):
198 | """Specify the next page to be displayed."""
199 | if self.conclusion.error:
200 | return 7
201 | if page == 2 and self.file_sync_page.unchanged:
202 | return 4
203 | if page == 5 and self.conclusion.unchanged:
204 | return 7
205 | return page + 1
206 |
207 | def add_page(self, page, page_type, title=""):
208 | """Add a page to the assistant."""
209 | page.show_all()
210 | self.assistant.append_page(page)
211 | self.assistant.set_page_title(page, title)
212 | self.assistant.set_page_type(page, page_type)
213 |
214 | def prepare(self, assistant, page):
215 | """Run page preparation code."""
216 | page.update_complete()
217 | if page == self.progress_page:
218 | self.save_credentials()
219 | url, username, password = self.get_credentials()
220 | self.api = self.handle_server_errors(
221 | WebApiHandler, url, username, password, None
222 | )
223 | if self.api is None:
224 | return None
225 | self.progress_page.label.set_text(_("Fetching remote data..."))
226 | t = threading.Thread(target=self.async_compare_dbs)
227 | t.start()
228 | elif page == self.confirmation:
229 | self.confirmation.prepare(self.changes)
230 | elif page == self.file_sync_page:
231 | self.assistant.commit()
232 | if self.file_sync_page.unchanged:
233 | self.file_sync_page.label.set_text(_("Both trees are the same."))
234 | else:
235 | self.file_sync_page.label.set_text(
236 | _("Successfully synchronized %s objects.") % len(self.changes)
237 | )
238 | elif page == self.file_confirmation:
239 | self.files_missing_local = self.get_missing_files_local()
240 | self.files_missing_remote = self.get_missing_files_remote()
241 | if not self.files_missing_local and not self.files_missing_remote:
242 | self.handle_files_unchanged()
243 | else:
244 | self.file_confirmation.prepare(
245 | self.files_missing_local, self.files_missing_remote
246 | )
247 | elif page == self.file_progress_page:
248 | self.file_progress_page.prepare(
249 | self.files_missing_local, self.files_missing_remote
250 | )
251 | t = threading.Thread(target=self.async_transfer_media)
252 | t.start()
253 | elif page == self.conclusion:
254 | if self.conclusion.error:
255 | pass
256 | elif self.conclusion.unchanged:
257 | text = _("Media files are in sync.")
258 | self.conclusion.label.set_text(text)
259 | else:
260 | text = ""
261 | if self.downloaded:
262 | ok = sum([b for gid, b in self.downloaded.items()])
263 | nok = sum([not b for gid, b in self.downloaded.items()])
264 | if ok:
265 | text += _("Successfully downloaded %s media files.") % ok
266 | text += " "
267 | if nok:
268 | text += _("Encountered %s errors during download.") % nok
269 | text += " "
270 | if self.uploaded:
271 | ok = sum([b for gid, b in self.uploaded.items()])
272 | nok = sum([not b for gid, b in self.uploaded.items()])
273 | if ok:
274 | text += _("Successfully uploaded %s media files.") % ok
275 | text += " "
276 | if nok:
277 | text += _("Encountered %s errors during upload.") % nok
278 | self.conclusion.label.set_text(text)
279 |
280 | self.conclusion.set_complete()
281 |
282 | def handle_files_unchanged(self):
283 | self.conclusion.unchanged = True
284 | self.assistant.next_page()
285 |
286 | def apply(self, assistant):
287 | """Apply the changes."""
288 | page_number = assistant.get_current_page()
289 | page = assistant.get_nth_page(page_number)
290 | if page == self.confirmation:
291 | try:
292 | self.commit()
293 | except:
294 | self.handle_error(_("Unexpected error while applying changes."))
295 | elif page == self.file_confirmation:
296 | pass
297 |
298 | def download_files(self):
299 | """Download media files missing locally."""
300 | if not self.files_missing_local:
301 | return
302 | res = {}
303 | for gramps_id, handle in self.files_missing_local:
304 | self.downloaded[gramps_id] = self._download_file(handle)
305 | self._update_file_progress()
306 | return res
307 |
308 | def _update_file_progress(self):
309 | """Update the file progress bars."""
310 | self.file_progress_page.update_progress(
311 | self.files_missing_local,
312 | self.files_missing_remote,
313 | self.downloaded,
314 | self.uploaded,
315 | )
316 |
317 | def _download_file(self, handle):
318 | """Download a single media file."""
319 | try:
320 | obj = self.db1.get_media_from_handle(handle)
321 | except HandleError:
322 | self.handle_error(_("Error accessing media object."))
323 | return
324 | path = media_path_full(self.db1, obj.get_path())
325 | return self.api.download_media_file(handle=handle, path=path)
326 |
327 | def upload_files(self):
328 | """Upload media files missing remotely."""
329 | if not self.files_missing_remote:
330 | return
331 | res = {}
332 | for gramps_id, handle in self.files_missing_remote:
333 | self.uploaded[gramps_id] = self._upload_file(handle)
334 | self._update_file_progress()
335 | return res
336 |
337 | def _upload_file(self, handle):
338 | """Upload a single media file."""
339 | try:
340 | obj = self.db1.get_media_from_handle(handle)
341 | except HandleError:
342 | self.handle_error(_("Error accessing media object."))
343 | return
344 | path = media_path_full(self.db1, obj.get_path())
345 | return self.api.upload_media_file(handle=handle, path=path)
346 |
347 | def get_password(self):
348 | """Get a stored password."""
349 | url = self.config.get("credentials.url")
350 | username = self.config.get("credentials.username")
351 | if not url or not username:
352 | return None
353 | return get_password(url, username)
354 |
355 | def handle_error(self, message):
356 | """Handle an error message during sync."""
357 | self.conclusion.error = True
358 | self.assistant.next_page()
359 | self.conclusion.label.set_text(message) #
360 | self.conclusion.set_complete()
361 |
362 | def handle_unchanged(self):
363 | """Return a message if nothing has changed."""
364 | self.file_sync_page.unchanged = True
365 | self.save_timestamp()
366 | self.assistant.next_page()
367 |
368 | def async_compare_dbs(self):
369 | """Download the remote data and import it to an in-memory database."""
370 | # store timestamp just before downloading the XML
371 | self._download_timestamp = datetime.now().timestamp()
372 | GLib.idle_add(self.get_diff_actions)
373 |
374 | def get_diff_actions(self):
375 | """Download the remote data, import it and compare it to local."""
376 | path = self.handle_server_errors(self.api.download_xml)
377 | if path is None:
378 | return None
379 | db2 = import_as_dict(str(path), self._user)
380 | path.unlink() # delete temporary file
381 | self.db2 = db2
382 | self.progress_page.label.set_text(_("Comparing local and remote data..."))
383 | timestamp = self.config.get("credentials.timestamp") or None
384 | self.sync = WebApiSyncDiffHandler(
385 | self.db1, self.db2, user=self._user, last_synced=timestamp
386 | )
387 | self.changes = self.sync.get_changes()
388 | self.progress_page.label.set_text("")
389 | self.progress_page.set_complete()
390 | if len(self.changes) == 0:
391 | self.handle_unchanged()
392 | else:
393 | self.assistant.next_page()
394 |
395 | def async_transfer_media(self):
396 | """Upload/download media files."""
397 | GLib.idle_add(self._async_transfer_media)
398 |
399 | def _async_transfer_media(self):
400 | """Upload/download media files."""
401 | self.handle_server_errors(self.download_files)
402 | self.handle_server_errors(self.upload_files)
403 | self.file_progress_page.set_complete()
404 | self.assistant.next_page()
405 |
406 | def handle_server_errors(self, callback: Callable, *args):
407 | """Handle server errors while executing a function."""
408 | try:
409 | return callback(*args)
410 | except HTTPError as exc:
411 | if exc.code == 401:
412 | self.handle_error(_("Server authorization error."))
413 | elif exc.code == 403:
414 | self.handle_error(
415 | _("Server authorization error: insufficient permissions.")
416 | )
417 | elif exc.code == 404:
418 | self.handle_error(_("Error: URL not found."))
419 | elif exc.code == 409:
420 | self.handle_error(
421 | _(
422 | "Unable to synchronize changes to server: objects have been modified."
423 | )
424 | )
425 | else:
426 | self.handle_error(_("Error %s while connecting to server.") % exc.code)
427 | return None
428 | except URLError:
429 | self.handle_error(_("Error connecting to server."))
430 | return None
431 | except ValueError:
432 | self.handle_error(_("Error while parsing response from server."))
433 | return None
434 |
435 | def save_credentials(self):
436 | """Save the login credentials."""
437 | url = self.loginpage.url.get_text()
438 | url = self.sanitize_url(url)
439 | username = self.loginpage.username.get_text()
440 | password = self.loginpage.password.get_text()
441 | if url != self.config.get("credentials.url"):
442 | # if URL changed, clear last sync timestamp
443 | self.config.set("credentials.timestamp", 0)
444 | self.config.set("credentials.url", url)
445 | self.config.set("credentials.username", username)
446 | set_password(url, username, password)
447 | self.config.save()
448 |
449 | def sanitize_url(self, url: str) -> Optional[str]:
450 | """Warn if http and prepend https if missing."""
451 | parsed_url = urlparse(url)
452 | if parsed_url.scheme == "":
453 | # if no httpX given, prepend https!
454 | url = f"https://{url}"
455 | elif parsed_url.scheme == "http":
456 | question = QuestionDialog2(
457 | _("Continue without transport encryption?"),
458 | _(
459 | "You have specified a URL with http scheme. "
460 | "If you continue, your password will be sent "
461 | "in clear text over the network. "
462 | "Use only for local testing!"
463 | ),
464 | _("Continue with HTTP"),
465 | _("Use HTTPS"),
466 | parent=self.window,
467 | )
468 | if not question.run():
469 | return url.replace("http", "https")
470 | return url
471 |
472 | def get_credentials(self):
473 | """Get a tuple of URL, username, and password."""
474 | return (
475 | self.config.get("credentials.url"),
476 | self.config.get("credentials.username"),
477 | self.loginpage.password.get_text(),
478 | )
479 |
480 | def commit(self):
481 | """Commit all changes to the databases."""
482 | msg = "Apply Gramps Web Sync changes"
483 | with DbTxn(msg, self.sync.db1) as trans1:
484 | with DbTxn(msg, self.sync.db2) as trans2:
485 | actions = self.sync.changes_to_actions(self.changes, self.confirmation.sync_mode)
486 | self.sync.commit_actions(actions, trans1, trans2)
487 | # force the sync if mode is NOT bidirectional
488 | force = self.confirmation.sync_mode != MODE_BIDIRECTIONAL
489 | self.handle_server_errors(self.api.commit, trans2, force)
490 | self.save_timestamp()
491 |
492 | def save_timestamp(self):
493 | """Save last sync timestamp."""
494 | # self.config.set("credentials.timestamp", self._download_timestamp)
495 | self.config.set("credentials.timestamp", datetime.now().timestamp())
496 | self.config.save()
497 |
498 | def get_missing_files_local(self):
499 | """Get a list of media files missing locally."""
500 | return [
501 | (media.gramps_id, media.handle)
502 | for media in self.db1.iter_media()
503 | if not os.path.exists(media_path_full(self.db1, media.get_path()))
504 | ]
505 |
506 | def get_missing_files_remote(self):
507 | """Get a list of media files missing remotely."""
508 | missing_files = self.handle_server_errors(self.api.get_missing_files) or []
509 | return [(media["gramps_id"], media["handle"]) for media in missing_files]
510 |
511 |
512 | class Page(Gtk.Box):
513 | """Page base class."""
514 |
515 | def __init__(self, assistant: Gtk.Assistant):
516 | """Initialize self."""
517 | Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL)
518 | self.assistant = assistant
519 | self._complete = False
520 |
521 | def set_complete(self):
522 | """Set as complete."""
523 | self._complete = True
524 | self.update_complete()
525 |
526 | @property
527 | def complete(self):
528 | return self._complete
529 |
530 | def update_complete(self):
531 | """Set the current page's complete status."""
532 | page_number = self.assistant.get_current_page()
533 | current_page = self.assistant.get_nth_page(page_number)
534 | self.assistant.set_page_complete(current_page, self.complete)
535 |
536 |
537 | class IntroductionPage(Page):
538 | """A page containing introductory text."""
539 |
540 | def __init__(self, assistant):
541 | super().__init__(assistant)
542 | label = Gtk.Label(label=self.__get_intro_text())
543 | label.set_line_wrap(True)
544 | label.set_use_markup(True)
545 | label.set_max_width_chars(60)
546 |
547 | self.pack_start(label, False, False, 0)
548 | self._complete = True
549 |
550 | def __get_intro_text(self):
551 | """Return the introductory text."""
552 | return _(
553 | "This tool allows to synchronize the currently opened "
554 | "family tree with a remote family tree served by Gramps Web.\n\n"
555 | "The tool assumes that the two trees are derivatives of each other, "
556 | "i.e. one of the two was created from a Gramps XML (not GEDCOM!) "
557 | "export of the other.\n\n"
558 | "After successful synchronization, the two trees will be identical. "
559 | "Modifications will be propagated based on timestamps. "
560 | "You will be prompted for confirmation before any changes are made "
561 | "to the local or remote trees.\n\n"
562 | "If you instead want to merge two significantly different trees "
563 | "with the option to make manual modifications, use the Import Merge "
564 | "Tool instead."
565 | )
566 |
567 |
568 | class LoginPage(Page):
569 | """A page to log in."""
570 |
571 | def __init__(self, assistant, url, username, password):
572 | super().__init__(assistant)
573 | self.set_spacing(12)
574 |
575 | grid = Gtk.Grid()
576 | grid.set_row_spacing(6)
577 | grid.set_column_spacing(6)
578 | self.add(grid)
579 |
580 | label = Gtk.Label(label=_("Server URL: "))
581 | grid.attach(label, 0, 0, 1, 1)
582 | self.url = Gtk.Entry()
583 | if url:
584 | self.url.set_text(url)
585 | self.url.set_hexpand(True)
586 | self.url.set_input_purpose(Gtk.InputPurpose.URL)
587 | grid.attach(self.url, 1, 0, 1, 1)
588 |
589 | label = Gtk.Label(label=_("Username: "))
590 | grid.attach(label, 0, 1, 1, 1)
591 | self.username = Gtk.Entry()
592 | if username:
593 | self.username.set_text(username)
594 | self.username.set_hexpand(True)
595 | grid.attach(self.username, 1, 1, 1, 1)
596 |
597 | label = Gtk.Label(label=_("Password: "))
598 | grid.attach(label, 0, 2, 1, 1)
599 | self.password = Gtk.Entry()
600 | if password:
601 | self.password.set_text(password)
602 | self.password.set_hexpand(True)
603 | self.password.set_visibility(False)
604 | self.password.set_input_purpose(Gtk.InputPurpose.PASSWORD)
605 | grid.attach(self.password, 1, 2, 1, 1)
606 |
607 | self.url.connect("changed", self.on_entry_changed)
608 | self.username.connect("changed", self.on_entry_changed)
609 | self.password.connect("changed", self.on_entry_changed)
610 |
611 | @property
612 | def complete(self):
613 | url = self.url.get_text()
614 | username = self.username.get_text()
615 | password = self.password.get_text()
616 | if url and username and password:
617 | return True
618 | return False
619 |
620 | def on_entry_changed(self, widget):
621 | self.update_complete()
622 |
623 |
624 | class ProgressPage(Page):
625 | """A progress 2page."""
626 |
627 | def __init__(self, assistant):
628 | super().__init__(assistant)
629 | label = Gtk.Label(label="")
630 | label.set_line_wrap(True)
631 | label.set_use_markup(True)
632 | label.set_max_width_chars(60)
633 | self.label = label
634 | self.pack_start(self.label, False, False, 0)
635 |
636 |
637 | class FileProgressPage(Page):
638 | """A file progress page."""
639 |
640 | def __init__(self, assistant):
641 | """Initialize page."""
642 | super().__init__(assistant)
643 | self.label1 = Gtk.Label(label="Media file download")
644 | self.pack_start(self.label1, False, False, 20)
645 |
646 | # self.progressbar1 = Gtk.ProgressBar()
647 | # self.pack_start(self.progressbar1, False, False, 20)
648 |
649 | self.label2 = Gtk.Label(label="Media file upload")
650 | self.pack_start(self.label2, False, False, 20)
651 |
652 | # self.progressbar2 = Gtk.ProgressBar()
653 | # self.pack_start(self.progressbar2, False, False, 20)
654 |
655 | def prepare(self, files_missing_local, files_missing_remote):
656 | """Prepare."""
657 | n_down = len(files_missing_local)
658 | if not n_down:
659 | self.label1.hide()
660 | # self.progressbar1.hide()
661 | else:
662 | self.label1.show()
663 | # self.progressbar1.show()
664 | self.label1.set_text(_("Downloading %s media file(s)") % n_down)
665 | n_up = len(files_missing_remote)
666 | if not n_up:
667 | self.label2.hide()
668 | # self.progressbar2.hide()
669 | else:
670 | self.label2.show()
671 | # self.progressbar2.show()
672 | self.label2.set_text(_("Uploading %s media file(s)") % n_up)
673 |
674 | def update_progress(
675 | self, files_missing_local, files_missing_remote, downloaded, uploaded
676 | ):
677 | """Update the progress bar."""
678 | n_down = len(files_missing_local)
679 | n_up = len(files_missing_remote)
680 | i_down = len(downloaded)
681 | i_up = len(uploaded)
682 | # if n_down:
683 | # self.progressbar1.set_fraction(i_down / n_down)
684 | # if n_up:
685 | # self.progressbar2.set_fraction(i_up / n_up)
686 |
687 |
688 | class ConfirmationPage(Page):
689 | """Page showing the differences before applying them."""
690 |
691 | # def diff_dialog(self) -> bool:
692 | # """Edit the automatically generated actions via user interaction."""
693 | # dialog = DiffDetailDialog(self._user.uistate, self.actions, on_ok=self.commit)
694 | # dialog.show()
695 |
696 | def __init__(self, assistant):
697 | super().__init__(assistant)
698 | self.sync_mode = MODE_BIDIRECTIONAL
699 | self.store = Gtk.TreeStore(str, str)
700 |
701 | # tree view
702 | self.tree_view = Gtk.TreeView(model=self.store)
703 |
704 | for i, col in enumerate(["ID", "Content"]):
705 | renderer = Gtk.CellRendererText()
706 | column = Gtk.TreeViewColumn(col, renderer, text=i)
707 | self.tree_view.append_column(column)
708 |
709 | # scrolled window
710 | scrolled_window = Gtk.ScrolledWindow()
711 | scrolled_window.add(self.tree_view)
712 |
713 | self.sync_label = Gtk.Label()
714 | self.sync_label.set_text("Sync mode")
715 |
716 | # Box for radio buttons
717 | self.radio_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
718 |
719 | # Radio buttons
720 | option_name = _("Bidirectional Synchronization")
721 | self.radio_button1 = Gtk.RadioButton.new_with_label_from_widget(
722 | None, option_name
723 | )
724 | self.radio_button1.connect(
725 | "toggled", self.on_radio_button_toggled, MODE_BIDIRECTIONAL
726 | )
727 | self.radio_box.pack_start(self.radio_button1, False, False, 0)
728 |
729 | option_name = _("Reset remote to local")
730 | self.radio_button2 = Gtk.RadioButton.new_from_widget(self.radio_button1)
731 | self.radio_button2.set_label(option_name)
732 | self.radio_button2.connect(
733 | "toggled", self.on_radio_button_toggled, MODE_RESET_TO_LOCAL
734 | )
735 | self.radio_box.pack_start(self.radio_button2, False, False, 0)
736 |
737 | option_name = _("Reset local to remote")
738 | self.radio_button3 = Gtk.RadioButton.new_from_widget(self.radio_button1)
739 | self.radio_button3.set_label(option_name)
740 | self.radio_button3.connect(
741 | "toggled", self.on_radio_button_toggled, MODE_RESET_TO_REMOTE
742 | )
743 | self.radio_box.pack_start(self.radio_button3, False, False, 0)
744 |
745 | # Box to hold the label and radio buttons
746 | self.label_radio_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5)
747 | self.label_radio_box.pack_start(self.sync_label, False, False, 0)
748 | self.label_radio_box.pack_start(self.radio_box, False, False, 0)
749 |
750 | self.outer_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
751 | self.outer_box.pack_start(scrolled_window, True, True, 0)
752 | self.outer_box.pack_start(self.label_radio_box, False, False, 10)
753 |
754 | self.pack_start(self.outer_box, True, True, 0)
755 |
756 | def on_radio_button_toggled(self, button, name):
757 | """Callback for radio buttons setting sync mode."""
758 | if button.get_active():
759 | self.sync_mode = int(name)
760 |
761 | def prepare(self, changes: Actions):
762 | """Convert the changes list to a tree store."""
763 | change_labels = {
764 | _("Local changes"): {
765 | _("Added"): C_ADD_LOC,
766 | _("Deleted"): C_DEL_LOC,
767 | _("Modified"): C_UPD_LOC,
768 | },
769 | _("Remote changes"): {
770 | _("Added"): C_ADD_REM,
771 | _("Deleted"): C_DEL_REM,
772 | _("Modified"): C_UPD_REM,
773 | },
774 | _("Simultaneous changes"): {_("Modified"): C_UPD_BOTH},
775 | }
776 |
777 | for label1, v1 in change_labels.items():
778 | iter1 = self.store.append(None, [label1, ""])
779 | for label2, change_type in v1.items():
780 | rows = []
781 | for change in changes:
782 | _type, handle, class_name, obj1, obj2 = change
783 | if _type == change_type:
784 | if obj1 is not None:
785 | if class_name == "Tag":
786 | gid = obj1.name
787 | else:
788 | gid = obj1.gramps_id
789 | else:
790 | if class_name == "Tag":
791 | gid = obj2.name
792 | else:
793 | gid = obj2.gramps_id
794 | obj_details = [class_name, gid]
795 | rows.append(obj_details)
796 | if rows:
797 | label2 = f"{label2} ({len(rows)})"
798 | iter2 = self.store.append(iter1, [label2, ""])
799 | for row in rows:
800 | self.store.append(iter2, row)
801 |
802 | # expand first level
803 | for i, row in enumerate(self.store):
804 | self.tree_view.expand_row(Gtk.TreePath(i), False)
805 |
806 | self.set_complete()
807 |
808 |
809 | class FileSyncPage(Page):
810 | """Page to start media file sync."""
811 |
812 | def __init__(self, assistant):
813 | super().__init__(assistant)
814 | label = Gtk.Label(label="")
815 | label.set_line_wrap(True)
816 | label.set_use_markup(True)
817 | label.set_max_width_chars(60)
818 | self.label = label
819 | self.unchanged = False
820 | self.pack_start(self.label, False, False, 0)
821 | label = Gtk.Label(label=_("Click Next to synchronize media files."))
822 | label.set_line_wrap(True)
823 | label.set_use_markup(True)
824 | label.set_max_width_chars(60)
825 | self.pack_start(label, False, False, 0)
826 | self.set_complete()
827 |
828 |
829 | class FileConfirmationPage(Page):
830 | """File sync confirmation page."""
831 |
832 | def __init__(self, assistant):
833 | super().__init__(assistant)
834 | self.store = Gtk.TreeStore(str)
835 |
836 | # tree view
837 | self.tree_view = Gtk.TreeView(model=self.store)
838 |
839 | for i, col in enumerate(["ID"]):
840 | renderer = Gtk.CellRendererText()
841 | column = Gtk.TreeViewColumn(col, renderer, text=i)
842 | self.tree_view.append_column(column)
843 |
844 | # scrolled window
845 | scrolled_window = Gtk.ScrolledWindow()
846 | scrolled_window.add(self.tree_view)
847 |
848 | self.pack_start(scrolled_window, True, True, 0)
849 |
850 | def prepare(self, missing_local, missing_remote):
851 | iter_local = self.store.append(None, [_("Missing locally")])
852 | for gramps_id, handle in missing_local:
853 | self.store.append(iter_local, [gramps_id])
854 | iter_remote = self.store.append(None, [_("Missing remotely")])
855 | for gramps_id, handle in missing_remote:
856 | self.store.append(iter_remote, [gramps_id])
857 |
858 | # expand first level
859 | for i, row in enumerate(self.store):
860 | self.tree_view.expand_row(Gtk.TreePath(i), False)
861 |
862 | self.set_complete()
863 |
864 |
865 | class ConclusionPage(Page):
866 | """The conclusion page."""
867 |
868 | def __init__(self, assistant):
869 | super().__init__(assistant)
870 | self.error = False
871 | self.unchanged = False
872 | label = Gtk.Label(label="")
873 | label.set_line_wrap(True)
874 | label.set_use_markup(True)
875 | label.set_max_width_chars(60)
876 | self.label = label
877 | self.pack_start(self.label, False, False, 0)
878 |
879 |
880 | class GrampsWebSyncOptions(ToolOptions):
881 | """Options for Gramps Web Sync."""
882 |
--------------------------------------------------------------------------------
/po/de-local.po:
--------------------------------------------------------------------------------
1 | # German translations for gramps-addon-webapisync package.
2 | # Copyright (C) 2022 David Straub
3 | # This file is distributed under the same license as the gramps-addon-webapisync package.
4 | #
5 | msgid ""
6 | msgstr ""
7 | "Project-Id-Version: PACKAGE VERSION\n"
8 | "Report-Msgid-Bugs-To: \n"
9 | "POT-Creation-Date: 2022-07-21 22:23+0200\n"
10 | "PO-Revision-Date: 2022-07-21 22:03+0200\n"
11 | "Last-Translator: David Straub \n"
12 | "Language-Team: German \n"
13 | "Language: de\n"
14 | "MIME-Version: 1.0\n"
15 | "Content-Type: text/plain; charset=UTF-8\n"
16 | "Content-Transfer-Encoding: 8bit\n"
17 | "Plural-Forms: nplurals=2; plural=(n != 1);\n"
18 |
19 | #: GrampsWebSync/webapisync.gpr.py:29 GrampsWebSync/webapisync.py:96
20 | #: GrampsWebSync/webapisync.py:171
21 | msgid "Gramps Web Sync"
22 | msgstr "Gramps-Web-Synchronisierung"
23 |
24 | #: GrampsWebSync/webapisync.gpr.py:31
25 | msgid "Synchronizes a local database with a Gramps Web instance."
26 | msgstr "Synchronisiert eine lokale Datenbank mit einer Gramps-Web-Instanz."
27 |
28 | #: GrampsWebSync/webapisync.py:105
29 | msgid "Introduction"
30 | msgstr ""
31 |
32 | #: GrampsWebSync/webapisync.py:116
33 | msgid "Login"
34 | msgstr "Einloggen"
35 |
36 | #: GrampsWebSync/webapisync.py:122 GrampsWebSync/webapisync.py:148
37 | msgid "Progress Information"
38 | msgstr ""
39 |
40 | #: GrampsWebSync/webapisync.py:127
41 | msgid "Final confirmation"
42 | msgstr ""
43 |
44 | #: GrampsWebSync/webapisync.py:134 GrampsWebSync/webapisync.py:152
45 | msgid "Summary"
46 | msgstr ""
47 |
48 | #: GrampsWebSync/webapisync.py:141
49 | msgid "Media Files"
50 | msgstr "Mediendateien"
51 |
52 | #: GrampsWebSync/webapisync.py:208
53 | msgid "Fetching remote data..."
54 | msgstr "Lade Daten vom Server herunter..."
55 |
56 | #: GrampsWebSync/webapisync.py:217
57 | msgid "Both trees are the same."
58 | msgstr "Beide Stammbäume sind identisch."
59 |
60 | #: GrampsWebSync/webapisync.py:221
61 | #, python-format
62 | msgid "Successfully synchronized %s objects."
63 | msgstr "%s Objekte erfolgreich synchronisiert."
64 |
65 | #: GrampsWebSync/webapisync.py:241
66 | msgid "Media files are in sync."
67 | msgstr "Mediendateien sind schon synchron."
68 |
69 | #: GrampsWebSync/webapisync.py:247
70 | #, python-format
71 | msgid "Successfully downloaded %s media files."
72 | msgstr "%s Mediendateien erfolgreich heruntergeladen."
73 |
74 | #: GrampsWebSync/webapisync.py:250
75 | #, python-format
76 | msgid "Encountered %s errors during download."
77 | msgstr "Beim Herunterladen sind %s Fehler aufgetreten."
78 |
79 | #: GrampsWebSync/webapisync.py:256
80 | #, python-format
81 | msgid "Successfully uploaded %s media files."
82 | msgstr "%s Mediendateien erfolgreich hochgeladen."
83 |
84 | #: GrampsWebSync/webapisync.py:259
85 | #, python-format
86 | msgid "Encountered %s errors during upload."
87 | msgstr "Beim Hochladen sind %s Fehler aufgetreten."
88 |
89 | #: GrampsWebSync/webapisync.py:275
90 | msgid "Unexpected error while applying changes."
91 | msgstr "Unerwarteter Fehler beim Anwenden der Änderungen."
92 |
93 | #: GrampsWebSync/webapisync.py:303 GrampsWebSync/webapisync.py:323
94 | msgid "Error accessing media object."
95 | msgstr "Fehler beim Zugriff auf das Medienobjekt."
96 |
97 | #: GrampsWebSync/webapisync.py:361
98 | msgid "Comparing local and remote data..."
99 | msgstr "Vergleiche lokale und entfernte Daten..."
100 |
101 | #: GrampsWebSync/webapisync.py:391
102 | msgid "Server authorization error."
103 | msgstr "Server-Authorisierungsfehler."
104 |
105 | #: GrampsWebSync/webapisync.py:394
106 | msgid "Server authorization error: insufficient permissions."
107 | msgstr "Server-Authorisierungsfehler: unzureichende Berechtigungen."
108 |
109 | #: GrampsWebSync/webapisync.py:397
110 | msgid "Error: URL not found."
111 | msgstr "Fehler: URL nicht gefunden."
112 |
113 | #: GrampsWebSync/webapisync.py:401
114 | msgid "Unable to synchronize changes to server: objects have been modified."
115 | msgstr ""
116 | "Kann Änderungen nicht zum Server synchronisieren: Objekte wurden verändert."
117 |
118 | #: GrampsWebSync/webapisync.py:405
119 | #, python-format
120 | msgid "Error %s while connecting to server."
121 | msgstr "Fehler %s beim Zugriff auf den Server."
122 |
123 | #: GrampsWebSync/webapisync.py:408
124 | msgid "Error connecting to server."
125 | msgstr "Fehler beim Zugriff auf den Server."
126 |
127 | #: GrampsWebSync/webapisync.py:411
128 | msgid "Error while parsing response from server."
129 | msgstr "Fehler beim Verarbeiten der Serverantwort."
130 |
131 | #: GrampsWebSync/webapisync.py:436
132 | msgid "Continue without transport encryption?"
133 | msgstr "Mit unverschlüsselter Datenübertragung fortsetzen?"
134 |
135 | #: GrampsWebSync/webapisync.py:438
136 | msgid ""
137 | "You have specified a URL with http scheme. If you continue, your password "
138 | "will be sent in clear text over the network. Use only for local testing!"
139 | msgstr ""
140 | "Es wurde eine URL im http-Schema angegeben. Damit wird das Passwort "
141 | "unverschlüsselt über das Netzwerk übertragen. Bitte ausschliesslich "
142 | "für lokales Testen verwenden!"
143 |
144 | #: GrampsWebSync/webapisync.py:443
145 | msgid "Continue with HTTP"
146 | msgstr "Mit HTTP weitermachen"
147 |
148 | #: GrampsWebSync/webapisync.py:444
149 | msgid "Use HTTPS"
150 | msgstr "HTTPS verwenden"
151 |
152 | #: GrampsWebSync/webapisync.py:529
153 | msgid ""
154 | "This tool allows to synchronize the currently opened family tree with a "
155 | "remote family tree served by Gramps Web.\n"
156 | "\n"
157 | "The tool assumes that the two trees are derivatives of each other, i.e. one "
158 | "of the two was created from a Gramps XML (not GEDCOM!) export of the other.\n"
159 | "\n"
160 | "After successful synchronization, the two trees will be identical. "
161 | "Modifications will be propagated based on timestamps. You will be prompted "
162 | "for confirmation before any changes are made to the local or remote trees.\n"
163 | "\n"
164 | "If you instead want to merge two significantly different trees with the "
165 | "option to make manual modifications, use the Import Merge Tool instead."
166 | msgstr ""
167 | "Dieses Programm synchronisiert den aktuell geöffneten Stammbaum mit einem "
168 | "entfernten, von Gramps Web bereitgestellten Stammbaum.\n"
169 | "\n"
170 | "Die Synchronisierung funktioniert nur wenn beide Stammbäume die gleiche "
171 | "Basis haben. Das heisst, der eine Stammbaum muss aus dem Gramps XML "
172 | "(not GEDCOM!) Export des anderen Stammbaums erzeugt wurden sein.\n"
173 | "\n"
174 | "Nach der erfolgreichen Synchronisierung sind beide Stammbäume identisch. "
175 | "Die Änderungen zwischen den Stammbäumen werden anhand ihrer Zeitstempel erkannt.\n"
176 | "Alle geplanten Änderungen an dem lokalen oder entfernten Stammbaum können vorab "
177 | "bestätigt werden.\n"
178 | "\n"
179 | "Zum Zusammenführen von zwei sehr unterschiedlichen Stammbäumen sollte ein "
180 | "anderes Programm, das Import Merge Tool, verwendet werden. Dieses erlaubt "
181 | "auch den manuellen Eingriff beim Zusammenführen."
182 |
183 | #: GrampsWebSync/webapisync.py:556
184 | msgid "Server URL: "
185 | msgstr "Server-URL: "
186 |
187 | #: GrampsWebSync/webapisync.py:565
188 | msgid "Username: "
189 | msgstr "Benutzername: "
190 |
191 | #: GrampsWebSync/webapisync.py:573
192 | msgid "Password: "
193 | msgstr "Passwort: "
194 |
195 | #: GrampsWebSync/webapisync.py:640
196 | #, python-format
197 | msgid "Downloading %s media file(s)"
198 | msgstr "Lade %s Mediendatei(en) herunter"
199 |
200 | #: GrampsWebSync/webapisync.py:648
201 | #, python-format
202 | msgid "Uploading %s media file(s)"
203 | msgstr "Lade %s Mediendatei(en) hoch"
204 |
205 | #: GrampsWebSync/webapisync.py:693
206 | msgid "Local changes"
207 | msgstr "Lokale Änderungen"
208 |
209 | #: GrampsWebSync/webapisync.py:694 GrampsWebSync/webapisync.py:699
210 | msgid "Added"
211 | msgstr "Hinzugefügt"
212 |
213 | #: GrampsWebSync/webapisync.py:695 GrampsWebSync/webapisync.py:700
214 | msgid "Deleted"
215 | msgstr "Gelöscht"
216 |
217 | #: GrampsWebSync/webapisync.py:696 GrampsWebSync/webapisync.py:701
218 | #: GrampsWebSync/webapisync.py:703
219 | msgid "Modified"
220 | msgstr "Verändert"
221 |
222 | #: GrampsWebSync/webapisync.py:698
223 | msgid "Remote changes"
224 | msgstr "Entfernte Änderungen"
225 |
226 | #: GrampsWebSync/webapisync.py:703
227 | msgid "Simultaneous changes"
228 | msgstr "Gleichzeitige Änderungen"
229 |
230 | #: GrampsWebSync/webapisync.py:744
231 | msgid "Click Next to synchronize media files."
232 | msgstr "Auf Weiter klicken, um Mediendateien zu synchronisieren."
233 |
234 | #: GrampsWebSync/webapisync.py:774
235 | msgid "Missing locally"
236 | msgstr "Fehlt lokal"
237 |
238 | #: GrampsWebSync/webapisync.py:777
239 | msgid "Missing remotely"
240 | msgstr "Fehlt auf dem Server"
241 |
--------------------------------------------------------------------------------
/po/de.po:
--------------------------------------------------------------------------------
1 | # German translations for gramps-addon-webapisync package.
2 | # Copyright (C) 2022 David Straub
3 | # This file is distributed under the same license as the gramps-addon-webapisync package.
4 | #
5 | msgid ""
6 | msgstr ""
7 | "Project-Id-Version: PACKAGE VERSION\n"
8 | "Report-Msgid-Bugs-To: \n"
9 | "POT-Creation-Date: 2022-07-21 22:23+0200\n"
10 | "PO-Revision-Date: 2022-07-21 22:26+0200\n"
11 | "Last-Translator: David Straub \n"
12 | "Language-Team: German \n"
13 | "Language: de\n"
14 | "MIME-Version: 1.0\n"
15 | "Content-Type: text/plain; charset=UTF-8\n"
16 | "Content-Transfer-Encoding: 8bit\n"
17 | "Plural-Forms: nplurals=2; plural=(n != 1);\n"
18 |
19 | #: GrampsWebSync/webapisync.gpr.py:29 GrampsWebSync/webapisync.py:96
20 | #: GrampsWebSync/webapisync.py:171
21 | msgid "Gramps Web Sync"
22 | msgstr ""
23 |
24 | #: GrampsWebSync/webapisync.gpr.py:31
25 | msgid "Synchronizes a local database with a Gramps Web instance."
26 | msgstr ""
27 |
28 | #: GrampsWebSync/webapisync.py:105
29 | msgid "Introduction"
30 | msgstr ""
31 |
32 | #: GrampsWebSync/webapisync.py:116
33 | msgid "Login"
34 | msgstr ""
35 |
36 | #: GrampsWebSync/webapisync.py:122 GrampsWebSync/webapisync.py:148
37 | msgid "Progress Information"
38 | msgstr ""
39 |
40 | #: GrampsWebSync/webapisync.py:127
41 | msgid "Final confirmation"
42 | msgstr ""
43 |
44 | #: GrampsWebSync/webapisync.py:134 GrampsWebSync/webapisync.py:152
45 | msgid "Summary"
46 | msgstr ""
47 |
48 | #: GrampsWebSync/webapisync.py:141
49 | msgid "Media Files"
50 | msgstr ""
51 |
52 | #: GrampsWebSync/webapisync.py:208
53 | msgid "Fetching remote data..."
54 | msgstr ""
55 |
56 | #: GrampsWebSync/webapisync.py:217
57 | msgid "Both trees are the same."
58 | msgstr ""
59 |
60 | #: GrampsWebSync/webapisync.py:221
61 | #, python-format
62 | msgid "Successfully synchronized %s objects."
63 | msgstr ""
64 |
65 | #: GrampsWebSync/webapisync.py:241
66 | msgid "Media files are in sync."
67 | msgstr ""
68 |
69 | #: GrampsWebSync/webapisync.py:247
70 | #, python-format
71 | msgid "Successfully downloaded %s media files."
72 | msgstr ""
73 |
74 | #: GrampsWebSync/webapisync.py:250
75 | #, python-format
76 | msgid "Encountered %s errors during download."
77 | msgstr ""
78 |
79 | #: GrampsWebSync/webapisync.py:256
80 | #, python-format
81 | msgid "Successfully uploaded %s media files."
82 | msgstr ""
83 |
84 | #: GrampsWebSync/webapisync.py:259
85 | #, python-format
86 | msgid "Encountered %s errors during upload."
87 | msgstr ""
88 |
89 | #: GrampsWebSync/webapisync.py:275
90 | msgid "Unexpected error while applying changes."
91 | msgstr ""
92 |
93 | #: GrampsWebSync/webapisync.py:303 GrampsWebSync/webapisync.py:323
94 | msgid "Error accessing media object."
95 | msgstr ""
96 |
97 | #: GrampsWebSync/webapisync.py:361
98 | msgid "Comparing local and remote data..."
99 | msgstr ""
100 |
101 | #: GrampsWebSync/webapisync.py:391
102 | msgid "Server authorization error."
103 | msgstr ""
104 |
105 | #: GrampsWebSync/webapisync.py:394
106 | msgid "Server authorization error: insufficient permissions."
107 | msgstr ""
108 |
109 | #: GrampsWebSync/webapisync.py:397
110 | msgid "Error: URL not found."
111 | msgstr ""
112 |
113 | #: GrampsWebSync/webapisync.py:401
114 | msgid "Unable to synchronize changes to server: objects have been modified."
115 | msgstr ""
116 |
117 | #: GrampsWebSync/webapisync.py:405
118 | #, python-format
119 | msgid "Error %s while connecting to server."
120 | msgstr ""
121 |
122 | #: GrampsWebSync/webapisync.py:408
123 | msgid "Error connecting to server."
124 | msgstr ""
125 |
126 | #: GrampsWebSync/webapisync.py:411
127 | msgid "Error while parsing response from server."
128 | msgstr ""
129 |
130 | #: GrampsWebSync/webapisync.py:436
131 | msgid "Continue without transport encryption?"
132 | msgstr ""
133 |
134 | #: GrampsWebSync/webapisync.py:438
135 | msgid ""
136 | "You have specified a URL with http scheme. If you continue, your password "
137 | "will be sent in clear text over the network. Use only for local testing!"
138 | msgstr ""
139 |
140 | #: GrampsWebSync/webapisync.py:443
141 | msgid "Continue with HTTP"
142 | msgstr ""
143 |
144 | #: GrampsWebSync/webapisync.py:444
145 | msgid "Use HTTPS"
146 | msgstr ""
147 |
148 | #: GrampsWebSync/webapisync.py:529
149 | msgid ""
150 | "This tool allows to synchronize the currently opened family tree with a "
151 | "remote family tree served by Gramps Web.\n"
152 | "\n"
153 | "The tool assumes that the two trees are derivatives of each other, i.e. one "
154 | "of the two was created from a Gramps XML (not GEDCOM!) export of the other.\n"
155 | "\n"
156 | "After successful synchronization, the two trees will be identical. "
157 | "Modifications will be propagated based on timestamps. You will be prompted "
158 | "for confirmation before any changes are made to the local or remote trees.\n"
159 | "\n"
160 | "If you instead want to merge two significantly different trees with the "
161 | "option to make manual modifications, use the Import Merge Tool instead."
162 | msgstr ""
163 |
164 | #: GrampsWebSync/webapisync.py:556
165 | msgid "Server URL: "
166 | msgstr ""
167 |
168 | #: GrampsWebSync/webapisync.py:565
169 | msgid "Username: "
170 | msgstr ""
171 |
172 | #: GrampsWebSync/webapisync.py:573
173 | msgid "Password: "
174 | msgstr ""
175 |
176 | #: GrampsWebSync/webapisync.py:640
177 | #, python-format
178 | msgid "Downloading %s media file(s)"
179 | msgstr ""
180 |
181 | #: GrampsWebSync/webapisync.py:648
182 | #, python-format
183 | msgid "Uploading %s media file(s)"
184 | msgstr ""
185 |
186 | #: GrampsWebSync/webapisync.py:693
187 | msgid "Local changes"
188 | msgstr ""
189 |
190 | #: GrampsWebSync/webapisync.py:694 GrampsWebSync/webapisync.py:699
191 | msgid "Added"
192 | msgstr ""
193 |
194 | #: GrampsWebSync/webapisync.py:695 GrampsWebSync/webapisync.py:700
195 | msgid "Deleted"
196 | msgstr ""
197 |
198 | #: GrampsWebSync/webapisync.py:696 GrampsWebSync/webapisync.py:701
199 | #: GrampsWebSync/webapisync.py:703
200 | msgid "Modified"
201 | msgstr ""
202 |
203 | #: GrampsWebSync/webapisync.py:698
204 | msgid "Remote changes"
205 | msgstr ""
206 |
207 | #: GrampsWebSync/webapisync.py:703
208 | msgid "Simultaneous changes"
209 | msgstr ""
210 |
211 | #: GrampsWebSync/webapisync.py:744
212 | msgid "Click Next to synchronize media files."
213 | msgstr ""
214 |
215 | #: GrampsWebSync/webapisync.py:774
216 | msgid "Missing locally"
217 | msgstr ""
218 |
219 | #: GrampsWebSync/webapisync.py:777
220 | msgid "Missing remotely"
221 | msgstr ""
222 |
--------------------------------------------------------------------------------
/webapihandler.py:
--------------------------------------------------------------------------------
1 | """Web API handler class for the Gramps Web Sync plugin."""
2 |
3 | import gzip
4 | import json
5 | import os
6 | import platform
7 | from pathlib import Path
8 | from tempfile import NamedTemporaryFile
9 | from time import sleep
10 |
11 | try:
12 | from typing import Any, Callable, Dict, List, Optional
13 | except ImportError:
14 | from const import Type
15 |
16 | Any = Type
17 | Callable = Type
18 | Dict = Type
19 | List = Type
20 | Optional = Type
21 | from urllib.error import HTTPError
22 | from urllib.request import Request, urlopen
23 |
24 | import gramps
25 | from gramps.gen.db import KEY_TO_CLASS_MAP, DbTxn
26 | from gramps.gen.db.dbconst import TXNADD, TXNDEL, TXNUPD
27 | from gramps.gen.utils.grampslocale import GrampsLocale
28 |
29 |
30 | def create_macos_ssl_context():
31 | import ssl
32 | import subprocess
33 |
34 | """Creates an SSL context using macOS system certificates."""
35 | ctx = ssl.create_default_context()
36 | macos_ca_certs = subprocess.run(
37 | [
38 | "security",
39 | "find-certificate",
40 | "-a",
41 | "-p",
42 | "/System/Library/Keychains/SystemRootCertificates.keychain",
43 | ],
44 | stdout=subprocess.PIPE,
45 | ).stdout
46 |
47 | with NamedTemporaryFile("w+b") as tmp_file:
48 | tmp_file.write(macos_ca_certs)
49 | ctx.load_verify_locations(tmp_file.name)
50 |
51 | return ctx
52 |
53 |
54 | class WebApiHandler:
55 | """Web API connection handler."""
56 |
57 | def __init__(
58 | self,
59 | url: str,
60 | username: str,
61 | password: str,
62 | download_callback: Optional[Callable] = None,
63 | ) -> None:
64 | """Initialize given URL, user name, and password."""
65 | self.url = url.rstrip("/")
66 | self.username = username
67 | self.password = password
68 | self._access_token: Optional[str] = None
69 | self.download_callback = download_callback
70 | # Determine the appropriate SSL context based on platform
71 | self._ctx = (
72 | create_macos_ssl_context() if platform.system() == "Darwin" else None
73 | )
74 |
75 | # get and cache the access token
76 | self.fetch_token()
77 |
78 | @property
79 | def access_token(self) -> str:
80 | """Get the access token. Cached after first call"""
81 | if not self._access_token:
82 | self.fetch_token()
83 | return self._access_token
84 |
85 | def fetch_token(self) -> None:
86 | """Fetch and store an access token."""
87 | data = json.dumps({"username": self.username, "password": self.password})
88 | req = Request(
89 | f"{self.url}/token/",
90 | data=data.encode(),
91 | headers={"Content-Type": "application/json"},
92 | )
93 | try:
94 | with urlopen(req, context=self._ctx) as res:
95 | res_json = json.load(res)
96 | except (UnicodeDecodeError, json.JSONDecodeError, HTTPError):
97 | if "/api" not in self.url:
98 | self.url = f"{self.url}/api"
99 | return self.fetch_token()
100 | raise
101 | self._access_token = res_json["access_token"]
102 |
103 | def get_lang(self) -> Optional[str]:
104 | """Fetch language information."""
105 | req = Request(
106 | f"{self.url}/metadata/",
107 | headers={"Authorization": f"Bearer {self.access_token}"},
108 | )
109 | with urlopen(req, context=self._ctx) as res:
110 | try:
111 | res_json = json.load(res)
112 | except (UnicodeDecodeError, json.JSONDecodeError, HTTPError):
113 | return None
114 | return (res_json.get("locale") or {}).get("lang")
115 |
116 | def download_xml(self) -> Path:
117 | """Download an XML export and return the path of the temp file."""
118 | url = f"{self.url}/exporters/gramps/file"
119 | temp = NamedTemporaryFile(delete=False)
120 | try:
121 | self._download_file(url=url, fobj=temp)
122 | finally:
123 | temp.close()
124 | unzipped_name = f"{temp.name}.gramps"
125 | with open(unzipped_name, "wb") as fu:
126 | with gzip.open(temp.name) as fz:
127 | fu.write(fz.read())
128 | os.remove(temp.name)
129 | return Path(unzipped_name)
130 |
131 | def commit(self, trans: DbTxn, force: bool = True) -> None:
132 | """Commit the changes to the remote database."""
133 | lang = self.get_lang()
134 | payload = transaction_to_json(trans, lang)
135 | if payload:
136 | data = json.dumps(payload).encode()
137 | endpoint = f"{self.url}/transactions/"
138 | if force:
139 | endpoint = f"{endpoint}?force=1"
140 | req = Request(
141 | endpoint,
142 | data=data,
143 | headers={
144 | "Content-Type": "application/json",
145 | "Authorization": f"Bearer {self.access_token}",
146 | },
147 | )
148 | try:
149 | urlopen(req, context=self._ctx)
150 | except HTTPError as exc:
151 | if exc.code == 422 and force:
152 | # Web API version might not support force parameter yet
153 | self.commit(trans, force=False)
154 |
155 | def get_missing_files(self, retry: bool = True) -> List:
156 | """Get a list of remote media objects with missing files."""
157 | req = Request(
158 | f"{self.url}/media/?filemissing=1",
159 | headers={"Authorization": f"Bearer {self.access_token}"},
160 | )
161 | try:
162 | with urlopen(req, context=self._ctx) as res:
163 | res_json = json.load(res)
164 | except HTTPError as exc:
165 | if exc.code == 401 and retry:
166 | # in case of 401, retry once with a new token
167 | sleep(1) # avoid server-side rate limit
168 | self.fetch_token()
169 | return self.get_missing_files(retry=False)
170 | raise
171 | return res_json
172 |
173 | def _download_file(
174 | self, url: str, fobj, retry: bool = True, token_url: bool = False
175 | ):
176 | """Download a file."""
177 | if token_url:
178 | req = Request(f"{url}?jwt={self.access_token}")
179 | else:
180 | req = Request(
181 | url,
182 | headers={"Authorization": f"Bearer {self.access_token}"},
183 | )
184 | try:
185 | with urlopen(req, context=self._ctx) as res:
186 | chunk_size = 1024
187 | chunk = res.read(chunk_size)
188 | fobj.write(chunk)
189 | while chunk:
190 | if self.download_callback is not None:
191 | self.download_callback()
192 | chunk = res.read(chunk_size)
193 | fobj.write(chunk)
194 | except HTTPError as exc:
195 | if exc.code == 401 and retry:
196 | # in case of 401, retry once with a new token
197 | sleep(1) # avoid server-side rate limit
198 | self.fetch_token()
199 | return self._download_file(
200 | url=url, fobj=fobj, retry=False, token_url=token_url
201 | )
202 | raise
203 |
204 | def download_media_file(self, handle: str, path) -> bool:
205 | """Download a media file."""
206 | url = f"{self.url}/media/{handle}/file"
207 | with open(path, "wb") as f:
208 | self._download_file(url=url, fobj=f, token_url=True)
209 | return True
210 |
211 | def upload_media_file(self, handle: str, path) -> bool:
212 | """Upload a media file."""
213 | url = f"{self.url}/media/{handle}/file?uploadmissing=1"
214 | try:
215 | with open(path, "rb") as f:
216 | self._upload_file(url=url, fobj=f)
217 | except HTTPError as exc:
218 | if exc.code == 409:
219 | return False
220 | raise
221 | return True
222 |
223 | def _upload_file(self, url: str, fobj, retry: bool = True):
224 | """Upload a file."""
225 | req = Request(
226 | url,
227 | data=fobj,
228 | headers={"Authorization": f"Bearer {self.access_token}"},
229 | method="PUT",
230 | )
231 | try:
232 | with urlopen(req, context=self._ctx) as res:
233 | pass
234 | except HTTPError as exc:
235 | if exc.code == 401 and retry:
236 | # in case of 401, retry once with a new token
237 | sleep(1) # avoid server-side rate limit
238 | self.fetch_token()
239 | return self._upload_file(url=url, fobj=fobj, retry=False)
240 | raise
241 |
242 |
243 | # special cases for type names. See https://github.com/gramps-project/gramps-webapi/issues/163#issuecomment-940361882
244 | _type_name_special_cases = {
245 | "Father Age": "Father's Age",
246 | "Mother Age": "Mother's Age",
247 | "BIC": "Born In Covenant",
248 | "DNS": "Do not seal",
249 | "DNS/CAN": "Do not seal/Cancel",
250 | "bold": "Bold",
251 | "italic": "Italic",
252 | "underline": "Underline",
253 | "fontface": "Fontface",
254 | "fontsize": "Fontsize",
255 | "fontcolor": "Fontcolor",
256 | "highlight": "Highlight",
257 | "superscript": "Superscript",
258 | "link": "Link",
259 | }
260 |
261 |
262 | def to_json(obj, lang: Optional[str] = None) -> str:
263 | """
264 | Encode a Gramps object to a JSON object.
265 |
266 | Patched from `gramps.gen.serialize` to allow translation of type names.
267 | """
268 |
269 | def __default(obj):
270 | obj_dict = {"_class": obj.__class__.__name__}
271 | if isinstance(obj, gramps.gen.lib.GrampsType):
272 | if not lang:
273 | obj_dict["string"] = getattr(obj, "string")
274 | else:
275 | # if the remote locale is different from the local one,
276 | # need to translate type names.
277 | glocale = GrampsLocale(lang=lang)
278 | # In most cases, the xml_str
279 | # is the same as the gettext message, so it can just be translated.
280 | s_untrans = obj.xml_str()
281 | # handle exceptional cases
282 | s_untrans = _type_name_special_cases.get(s_untrans, s_untrans)
283 | # translate
284 | obj_dict["string"] = glocale.translation.gettext(s_untrans)
285 | if isinstance(obj, gramps.gen.lib.Date):
286 | if obj.is_empty() and not obj.text:
287 | return None
288 | for key, value in obj.__dict__.items():
289 | if not key.startswith("_"):
290 | obj_dict[key] = value
291 | for key, value in obj.__class__.__dict__.items():
292 | if isinstance(value, property):
293 | if key != "year":
294 | obj_dict[key] = getattr(obj, key)
295 | return obj_dict
296 |
297 | return json.dumps(obj, default=__default, ensure_ascii=False)
298 |
299 |
300 | def transaction_to_json(
301 | transaction: DbTxn, lang: Optional[str] = None
302 | ) -> List[Dict[str, Any]]:
303 | """Return a JSON representation of a database transaction."""
304 | out = []
305 | for recno in transaction.get_recnos(reverse=False):
306 | key, action, handle, old_data, new_data = transaction.get_record(recno)
307 | try:
308 | obj_cls_name = KEY_TO_CLASS_MAP[key]
309 | except KeyError:
310 | continue # this happens for references
311 | trans_dict = {TXNUPD: "update", TXNDEL: "delete", TXNADD: "add"}
312 | obj_cls = getattr(gramps.gen.lib, obj_cls_name)
313 | if old_data:
314 | old_data = obj_cls().unserialize(old_data)
315 | if new_data:
316 | new_data = obj_cls().unserialize(new_data)
317 | item = {
318 | "type": trans_dict[action],
319 | "handle": handle,
320 | "_class": obj_cls_name,
321 | "old": json.loads(to_json(old_data, lang=lang)),
322 | "new": json.loads(to_json(new_data, lang=lang)),
323 | }
324 | out.append(item)
325 | return out
326 |
--------------------------------------------------------------------------------