├── .flake8
├── .github
└── workflows
│ └── build.yml
├── .gitignore
├── LICENSE
├── MANIFEST.in
├── README.md
├── abeluna
├── __init__.py
├── main.py
├── settings.py
├── sync
│ ├── __init__.py
│ ├── calendar.py
│ ├── local.py
│ └── server.py
├── ui
│ └── menubar.ui
├── util.py
├── widgets
│ ├── __init__.py
│ ├── datetime_picker.py
│ ├── dropdown_select.py
│ └── error_dialog.py
└── windows
│ ├── __init__.py
│ ├── settings.py
│ └── todolist.py
├── requirements.txt
└── setup.py
/.flake8:
--------------------------------------------------------------------------------
1 | [flake8]
2 | max-line-length = 120
3 | application-import-names = abeluna
4 | import-order-style = pycharm
5 | enable-extensions = G
6 | ignore =
7 | # line break occurred after a binary operator
8 | W504,
9 | # allow only generator_stop and annotations future imports
10 | FI10,FI11,FI12,FI13,FI14,FI15,FI16,FI17,FI18,FI55,FI58,
11 | # missing trailing comma in Python 2 only
12 | C814,
13 | per-file-ignores =
14 | # F401: unused imports, ignore in all __init__.py
15 | # F403: import *
16 | ./*/__init__.py:F401,F403
17 | ./abeluna/main.py:E402,I100
18 |
--------------------------------------------------------------------------------
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: build
2 | on: [push, pull_request]
3 | jobs:
4 | lint:
5 | runs-on: ubuntu-latest
6 | steps:
7 | - uses: actions/checkout@v1
8 | - name: Set up Python 3.7
9 | uses: actions/setup-python@v1
10 | with:
11 | python-version: 3.7
12 | - name: Install flake8
13 | run: pip install flake8 flake8-import-order flake8-future-import flake8-commas flake8-logging-format
14 | - name: Lint with flake8
15 | run: |
16 | flake8 --version
17 | flake8
18 |
--------------------------------------------------------------------------------
/.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 AFFERO GENERAL PUBLIC LICENSE
2 | Version 3, 19 November 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 Affero General Public License is a free, copyleft license for
11 | software and other kinds of works, specifically designed to ensure
12 | cooperation with the community in the case of network server software.
13 |
14 | The licenses for most software and other practical works are designed
15 | to take away your freedom to share and change the works. By contrast,
16 | our General Public Licenses are intended to guarantee your freedom to
17 | share and change all versions of a program--to make sure it remains free
18 | software for all its users.
19 |
20 | When we speak of free software, we are referring to freedom, not
21 | price. Our General Public Licenses are designed to make sure that you
22 | have the freedom to distribute copies of free software (and charge for
23 | them if you wish), that you receive source code or can get it if you
24 | want it, that you can change the software or use pieces of it in new
25 | free programs, and that you know you can do these things.
26 |
27 | Developers that use our General Public Licenses protect your rights
28 | with two steps: (1) assert copyright on the software, and (2) offer
29 | you this License which gives you legal permission to copy, distribute
30 | and/or modify the software.
31 |
32 | A secondary benefit of defending all users' freedom is that
33 | improvements made in alternate versions of the program, if they
34 | receive widespread use, become available for other developers to
35 | incorporate. Many developers of free software are heartened and
36 | encouraged by the resulting cooperation. However, in the case of
37 | software used on network servers, this result may fail to come about.
38 | The GNU General Public License permits making a modified version and
39 | letting the public access it on a server without ever releasing its
40 | source code to the public.
41 |
42 | The GNU Affero General Public License is designed specifically to
43 | ensure that, in such cases, the modified source code becomes available
44 | to the community. It requires the operator of a network server to
45 | provide the source code of the modified version running there to the
46 | users of that server. Therefore, public use of a modified version, on
47 | a publicly accessible server, gives the public access to the source
48 | code of the modified version.
49 |
50 | An older license, called the Affero General Public License and
51 | published by Affero, was designed to accomplish similar goals. This is
52 | a different license, not a version of the Affero GPL, but Affero has
53 | released a new version of the Affero GPL which permits relicensing under
54 | this license.
55 |
56 | The precise terms and conditions for copying, distribution and
57 | modification follow.
58 |
59 | TERMS AND CONDITIONS
60 |
61 | 0. Definitions.
62 |
63 | "This License" refers to version 3 of the GNU Affero General Public License.
64 |
65 | "Copyright" also means copyright-like laws that apply to other kinds of
66 | works, such as semiconductor masks.
67 |
68 | "The Program" refers to any copyrightable work licensed under this
69 | License. Each licensee is addressed as "you". "Licensees" and
70 | "recipients" may be individuals or organizations.
71 |
72 | To "modify" a work means to copy from or adapt all or part of the work
73 | in a fashion requiring copyright permission, other than the making of an
74 | exact copy. The resulting work is called a "modified version" of the
75 | earlier work or a work "based on" the earlier work.
76 |
77 | A "covered work" means either the unmodified Program or a work based
78 | on the Program.
79 |
80 | To "propagate" a work means to do anything with it that, without
81 | permission, would make you directly or secondarily liable for
82 | infringement under applicable copyright law, except executing it on a
83 | computer or modifying a private copy. Propagation includes copying,
84 | distribution (with or without modification), making available to the
85 | public, and in some countries other activities as well.
86 |
87 | To "convey" a work means any kind of propagation that enables other
88 | parties to make or receive copies. Mere interaction with a user through
89 | a computer network, with no transfer of a copy, is not conveying.
90 |
91 | An interactive user interface displays "Appropriate Legal Notices"
92 | to the extent that it includes a convenient and prominently visible
93 | feature that (1) displays an appropriate copyright notice, and (2)
94 | tells the user that there is no warranty for the work (except to the
95 | extent that warranties are provided), that licensees may convey the
96 | work under this License, and how to view a copy of this License. If
97 | the interface presents a list of user commands or options, such as a
98 | menu, a prominent item in the list meets this criterion.
99 |
100 | 1. Source Code.
101 |
102 | The "source code" for a work means the preferred form of the work
103 | for making modifications to it. "Object code" means any non-source
104 | form of a work.
105 |
106 | A "Standard Interface" means an interface that either is an official
107 | standard defined by a recognized standards body, or, in the case of
108 | interfaces specified for a particular programming language, one that
109 | is widely used among developers working in that language.
110 |
111 | The "System Libraries" of an executable work include anything, other
112 | than the work as a whole, that (a) is included in the normal form of
113 | packaging a Major Component, but which is not part of that Major
114 | Component, and (b) serves only to enable use of the work with that
115 | Major Component, or to implement a Standard Interface for which an
116 | implementation is available to the public in source code form. A
117 | "Major Component", in this context, means a major essential component
118 | (kernel, window system, and so on) of the specific operating system
119 | (if any) on which the executable work runs, or a compiler used to
120 | produce the work, or an object code interpreter used to run it.
121 |
122 | The "Corresponding Source" for a work in object code form means all
123 | the source code needed to generate, install, and (for an executable
124 | work) run the object code and to modify the work, including scripts to
125 | control those activities. However, it does not include the work's
126 | System Libraries, or general-purpose tools or generally available free
127 | programs which are used unmodified in performing those activities but
128 | which are not part of the work. For example, Corresponding Source
129 | includes interface definition files associated with source files for
130 | the work, and the source code for shared libraries and dynamically
131 | linked subprograms that the work is specifically designed to require,
132 | such as by intimate data communication or control flow between those
133 | subprograms and other parts of the work.
134 |
135 | The Corresponding Source need not include anything that users
136 | can regenerate automatically from other parts of the Corresponding
137 | Source.
138 |
139 | The Corresponding Source for a work in source code form is that
140 | same work.
141 |
142 | 2. Basic Permissions.
143 |
144 | All rights granted under this License are granted for the term of
145 | copyright on the Program, and are irrevocable provided the stated
146 | conditions are met. This License explicitly affirms your unlimited
147 | permission to run the unmodified Program. The output from running a
148 | covered work is covered by this License only if the output, given its
149 | content, constitutes a covered work. This License acknowledges your
150 | rights of fair use or other equivalent, as provided by copyright law.
151 |
152 | You may make, run and propagate covered works that you do not
153 | convey, without conditions so long as your license otherwise remains
154 | in force. You may convey covered works to others for the sole purpose
155 | of having them make modifications exclusively for you, or provide you
156 | with facilities for running those works, provided that you comply with
157 | the terms of this License in conveying all material for which you do
158 | not control copyright. Those thus making or running the covered works
159 | for you must do so exclusively on your behalf, under your direction
160 | and control, on terms that prohibit them from making any copies of
161 | your copyrighted material outside their relationship with you.
162 |
163 | Conveying under any other circumstances is permitted solely under
164 | the conditions stated below. Sublicensing is not allowed; section 10
165 | makes it unnecessary.
166 |
167 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
168 |
169 | No covered work shall be deemed part of an effective technological
170 | measure under any applicable law fulfilling obligations under article
171 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or
172 | similar laws prohibiting or restricting circumvention of such
173 | measures.
174 |
175 | When you convey a covered work, you waive any legal power to forbid
176 | circumvention of technological measures to the extent such circumvention
177 | is effected by exercising rights under this License with respect to
178 | the covered work, and you disclaim any intention to limit operation or
179 | modification of the work as a means of enforcing, against the work's
180 | users, your or third parties' legal rights to forbid circumvention of
181 | technological measures.
182 |
183 | 4. Conveying Verbatim Copies.
184 |
185 | You may convey verbatim copies of the Program's source code as you
186 | receive it, in any medium, provided that you conspicuously and
187 | appropriately publish on each copy an appropriate copyright notice;
188 | keep intact all notices stating that this License and any
189 | non-permissive terms added in accord with section 7 apply to the code;
190 | keep intact all notices of the absence of any warranty; and give all
191 | recipients a copy of this License along with the Program.
192 |
193 | You may charge any price or no price for each copy that you convey,
194 | and you may offer support or warranty protection for a fee.
195 |
196 | 5. Conveying Modified Source Versions.
197 |
198 | You may convey a work based on the Program, or the modifications to
199 | produce it from the Program, in the form of source code under the
200 | terms of section 4, provided that you also meet all of these conditions:
201 |
202 | a) The work must carry prominent notices stating that you modified
203 | it, and giving a relevant date.
204 |
205 | b) The work must carry prominent notices stating that it is
206 | released under this License and any conditions added under section
207 | 7. This requirement modifies the requirement in section 4 to
208 | "keep intact all notices".
209 |
210 | c) You must license the entire work, as a whole, under this
211 | License to anyone who comes into possession of a copy. This
212 | License will therefore apply, along with any applicable section 7
213 | additional terms, to the whole of the work, and all its parts,
214 | regardless of how they are packaged. This License gives no
215 | permission to license the work in any other way, but it does not
216 | invalidate such permission if you have separately received it.
217 |
218 | d) If the work has interactive user interfaces, each must display
219 | Appropriate Legal Notices; however, if the Program has interactive
220 | interfaces that do not display Appropriate Legal Notices, your
221 | work need not make them do so.
222 |
223 | A compilation of a covered work with other separate and independent
224 | works, which are not by their nature extensions of the covered work,
225 | and which are not combined with it such as to form a larger program,
226 | in or on a volume of a storage or distribution medium, is called an
227 | "aggregate" if the compilation and its resulting copyright are not
228 | used to limit the access or legal rights of the compilation's users
229 | beyond what the individual works permit. Inclusion of a covered work
230 | in an aggregate does not cause this License to apply to the other
231 | parts of the aggregate.
232 |
233 | 6. Conveying Non-Source Forms.
234 |
235 | You may convey a covered work in object code form under the terms
236 | of sections 4 and 5, provided that you also convey the
237 | machine-readable Corresponding Source under the terms of this License,
238 | in one of these ways:
239 |
240 | a) Convey the object code in, or embodied in, a physical product
241 | (including a physical distribution medium), accompanied by the
242 | Corresponding Source fixed on a durable physical medium
243 | customarily used for software interchange.
244 |
245 | b) Convey the object code in, or embodied in, a physical product
246 | (including a physical distribution medium), accompanied by a
247 | written offer, valid for at least three years and valid for as
248 | long as you offer spare parts or customer support for that product
249 | model, to give anyone who possesses the object code either (1) a
250 | copy of the Corresponding Source for all the software in the
251 | product that is covered by this License, on a durable physical
252 | medium customarily used for software interchange, for a price no
253 | more than your reasonable cost of physically performing this
254 | conveying of source, or (2) access to copy the
255 | Corresponding Source from a network server at no charge.
256 |
257 | c) Convey individual copies of the object code with a copy of the
258 | written offer to provide the Corresponding Source. This
259 | alternative is allowed only occasionally and noncommercially, and
260 | only if you received the object code with such an offer, in accord
261 | with subsection 6b.
262 |
263 | d) Convey the object code by offering access from a designated
264 | place (gratis or for a charge), and offer equivalent access to the
265 | Corresponding Source in the same way through the same place at no
266 | further charge. You need not require recipients to copy the
267 | Corresponding Source along with the object code. If the place to
268 | copy the object code is a network server, the Corresponding Source
269 | may be on a different server (operated by you or a third party)
270 | that supports equivalent copying facilities, provided you maintain
271 | clear directions next to the object code saying where to find the
272 | Corresponding Source. Regardless of what server hosts the
273 | Corresponding Source, you remain obligated to ensure that it is
274 | available for as long as needed to satisfy these requirements.
275 |
276 | e) Convey the object code using peer-to-peer transmission, provided
277 | you inform other peers where the object code and Corresponding
278 | Source of the work are being offered to the general public at no
279 | charge under subsection 6d.
280 |
281 | A separable portion of the object code, whose source code is excluded
282 | from the Corresponding Source as a System Library, need not be
283 | included in conveying the object code work.
284 |
285 | A "User Product" is either (1) a "consumer product", which means any
286 | tangible personal property which is normally used for personal, family,
287 | or household purposes, or (2) anything designed or sold for incorporation
288 | into a dwelling. In determining whether a product is a consumer product,
289 | doubtful cases shall be resolved in favor of coverage. For a particular
290 | product received by a particular user, "normally used" refers to a
291 | typical or common use of that class of product, regardless of the status
292 | of the particular user or of the way in which the particular user
293 | actually uses, or expects or is expected to use, the product. A product
294 | is a consumer product regardless of whether the product has substantial
295 | commercial, industrial or non-consumer uses, unless such uses represent
296 | the only significant mode of use of the product.
297 |
298 | "Installation Information" for a User Product means any methods,
299 | procedures, authorization keys, or other information required to install
300 | and execute modified versions of a covered work in that User Product from
301 | a modified version of its Corresponding Source. The information must
302 | suffice to ensure that the continued functioning of the modified object
303 | code is in no case prevented or interfered with solely because
304 | modification has been made.
305 |
306 | If you convey an object code work under this section in, or with, or
307 | specifically for use in, a User Product, and the conveying occurs as
308 | part of a transaction in which the right of possession and use of the
309 | User Product is transferred to the recipient in perpetuity or for a
310 | fixed term (regardless of how the transaction is characterized), the
311 | Corresponding Source conveyed under this section must be accompanied
312 | by the Installation Information. But this requirement does not apply
313 | if neither you nor any third party retains the ability to install
314 | modified object code on the User Product (for example, the work has
315 | been installed in ROM).
316 |
317 | The requirement to provide Installation Information does not include a
318 | requirement to continue to provide support service, warranty, or updates
319 | for a work that has been modified or installed by the recipient, or for
320 | the User Product in which it has been modified or installed. Access to a
321 | network may be denied when the modification itself materially and
322 | adversely affects the operation of the network or violates the rules and
323 | protocols for communication across the network.
324 |
325 | Corresponding Source conveyed, and Installation Information provided,
326 | in accord with this section must be in a format that is publicly
327 | documented (and with an implementation available to the public in
328 | source code form), and must require no special password or key for
329 | unpacking, reading or copying.
330 |
331 | 7. Additional Terms.
332 |
333 | "Additional permissions" are terms that supplement the terms of this
334 | License by making exceptions from one or more of its conditions.
335 | Additional permissions that are applicable to the entire Program shall
336 | be treated as though they were included in this License, to the extent
337 | that they are valid under applicable law. If additional permissions
338 | apply only to part of the Program, that part may be used separately
339 | under those permissions, but the entire Program remains governed by
340 | this License without regard to the additional permissions.
341 |
342 | When you convey a copy of a covered work, you may at your option
343 | remove any additional permissions from that copy, or from any part of
344 | it. (Additional permissions may be written to require their own
345 | removal in certain cases when you modify the work.) You may place
346 | additional permissions on material, added by you to a covered work,
347 | for which you have or can give appropriate copyright permission.
348 |
349 | Notwithstanding any other provision of this License, for material you
350 | add to a covered work, you may (if authorized by the copyright holders of
351 | that material) supplement the terms of this License with terms:
352 |
353 | a) Disclaiming warranty or limiting liability differently from the
354 | terms of sections 15 and 16 of this License; or
355 |
356 | b) Requiring preservation of specified reasonable legal notices or
357 | author attributions in that material or in the Appropriate Legal
358 | Notices displayed by works containing it; or
359 |
360 | c) Prohibiting misrepresentation of the origin of that material, or
361 | requiring that modified versions of such material be marked in
362 | reasonable ways as different from the original version; or
363 |
364 | d) Limiting the use for publicity purposes of names of licensors or
365 | authors of the material; or
366 |
367 | e) Declining to grant rights under trademark law for use of some
368 | trade names, trademarks, or service marks; or
369 |
370 | f) Requiring indemnification of licensors and authors of that
371 | material by anyone who conveys the material (or modified versions of
372 | it) with contractual assumptions of liability to the recipient, for
373 | any liability that these contractual assumptions directly impose on
374 | those licensors and authors.
375 |
376 | All other non-permissive additional terms are considered "further
377 | restrictions" within the meaning of section 10. If the Program as you
378 | received it, or any part of it, contains a notice stating that it is
379 | governed by this License along with a term that is a further
380 | restriction, you may remove that term. If a license document contains
381 | a further restriction but permits relicensing or conveying under this
382 | License, you may add to a covered work material governed by the terms
383 | of that license document, provided that the further restriction does
384 | not survive such relicensing or conveying.
385 |
386 | If you add terms to a covered work in accord with this section, you
387 | must place, in the relevant source files, a statement of the
388 | additional terms that apply to those files, or a notice indicating
389 | where to find the applicable terms.
390 |
391 | Additional terms, permissive or non-permissive, may be stated in the
392 | form of a separately written license, or stated as exceptions;
393 | the above requirements apply either way.
394 |
395 | 8. Termination.
396 |
397 | You may not propagate or modify a covered work except as expressly
398 | provided under this License. Any attempt otherwise to propagate or
399 | modify it is void, and will automatically terminate your rights under
400 | this License (including any patent licenses granted under the third
401 | paragraph of section 11).
402 |
403 | However, if you cease all violation of this License, then your
404 | license from a particular copyright holder is reinstated (a)
405 | provisionally, unless and until the copyright holder explicitly and
406 | finally terminates your license, and (b) permanently, if the copyright
407 | holder fails to notify you of the violation by some reasonable means
408 | prior to 60 days after the cessation.
409 |
410 | Moreover, your license from a particular copyright holder is
411 | reinstated permanently if the copyright holder notifies you of the
412 | violation by some reasonable means, this is the first time you have
413 | received notice of violation of this License (for any work) from that
414 | copyright holder, and you cure the violation prior to 30 days after
415 | your receipt of the notice.
416 |
417 | Termination of your rights under this section does not terminate the
418 | licenses of parties who have received copies or rights from you under
419 | this License. If your rights have been terminated and not permanently
420 | reinstated, you do not qualify to receive new licenses for the same
421 | material under section 10.
422 |
423 | 9. Acceptance Not Required for Having Copies.
424 |
425 | You are not required to accept this License in order to receive or
426 | run a copy of the Program. Ancillary propagation of a covered work
427 | occurring solely as a consequence of using peer-to-peer transmission
428 | to receive a copy likewise does not require acceptance. However,
429 | nothing other than this License grants you permission to propagate or
430 | modify any covered work. These actions infringe copyright if you do
431 | not accept this License. Therefore, by modifying or propagating a
432 | covered work, you indicate your acceptance of this License to do so.
433 |
434 | 10. Automatic Licensing of Downstream Recipients.
435 |
436 | Each time you convey a covered work, the recipient automatically
437 | receives a license from the original licensors, to run, modify and
438 | propagate that work, subject to this License. You are not responsible
439 | for enforcing compliance by third parties with this License.
440 |
441 | An "entity transaction" is a transaction transferring control of an
442 | organization, or substantially all assets of one, or subdividing an
443 | organization, or merging organizations. If propagation of a covered
444 | work results from an entity transaction, each party to that
445 | transaction who receives a copy of the work also receives whatever
446 | licenses to the work the party's predecessor in interest had or could
447 | give under the previous paragraph, plus a right to possession of the
448 | Corresponding Source of the work from the predecessor in interest, if
449 | the predecessor has it or can get it with reasonable efforts.
450 |
451 | You may not impose any further restrictions on the exercise of the
452 | rights granted or affirmed under this License. For example, you may
453 | not impose a license fee, royalty, or other charge for exercise of
454 | rights granted under this License, and you may not initiate litigation
455 | (including a cross-claim or counterclaim in a lawsuit) alleging that
456 | any patent claim is infringed by making, using, selling, offering for
457 | sale, or importing the Program or any portion of it.
458 |
459 | 11. Patents.
460 |
461 | A "contributor" is a copyright holder who authorizes use under this
462 | License of the Program or a work on which the Program is based. The
463 | work thus licensed is called the contributor's "contributor version".
464 |
465 | A contributor's "essential patent claims" are all patent claims
466 | owned or controlled by the contributor, whether already acquired or
467 | hereafter acquired, that would be infringed by some manner, permitted
468 | by this License, of making, using, or selling its contributor version,
469 | but do not include claims that would be infringed only as a
470 | consequence of further modification of the contributor version. For
471 | purposes of this definition, "control" includes the right to grant
472 | patent sublicenses in a manner consistent with the requirements of
473 | this License.
474 |
475 | Each contributor grants you a non-exclusive, worldwide, royalty-free
476 | patent license under the contributor's essential patent claims, to
477 | make, use, sell, offer for sale, import and otherwise run, modify and
478 | propagate the contents of its contributor version.
479 |
480 | In the following three paragraphs, a "patent license" is any express
481 | agreement or commitment, however denominated, not to enforce a patent
482 | (such as an express permission to practice a patent or covenant not to
483 | sue for patent infringement). To "grant" such a patent license to a
484 | party means to make such an agreement or commitment not to enforce a
485 | patent against the party.
486 |
487 | If you convey a covered work, knowingly relying on a patent license,
488 | and the Corresponding Source of the work is not available for anyone
489 | to copy, free of charge and under the terms of this License, through a
490 | publicly available network server or other readily accessible means,
491 | then you must either (1) cause the Corresponding Source to be so
492 | available, or (2) arrange to deprive yourself of the benefit of the
493 | patent license for this particular work, or (3) arrange, in a manner
494 | consistent with the requirements of this License, to extend the patent
495 | license to downstream recipients. "Knowingly relying" means you have
496 | actual knowledge that, but for the patent license, your conveying the
497 | covered work in a country, or your recipient's use of the covered work
498 | in a country, would infringe one or more identifiable patents in that
499 | country that you have reason to believe are valid.
500 |
501 | If, pursuant to or in connection with a single transaction or
502 | arrangement, you convey, or propagate by procuring conveyance of, a
503 | covered work, and grant a patent license to some of the parties
504 | receiving the covered work authorizing them to use, propagate, modify
505 | or convey a specific copy of the covered work, then the patent license
506 | you grant is automatically extended to all recipients of the covered
507 | work and works based on it.
508 |
509 | A patent license is "discriminatory" if it does not include within
510 | the scope of its coverage, prohibits the exercise of, or is
511 | conditioned on the non-exercise of one or more of the rights that are
512 | specifically granted under this License. You may not convey a covered
513 | work if you are a party to an arrangement with a third party that is
514 | in the business of distributing software, under which you make payment
515 | to the third party based on the extent of your activity of conveying
516 | the work, and under which the third party grants, to any of the
517 | parties who would receive the covered work from you, a discriminatory
518 | patent license (a) in connection with copies of the covered work
519 | conveyed by you (or copies made from those copies), or (b) primarily
520 | for and in connection with specific products or compilations that
521 | contain the covered work, unless you entered into that arrangement,
522 | or that patent license was granted, prior to 28 March 2007.
523 |
524 | Nothing in this License shall be construed as excluding or limiting
525 | any implied license or other defenses to infringement that may
526 | otherwise be available to you under applicable patent law.
527 |
528 | 12. No Surrender of Others' Freedom.
529 |
530 | If conditions are imposed on you (whether by court order, agreement or
531 | otherwise) that contradict the conditions of this License, they do not
532 | excuse you from the conditions of this License. If you cannot convey a
533 | covered work so as to satisfy simultaneously your obligations under this
534 | License and any other pertinent obligations, then as a consequence you may
535 | not convey it at all. For example, if you agree to terms that obligate you
536 | to collect a royalty for further conveying from those to whom you convey
537 | the Program, the only way you could satisfy both those terms and this
538 | License would be to refrain entirely from conveying the Program.
539 |
540 | 13. Remote Network Interaction; Use with the GNU General Public License.
541 |
542 | Notwithstanding any other provision of this License, if you modify the
543 | Program, your modified version must prominently offer all users
544 | interacting with it remotely through a computer network (if your version
545 | supports such interaction) an opportunity to receive the Corresponding
546 | Source of your version by providing access to the Corresponding Source
547 | from a network server at no charge, through some standard or customary
548 | means of facilitating copying of software. This Corresponding Source
549 | shall include the Corresponding Source for any work covered by version 3
550 | of the GNU General Public License that is incorporated pursuant to the
551 | following paragraph.
552 |
553 | Notwithstanding any other provision of this License, you have
554 | permission to link or combine any covered work with a work licensed
555 | under version 3 of the GNU General Public License into a single
556 | combined work, and to convey the resulting work. The terms of this
557 | License will continue to apply to the part which is the covered work,
558 | but the work with which it is combined will remain governed by version
559 | 3 of the GNU General Public License.
560 |
561 | 14. Revised Versions of this License.
562 |
563 | The Free Software Foundation may publish revised and/or new versions of
564 | the GNU Affero General Public License from time to time. Such new versions
565 | will be similar in spirit to the present version, but may differ in detail to
566 | address new problems or concerns.
567 |
568 | Each version is given a distinguishing version number. If the
569 | Program specifies that a certain numbered version of the GNU Affero General
570 | Public License "or any later version" applies to it, you have the
571 | option of following the terms and conditions either of that numbered
572 | version or of any later version published by the Free Software
573 | Foundation. If the Program does not specify a version number of the
574 | GNU Affero General Public License, you may choose any version ever published
575 | by the Free Software Foundation.
576 |
577 | If the Program specifies that a proxy can decide which future
578 | versions of the GNU Affero General Public License can be used, that proxy's
579 | public statement of acceptance of a version permanently authorizes you
580 | to choose that version for the Program.
581 |
582 | Later license versions may give you additional or different
583 | permissions. However, no additional obligations are imposed on any
584 | author or copyright holder as a result of your choosing to follow a
585 | later version.
586 |
587 | 15. Disclaimer of Warranty.
588 |
589 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
590 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
591 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
592 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
593 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
594 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
595 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
596 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
597 |
598 | 16. Limitation of Liability.
599 |
600 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
601 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
602 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
603 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
604 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
605 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
606 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
607 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
608 | SUCH DAMAGES.
609 |
610 | 17. Interpretation of Sections 15 and 16.
611 |
612 | If the disclaimer of warranty and limitation of liability provided
613 | above cannot be given local legal effect according to their terms,
614 | reviewing courts shall apply local law that most closely approximates
615 | an absolute waiver of all civil liability in connection with the
616 | Program, unless a warranty or assumption of liability accompanies a
617 | copy of the Program in return for a fee.
618 |
619 | END OF TERMS AND CONDITIONS
620 |
621 | How to Apply These Terms to Your New Programs
622 |
623 | If you develop a new program, and you want it to be of the greatest
624 | possible use to the public, the best way to achieve this is to make it
625 | free software which everyone can redistribute and change under these terms.
626 |
627 | To do so, attach the following notices to the program. It is safest
628 | to attach them to the start of each source file to most effectively
629 | state the exclusion of warranty; and each file should have at least
630 | the "copyright" line and a pointer to where the full notice is found.
631 |
632 |
633 | Copyright (C)
634 |
635 | This program is free software: you can redistribute it and/or modify
636 | it under the terms of the GNU Affero General Public License as published
637 | by the Free Software Foundation, either version 3 of the License, or
638 | (at your option) any later version.
639 |
640 | This program is distributed in the hope that it will be useful,
641 | but WITHOUT ANY WARRANTY; without even the implied warranty of
642 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
643 | GNU Affero General Public License for more details.
644 |
645 | You should have received a copy of the GNU Affero General Public License
646 | along with this program. If not, see .
647 |
648 | Also add information on how to contact you by electronic and paper mail.
649 |
650 | If your software can interact with users remotely through a computer
651 | network, you should also make sure that it provides a way for users to
652 | get its source. For example, if your program is a web application, its
653 | interface could display a "Source" link that leads users to an archive
654 | of the code. There are many ways you could offer source, and different
655 | solutions will be better for different programs; see section 13 for the
656 | specific requirements.
657 |
658 | You should also get your employer (if you work as a programmer) or school,
659 | if any, to sign a "copyright disclaimer" for the program, if necessary.
660 | For more information on this, and how to apply and follow the GNU AGPL, see
661 | .
662 |
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | include MANIFEST.in
2 | include abeluna/ui/*
3 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | Abeluna [](https://github.com/Ninjaclasher/abeluna/actions/)
2 | =====
3 |
4 | A simple GUI to-do/task manager with CalDAV support. In theory, Abeluna should support any CalDAV server, but currently only [Nextcloud](https://apps.nextcloud.com/apps/tasks) and [Radicale](https://radicale.org/3.0.html) are tested.
5 |
6 | The goal of this application is to become a desktop version of Nextcloud's Tasks app. As such, not all functionality in the [icalendar's VTODO](https://icalendar.org/iCalendar-RFC-5545/3-6-2-to-do-component.html) are supported, only those that are used by Nextcloud. On the other hand, there some non-standard fields used by Nextcloud that are supported by Abeluna, such as the ability to hide subtasks.
7 |
8 | ## Screenshots
9 |
10 | 
11 |
12 | 
13 |
14 | 
15 |
16 | ## Installation
17 |
18 | ### From PyPI
19 | First, install two packages, `libnotify` and `gobject-introspection`. On other distributions besides Arch Linux, these names may be different. For example, on Debian-based systems, `gobject-introspection` is `libgirepository1.0-dev`.
20 |
21 | ```sh
22 | $ pip install abeluna
23 | $ abeluna
24 | ```
25 |
26 | ### AUR
27 | If you are using Arch Linux and do not wish to install through PIP, you can install the AUR package [abeluna](https://aur.archlinux.org/packages/abeluna/) for the latest version.
28 |
29 | ## Usage
30 | ```sh
31 | $ abeluna
32 | ```
33 |
34 | In the GUI, calendars can be added through `Settings > Calendar settings`. General settings, such as the timezone and synchronization schedule can be accessed through `Settings > General settings`.
35 |
36 | ## Future Plans
37 | - Support for desktop notifications.
38 | - Support for recurring tasks.
39 | - Add common keyboard shortcuts.
40 |
--------------------------------------------------------------------------------
/abeluna/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ninjaclasher/abeluna/6ad5fca9a3b481d0da8fdaf7133c51b0da93317c/abeluna/__init__.py
--------------------------------------------------------------------------------
/abeluna/main.py:
--------------------------------------------------------------------------------
1 | import gi
2 |
3 | gi.require_version('Gtk', '3.0')
4 | gi.require_version('Gdk', '3.0')
5 | gi.require_version('Notify', '0.7')
6 |
7 | import os
8 | import sys
9 |
10 | import humanize
11 | from gi.repository import GLib, GObject, Gio, Gtk, Notify
12 |
13 | from abeluna.sync import server
14 | from abeluna.util import colour_text
15 | from abeluna.windows import SettingsWindow, TodoListWindow
16 |
17 |
18 | UI_LOCATION = os.path.join(os.path.dirname(__file__), 'ui')
19 |
20 |
21 | class MainWindow(Gtk.ApplicationWindow):
22 | def __init__(self, app):
23 | Gtk.Window.__init__(self, title='Abeluna', application=app)
24 | self.set_default_size(1200, 600)
25 |
26 | self.todolist_window = TodoListWindow()
27 |
28 | self.connect('button-press-event', self.todolist_window.reset_action_popover)
29 | self.connect('configure-event', self.todolist_window.reset_action_popover)
30 |
31 | self.new_todo_action = Gio.SimpleAction.new('new-todo', None)
32 | self.new_todo_action.connect('activate', lambda action, parameter: self.todolist_window.new_todo())
33 | self.add_action(self.new_todo_action)
34 | self.sync_todo_action = Gio.SimpleAction.new('sync-todo', None)
35 | self.sync_todo_action.connect('activate', lambda action, parameter: server.synchronize_todolist())
36 | self.add_action(self.sync_todo_action)
37 | self.general_settings_action = Gio.SimpleAction.new('general-settings', None)
38 | self.general_settings_action.connect(
39 | 'activate',
40 | lambda action, parameter: SettingsWindow(parent=self, active_child='General'),
41 | )
42 | self.add_action(self.general_settings_action)
43 | self.calendar_settings_action = Gio.SimpleAction.new('calendar-settings', None)
44 | self.calendar_settings_action.connect(
45 | 'activate',
46 | lambda action, parameter: SettingsWindow(parent=self, active_child='Calendars'),
47 | )
48 | self.add_action(self.calendar_settings_action)
49 |
50 | self.main_grid = Gtk.Grid()
51 | self.main_grid.set_column_homogeneous(True)
52 | self.main_grid.set_column_spacing(5)
53 | self.main_grid.set_row_homogeneous(True)
54 | self.main_grid.set_row_spacing(5)
55 | self.main_grid.set_border_width(10)
56 | self.add(self.main_grid)
57 |
58 | self.calendar_store = Gtk.ListStore(str, str)
59 | self.calendar_uid_to_iter = {}
60 | self.calendar_tree_view = Gtk.TreeView(model=self.calendar_store)
61 | self.calendar_tree_view.append_column(Gtk.TreeViewColumn('Calendars', Gtk.CellRendererText(), text=0))
62 | self.calendar_tree_view.get_selection().connect('changed', self.calendar_tree_selection_changed)
63 | self.calendar_tree_view.set_enable_search(False)
64 | self.calendar_tree_view.set_search_column(-1)
65 | self.calendar_tree_view.set_tooltip_column(0)
66 |
67 | self.calendar_scrollable_view = Gtk.ScrolledWindow(vexpand=True)
68 | self.calendar_scrollable_view.set_propagate_natural_width(True)
69 | self.calendar_scrollable_view.set_shadow_type(type=Gtk.ShadowType.ETCHED_OUT)
70 | self.calendar_scrollable_view.add(self.calendar_tree_view)
71 |
72 | self.main_grid.attach(self.calendar_scrollable_view, 0, 0, 4, 19)
73 |
74 | self.status_view = Gtk.Grid()
75 | self.status_view.set_column_spacing(5)
76 | self.status_view.set_hexpand(True)
77 | self.status_view.set_border_width(5)
78 | self.sync_spinner = Gtk.Spinner()
79 | self.sync_spinner.set_halign(Gtk.Align.START)
80 | self.sync_spinner.set_hexpand(False)
81 | self.status_view.add(self.sync_spinner)
82 | self.sync_label = Gtk.Label(label=' ')
83 | self.sync_label.set_halign(Gtk.Align.END)
84 | self.sync_label.set_hexpand(True)
85 | self.sync_label.set_property('use-markup', True)
86 | self.status_view.add(self.sync_label)
87 | self.main_grid.attach_next_to(
88 | self.status_view, self.calendar_scrollable_view, Gtk.PositionType.BOTTOM, 4, 1,
89 | )
90 |
91 | self.main_grid.attach_next_to(
92 | self.todolist_window, self.calendar_scrollable_view, Gtk.PositionType.RIGHT, 13, 20,
93 | )
94 |
95 | self.main_grid.show_all()
96 |
97 | GObject.timeout_add_seconds(30, self.update_natural_dates)
98 | server.refresh_calendars()
99 | self.rebuild_calendarlist()
100 | server.sync_connect(self.on_todo_sync)
101 |
102 | def on_todo_sync_watcher(self):
103 | if self._syncing:
104 | self.sync_spinner.start()
105 | return True
106 | self.todolist_window.rebuild_todolist()
107 | self.sync_spinner.stop()
108 | self.update_natural_dates()
109 | return False
110 |
111 | def on_todo_sync(self, mode):
112 | if mode == 'PRE_SYNC':
113 | self._syncing = True
114 | GObject.timeout_add(200, self.on_todo_sync_watcher)
115 | elif mode == 'POST_SYNC':
116 | self._syncing = False
117 |
118 | def update_natural_dates(self):
119 | last_sync = server.last_sync
120 | if last_sync is None:
121 | self.sync_label.set_label(' ')
122 | self.sync_label.set_tooltip_text('')
123 | else:
124 | self.sync_label.set_label(colour_text('Last synced {}.'.format(humanize.naturaltime(last_sync)), '#666'))
125 | self.sync_label.set_tooltip_text(last_sync.strftime('%c'))
126 | return True
127 |
128 | def rebuild_calendarlist(self):
129 | path_iter = self.calendar_tree_view.get_selection().get_selected()[1]
130 | if path_iter is None:
131 | _currently_selected_uid = None
132 | else:
133 | _currently_selected_uid = self.calendar_store[path_iter][1]
134 | self.calendar_store.clear()
135 | self.calendar_uid_to_iter.clear()
136 |
137 | for uid, calendar in server.calendars.items():
138 | self.calendar_uid_to_iter[uid] = self.calendar_store.append([calendar.name, uid])
139 |
140 | try:
141 | self.calendar_tree_view.get_selection().select_iter(
142 | self.calendar_uid_to_iter[_currently_selected_uid],
143 | )
144 | except KeyError:
145 | self.calendar_tree_selection_changed()
146 |
147 | def calendar_tree_selection_changed(self, calendar_tree_selection=None):
148 | self.todolist_window.reset_action_popover()
149 | if calendar_tree_selection is None:
150 | calendar_tree_selection = self.calendar_tree_view.get_selection()
151 |
152 | path_iter = calendar_tree_selection.get_selected()[1]
153 | if path_iter is None:
154 | self.new_todo_action.set_enabled(False)
155 | self.todolist_window.current_calendar = None
156 | else:
157 | self.new_todo_action.set_enabled(True)
158 | self.todolist_window.current_calendar = self.calendar_store[path_iter][1]
159 |
160 |
161 | class Abeluna(Gtk.Application):
162 | def __init__(self):
163 | Gtk.Application.__init__(self)
164 |
165 | def do_activate(self):
166 | win = MainWindow(self)
167 | win.show()
168 |
169 | def do_startup(self):
170 | Gtk.Application.do_startup(self)
171 | builder = Gtk.Builder()
172 | Notify.init('Abeluna')
173 | try:
174 | builder.add_from_file(os.path.join(UI_LOCATION, 'menubar.ui'))
175 | except GLib.Error:
176 | self.quit()
177 |
178 | self.set_menubar(builder.get_object('menubar'))
179 |
180 | quit_action = Gio.SimpleAction.new('quit', None)
181 | quit_action.connect('activate', lambda action, parameter: self.quit())
182 | self.add_action(quit_action)
183 |
184 | self.add_accelerator('N', 'win.new-todo', None)
185 | self.add_accelerator('R', 'win.sync-todo', None)
186 | self.add_accelerator('W', 'app.quit', None)
187 |
188 |
189 | def main():
190 | app = Abeluna()
191 | try:
192 | exit_code = app.run(sys.argv)
193 | except KeyboardInterrupt:
194 | exit_code = 1
195 | server.stop_all()
196 | return exit_code
197 |
198 |
199 | if __name__ == '__main__':
200 | main()
201 |
--------------------------------------------------------------------------------
/abeluna/settings.py:
--------------------------------------------------------------------------------
1 | import configparser
2 | import hashlib
3 | import os
4 | import threading
5 |
6 | import pytz
7 | from gi.repository import GLib
8 |
9 |
10 | def nonnegative_integer_validator(val):
11 | try:
12 | assert int(val) >= 0
13 | except (ValueError, AssertionError):
14 | return False
15 |
16 |
17 | class Settings:
18 | DEFAULT_GENERAL_CONFIG = {
19 | 'TIMEZONE': 'UTC',
20 | 'AUTOSYNC_INTERVAL': '600', # seconds
21 | 'SAVE_INTERVAL': '1', # seconds
22 | 'PRIORITIZE_ON_CONFLICT': 'SERVER',
23 | 'HIDE_COMPLETED': '0',
24 | 'ALL_DAY_DUE_TIME': '00:00',
25 | }
26 | VALID_GENERAL_CONFIG_VALUES = {
27 | 'TIMEZONE': pytz.all_timezones,
28 | 'AUTOSYNC_INTERVAL': [
29 | '-1', '10', '30', '60', '600', '1800', '3600', '21600', '86400', '604800', '2419200', '1036800',
30 | ],
31 | 'SAVE_INTERVAL': nonnegative_integer_validator,
32 | 'PRIORITIZE_ON_CONFLICT': ['SERVER', 'CLIENT'],
33 | 'HIDE_COMPLETED': ['0', '1'],
34 | 'ALL_DAY_DUE_TIME': ['{:02}:{:02}'.format(x, y) for x in range(24) for y in range(60)],
35 | }
36 |
37 | def __init__(self):
38 | self.TASK_STORAGE_LOCATION = os.path.join(GLib.get_user_data_dir(), 'abeluna', 'todolists')
39 | self.CONFIG_FILE = os.path.join(GLib.get_user_config_dir(), 'abeluna', 'config.ini')
40 | os.makedirs(os.path.dirname(self.CONFIG_FILE), mode=0o755, exist_ok=True)
41 | os.makedirs(self.TASK_STORAGE_LOCATION, mode=0o755, exist_ok=True)
42 |
43 | self._lock = threading.RLock()
44 |
45 | self.config = configparser.ConfigParser()
46 | self.config.read(self.CONFIG_FILE)
47 |
48 | self.CALENDARS = {}
49 |
50 | for section in self.config.sections():
51 | if section.startswith('calendar '):
52 | calendar = dict(self.config[section])
53 | self.add_or_update_calendar(calendar)
54 | self.commit()
55 |
56 | @property
57 | def ordered_calendars(self):
58 | _calendars = {}
59 | for key, value in sorted(self.CALENDARS.items(), key=lambda x: int(x[1]['order'] or -1)):
60 | _calendars[key] = value
61 | return _calendars
62 |
63 | def commit(self):
64 | with self._lock:
65 | for section in self.config.sections():
66 | if section.startswith('calendar '):
67 | self.config.remove_section(section)
68 | for uid, calendar in self.CALENDARS.items():
69 | section_name = 'calendar {}'.format(uid)
70 | self.config.add_section(section_name)
71 | self.config[section_name].update(calendar)
72 | with open(self.CONFIG_FILE, 'w') as f:
73 | self.config.write(f)
74 |
75 | def add_or_update_calendar(self, data):
76 | data.setdefault('local_storage', self.TASK_STORAGE_LOCATION)
77 | with self._lock:
78 | data.setdefault('order', str(len(self.CALENDARS) + 1))
79 |
80 | _old_uid = data.get('uid')
81 | hash_value = data.get('url', '').rstrip('/') or data['name']
82 | data['uid'] = _new_uid = hashlib.sha256(hash_value.encode()).hexdigest()
83 | if _old_uid is not None and _old_uid != _new_uid:
84 | # We're moving from a local todo list to a synced todo list, or vice versa
85 | _old_data = self.CALENDARS[_old_uid]
86 | if bool(_old_data['url']) ^ bool(data['url']):
87 | filename = '{}.db'
88 | try:
89 | os.rename(
90 | os.path.join(_old_data['local_storage'], filename.format(_old_uid)),
91 | os.path.join(data['local_storage'], filename.format(_new_uid)),
92 | )
93 | except FileNotFoundError:
94 | pass
95 | self.CALENDARS.pop(_old_uid)
96 |
97 | self.CALENDARS[_new_uid] = data
98 |
99 | def set_calendar_order(self, ordered_uids):
100 | with self._lock:
101 | if set(ordered_uids) != set(self.CALENDARS.keys()):
102 | return
103 | for order, uid in enumerate(ordered_uids, 1):
104 | self.CALENDARS[uid]['order'] = str(order)
105 |
106 | def delete_calendar(self, uid):
107 | with self._lock:
108 | try:
109 | self.CALENDARS.pop(uid)
110 | except KeyError:
111 | pass
112 |
113 | def __getattr__(self, field):
114 | if field in self.DEFAULT_GENERAL_CONFIG:
115 | with self._lock:
116 | try:
117 | value = self.config['General'][field]
118 | except KeyError:
119 | return self.DEFAULT_GENERAL_CONFIG[field]
120 |
121 | iterable_or_callable = self.VALID_GENERAL_CONFIG_VALUES[field]
122 | if callable(iterable_or_callable):
123 | valid = iterable_or_callable(value)
124 | else:
125 | valid = value in iterable_or_callable
126 | # if not valid:
127 | # print('Invalid setting "{}": {}'.format(field, value))
128 | return value if valid else self.DEFAULT_GENERAL_CONFIG[field]
129 | raise AttributeError()
130 |
131 | def __setattr__(self, field, value):
132 | if field in self.DEFAULT_GENERAL_CONFIG:
133 | with self._lock:
134 | if not self.config.has_section('General'):
135 | self.config.add_section('General')
136 | self.config['General'][field] = value
137 | else:
138 | super().__setattr__(field, value)
139 |
140 | def __getitem__(self, field):
141 | return getattr(self, field)
142 |
143 | def __setitem__(self, field, value):
144 | setattr(self, field, value)
145 |
146 |
147 | settings = Settings()
148 |
--------------------------------------------------------------------------------
/abeluna/sync/__init__.py:
--------------------------------------------------------------------------------
1 | from abeluna.sync.calendar import Calendar
2 | from abeluna.sync.local import LocalServer
3 | from abeluna.sync.server import server
4 |
--------------------------------------------------------------------------------
/abeluna/sync/calendar.py:
--------------------------------------------------------------------------------
1 | import caldav
2 |
3 | from abeluna.sync.local import LocalServer
4 |
5 |
6 | class Calendar:
7 | def __init__(self, uid, name, url, username, password, local_storage):
8 | self.uid = uid
9 | self.name = name
10 | self.url = url
11 | self.username = username
12 | self.password = password
13 | self.local_storage = local_storage
14 |
15 | if self.uid and self.local_storage:
16 | self.local_server = LocalServer(self.local_storage, self.uid)
17 | else:
18 | self.local_server = None
19 |
20 | if self.url:
21 | self.client = caldav.DAVClient(
22 | url=self.url,
23 | username=self.username,
24 | password=self.password,
25 | )
26 | self.calendar = caldav.Calendar(client=self.client, url=self.url)
27 | else:
28 | self.client = self.calendar = None
29 |
30 | @property
31 | def is_local(self):
32 | return self.client is None
33 |
34 | def validate(self):
35 | if self.is_local:
36 | self._validated = True
37 | else:
38 | try:
39 | response = self.client.propfind(self.url)
40 | except Exception:
41 | self._validated = False
42 | else:
43 | self._validated = response.status in (200, 207)
44 | return self._validated
45 |
46 | def to_dict(self):
47 | return {
48 | 'uid': self.uid,
49 | 'name': self.name,
50 | 'url': self.url,
51 | 'username': self.username,
52 | 'password': self.password,
53 | 'local_storage': self.local_storage,
54 | }
55 |
56 | @classmethod
57 | def from_dict(cls, d):
58 | return cls(
59 | d.get('uid', ''),
60 | d['name'],
61 | d['url'],
62 | d['username'],
63 | d['password'],
64 | d.get('local_storage', ''),
65 | )
66 |
--------------------------------------------------------------------------------
/abeluna/sync/local.py:
--------------------------------------------------------------------------------
1 | import os
2 | import sqlite3
3 | from collections import namedtuple
4 | from functools import partial
5 |
6 | import icalendar
7 |
8 |
9 | class LocalTodo(namedtuple('LocalTodo', 'uid local_vtodo remote_vtodo')):
10 | def __eq__(self, other):
11 | if isinstance(other, str):
12 | return self.uid == other
13 | return self.uid == other.uid
14 |
15 | def __hash__(self):
16 | return self.uid
17 |
18 |
19 | class LocalServer:
20 | def __init__(self, path, calendar_name):
21 | self.path = path
22 | self.calendar = calendar_name
23 | self.conn = partial(sqlite3.connect, os.path.join(self.path, '{}.db'.format(self.calendar)))
24 |
25 | with self.conn() as c:
26 | c.execute('''
27 | CREATE TABLE IF NOT EXISTS todo
28 | (uid TEXT PRIMARY KEY, local_vtodo TEXT, remote_vtodo TEXT)
29 | ''')
30 | c.commit()
31 |
32 | self.todolist = []
33 |
34 | def _sanitize_uid(self, uid):
35 | return str(uid)
36 |
37 | def todos(self, include_deleted=False):
38 | def create_ical(val):
39 | if val is None:
40 | return val
41 | return icalendar.Calendar.from_ical(val)
42 |
43 | with self.conn() as c:
44 | data = c.execute(
45 | '''
46 | SELECT * FROM todo
47 | {where}
48 | '''.format(where='' if include_deleted else 'WHERE local_vtodo IS NOT NULL'),
49 | ).fetchall()
50 |
51 | return [
52 | LocalTodo(
53 | uid=item[0],
54 | local_vtodo=create_ical(item[1]),
55 | remote_vtodo=create_ical(item[2]),
56 | ) for item in data
57 | ]
58 |
59 | def update_todo_from_server(self, vtodo):
60 | ical = vtodo.to_ical().decode()
61 | uid = self._sanitize_uid(vtodo['UID'])
62 | with self.conn() as c:
63 | c.execute(
64 | '''
65 | INSERT OR IGNORE INTO todo
66 | VALUES (?, NULL, NULL)
67 | ''',
68 | (uid,),
69 | )
70 | c.execute(
71 | '''
72 | UPDATE todo
73 | SET local_vtodo=?, remote_vtodo=?
74 | WHERE uid=?
75 | ''',
76 | (ical, ical, uid),
77 | )
78 | c.commit()
79 |
80 | def update_todo_from_client(self, vtodo): # also includes creating the todo
81 | ical = vtodo.to_ical().decode()
82 | uid = self._sanitize_uid(vtodo['UID'])
83 | with self.conn() as c:
84 | c.execute(
85 | '''
86 | INSERT OR IGNORE INTO todo
87 | VALUES (?, NULL, NULL)
88 | ''',
89 | (uid,),
90 | )
91 | c.execute(
92 | '''
93 | UPDATE todo
94 | SET local_vtodo=?
95 | WHERE uid=?
96 | ''',
97 | (ical, uid),
98 | )
99 | c.commit()
100 |
101 | def delete_todo_from_server(self, vtodo):
102 | with self.conn() as c:
103 | c.execute(
104 | '''
105 | DELETE FROM todo
106 | WHERE uid = ?
107 | ''',
108 | (self._sanitize_uid(vtodo['UID']),),
109 | )
110 | c.commit()
111 |
112 | def delete_todo_from_client(self, vtodo):
113 | uid = self._sanitize_uid(vtodo['UID'])
114 | with self.conn() as c:
115 | c.execute(
116 | '''
117 | DELETE FROM todo
118 | WHERE uid = ? and remote_vtodo IS NULL
119 | ''',
120 | (uid,),
121 | )
122 | c.execute(
123 | '''
124 | UPDATE todo
125 | SET local_vtodo = NULL
126 | WHERE uid = ? and remote_vtodo IS NOT NULL
127 | ''',
128 | (uid,),
129 | )
130 | c.commit()
131 |
--------------------------------------------------------------------------------
/abeluna/sync/server.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | import queue
3 | import threading
4 | import time
5 | import uuid
6 | from collections import defaultdict
7 | from functools import wraps
8 |
9 | import caldav
10 | import icalendar
11 |
12 | from abeluna.settings import settings
13 | from abeluna.sync.calendar import Calendar
14 | from abeluna.util import generate_vtimezone
15 |
16 |
17 | class Task:
18 | def __init__(self, progress=0):
19 | self.uid = uuid.uuid4().hex
20 | self._progress = progress
21 |
22 | def __str__(self):
23 | return self.uid
24 |
25 | @property
26 | def progress(self):
27 | return self._progress
28 |
29 | @progress.setter
30 | def progress(self, value):
31 | self._progress = value
32 |
33 | @property
34 | def completed(self):
35 | return self._progress == 100
36 |
37 | @completed.setter
38 | def completed(self, value):
39 | self._progress = 100 if value else 0
40 |
41 |
42 | def background_task(method):
43 | @wraps(method)
44 | def wrapper(self, *args, **kwargs):
45 | try:
46 | task = kwargs.pop('task', Task())
47 | delay_abs = kwargs.pop('delay', -1) + self.timefunc()
48 | self.task_queue.put_nowait((delay_abs, method, task, (self, task) + args, kwargs))
49 | except queue.Full:
50 | return None
51 | else:
52 | return task
53 | return wrapper
54 |
55 |
56 | class SynchronizationServer:
57 | def __init__(self):
58 | self.task_queue = queue.PriorityQueue()
59 |
60 | self.todolist = defaultdict(list)
61 |
62 | self._stop_lock = threading.RLock()
63 |
64 | self._worker_stop = threading.Event()
65 | self._worker_thread = threading.Thread(target=self.worker_run)
66 | self._worker_thread.start()
67 | self._general_lock = threading.RLock()
68 | self._update_todo_skip = {}
69 | self.timefunc = time.time
70 |
71 | self._autosync_stop = threading.Event()
72 | self._autosync_thread = None
73 | self.restart_autosync_thread()
74 | self.last_sync = None
75 | self._sync_lock = threading.RLock()
76 | self._sync_callbacks = []
77 |
78 | self.calendars = {}
79 |
80 | def stop_all(self):
81 | with self._stop_lock:
82 | self._worker_stop.set()
83 | self._autosync_stop.set()
84 | self._worker_thread.join()
85 | self._autosync_thread.join()
86 |
87 | def worker_run(self):
88 | timeout = 0
89 | while not self._worker_stop.wait(timeout=timeout):
90 | try:
91 | delay_abs, method, task, args, kwargs = self.task_queue.get_nowait()
92 | if delay_abs > self.timefunc():
93 | self.task_queue.put_nowait((delay_abs, method, task, args, kwargs))
94 | raise queue.Empty()
95 | except queue.Empty:
96 | timeout = 0.25
97 | else:
98 | timeout = 0
99 | try:
100 | task.completed = method(*args, **kwargs)
101 | except KeyboardInterrupt:
102 | break
103 | except Exception: # catch all
104 | task.completed = False
105 | import traceback
106 | traceback.print_exc()
107 |
108 | # print('Process background task:', method, task)
109 | self.task_queue.task_done()
110 |
111 | def restart_autosync_thread(self):
112 | with self._stop_lock:
113 | if self._autosync_stop.is_set():
114 | return
115 | self._autosync_stop.set()
116 | if self._autosync_thread is not None:
117 | self._autosync_thread.join()
118 | self._autosync_stop.clear()
119 |
120 | self._autosync_thread = threading.Thread(target=self.autosync_run)
121 | self._autosync_thread.start()
122 |
123 | def autosync_run(self):
124 | if settings.AUTOSYNC_INTERVAL == '-1':
125 | return
126 | while not self._autosync_stop.wait(timeout=int(settings.AUTOSYNC_INTERVAL)):
127 | try:
128 | self._synchronize_todolist()
129 | except KeyboardInterrupt:
130 | break
131 | except Exception: # catch all
132 | import traceback
133 | traceback.print_exc()
134 |
135 | def sync_connect(self, callback, *args, **kwargs):
136 | self._sync_callbacks.append((callback, args, kwargs))
137 |
138 | def _merge_todo(self, uid, local_copy_of_local, local_copy_of_remote, remote_copy_of_remote):
139 | # Nothing changed, don't touch anything.
140 | if local_copy_of_local.to_ical() == remote_copy_of_remote.to_ical():
141 | return False, remote_copy_of_remote
142 | # Nothing changed server side, so use the client todo if there are any updates.
143 | elif local_copy_of_remote.to_ical() == remote_copy_of_remote.to_ical():
144 | # print(uid, 'was changed locally but not changed on remote. Pushing to remote...')
145 | return True, local_copy_of_local
146 | # Something changed server side and client side, so we will prioritize the server.
147 | elif local_copy_of_remote.to_ical() != remote_copy_of_remote.to_ical():
148 | # print(uid, 'was changed both locally and on remote. Merging...')
149 |
150 | updated = False
151 | # User prioritizes server.
152 | for key, value in local_copy_of_local.items():
153 | try:
154 | remote_copy_of_remote_value = remote_copy_of_remote[key].to_ical()
155 | except KeyError:
156 | remote_copy_of_remote_value = None
157 | try:
158 | local_copy_of_remote_value = local_copy_of_remote[key].to_ical()
159 | except KeyError:
160 | local_copy_of_remote_value = None
161 | try:
162 | local_copy_of_local_value = local_copy_of_local[key].to_ical()
163 | except KeyError:
164 | local_copy_of_local_value = None
165 |
166 | # Nothing changed server side, so use client value.
167 | if (
168 | settings.PRIORITIZE_ON_CONFLICT == 'SERVER' and
169 | local_copy_of_remote_value == remote_copy_of_remote_value and
170 | local_copy_of_local_value != remote_copy_of_remote_value
171 | ):
172 | updated = True
173 | remote_copy_of_remote[key] = value
174 | # Something changed client side, so use client value.
175 | elif (
176 | settings.PRIORITIZE_ON_CONFLICT == 'CLIENT' and
177 | local_copy_of_local_value != local_copy_of_remote_value and
178 | local_copy_of_local_value != remote_copy_of_remote_value
179 | ):
180 | updated = True
181 | remote_copy_of_remote[key] = value
182 | return updated, remote_copy_of_remote
183 | # How did we get here...
184 | else:
185 | assert False
186 |
187 | def _synchronize_todolist(self):
188 | with self._sync_lock:
189 | for cb, args, kwargs in self._sync_callbacks:
190 | cb(*args, mode='PRE_SYNC', **kwargs)
191 |
192 | for cal in self.calendars.values():
193 | try:
194 | if cal.is_local:
195 | continue
196 | remote_todos = cal.calendar.todos(include_completed=True)
197 | local_todos = cal.local_server.todos(include_deleted=True)
198 |
199 | remote_uids = set()
200 | for remote_todo in remote_todos:
201 | remote_ical = remote_todo.icalendar_instance
202 | new_cal = remote_ical.copy()
203 | has_todo_component = False
204 | updated_todo_component = False
205 | for remote_item in remote_todo.icalendar_instance.subcomponents:
206 | # Keep all non-todo items unconditionally in case there are any.
207 | if not isinstance(remote_item, icalendar.Todo):
208 | new_cal.add_component(remote_item)
209 | continue
210 |
211 | uid = str(remote_item['UID'])
212 | remote_uids.add(uid)
213 | try:
214 | local_item = local_todos[local_todos.index(uid)]
215 | except ValueError:
216 | # print(uid, 'does not exist locally. Creating...')
217 | # Item exists on the server but does not exist locally AND was not deleted locally.
218 | has_todo_component = True
219 | new_cal.add_component(remote_item)
220 | cal.local_server.update_todo_from_server(remote_item)
221 | else:
222 | # Item exists on the server but does not exist locally AND was deleted locally.
223 | if local_item.local_vtodo is None:
224 | # print(uid, 'was deleted locally. Deleting from server...')
225 | updated_todo_component = True
226 | cal.local_server.delete_todo_from_server(remote_item)
227 | # Item exists on both the server and the client, compare the todos
228 | else:
229 | has_todo_component = True
230 | updated, item_to_use = self._merge_todo(
231 | uid,
232 | local_item.local_vtodo,
233 | local_item.remote_vtodo,
234 | remote_item,
235 | )
236 | updated_todo_component |= updated
237 |
238 | new_cal.add_component(item_to_use)
239 | cal.local_server.update_todo_from_server(item_to_use)
240 |
241 | if not has_todo_component:
242 | remote_todo.delete()
243 | elif updated_todo_component:
244 | remote_todo.icalendar_instance = new_cal
245 | remote_todo.save()
246 |
247 | for local_item in local_todos:
248 | # Item existed on server, so it was already processed.
249 | if local_item.uid in remote_uids:
250 | continue
251 |
252 | # Item has a record of being on the server, but it doesn't exist on the server anymore.
253 | # We can only assume it was deleted server-side.
254 | if local_item.remote_vtodo is not None:
255 | # print(local_item.uid, 'was deleted on remote. Deleting locally...')
256 | cal.local_server.delete_todo_from_server(local_item.remote_vtodo)
257 | # Item exists on client, has never existed on server, so create and push to the server.
258 | else:
259 | # print(local_item.uid, 'was created locally. Pushing to remote...')
260 | vcal = icalendar.Calendar()
261 | vcal.add('VERSION', '2.0')
262 | vcal.add('PRODID', '-//Abeluna//NONSGML v1.0//EN')
263 | vcal.add('CALSCALE', 'GREGORIAN')
264 | vtimezone = generate_vtimezone()
265 | if vtimezone is not None:
266 | vcal.add_component(vtimezone)
267 | vcal.add_component(local_item.local_vtodo)
268 | caldav.Todo(cal.client, data=vcal, parent=cal.calendar, id=local_item.uid).save()
269 | cal.local_server.update_todo_from_server(local_item.local_vtodo)
270 | except Exception:
271 | import traceback
272 | traceback.print_exc()
273 |
274 | self.initialize_todolist()
275 | self.last_sync = datetime.datetime.now()
276 | for cb, args, kwargs in self._sync_callbacks:
277 | cb(*args, mode='POST_SYNC', **kwargs)
278 |
279 | @background_task
280 | def synchronize_todolist(self, task):
281 | self._synchronize_todolist()
282 | return True
283 |
284 | def initialize_todolist(self, uid=None):
285 | with self._sync_lock:
286 | if uid is not None:
287 | try:
288 | calendar = self.calendars[uid]
289 | except KeyError:
290 | pass
291 | else:
292 | self.todolist[uid] = [item.local_vtodo for item in calendar.local_server.todos()]
293 | else:
294 | self.todolist.clear()
295 | for uid, cal in self.calendars.items():
296 | self.todolist[uid] = [item.local_vtodo for item in cal.local_server.todos()]
297 |
298 | def refresh_calendars(self):
299 | new_calendars = {}
300 | for uid, cal_dict in settings.ordered_calendars.items():
301 | cal = Calendar.from_dict(cal_dict)
302 | new_calendars[uid] = cal
303 |
304 | with self._sync_lock:
305 | self.calendars = new_calendars
306 | self.initialize_todolist()
307 |
308 | @background_task
309 | def update_todo(self, task, vtodo, cal_uid, postpone=True):
310 | uid = str(vtodo['UID'])
311 | _time = self.timefunc()
312 | with self._general_lock:
313 | # Some sketchy logic to "batch" saves together.
314 | # E.g when the user is editting a textbox, don't save after every keystroke.
315 | if postpone:
316 | if uid not in self._update_todo_skip:
317 | self.update_todo(vtodo, cal_uid, task=task, postpone=False)
318 | self._update_todo_skip[uid] = _time
319 | return False
320 | else:
321 | time_diff = int(settings.SAVE_INTERVAL) - (_time - self._update_todo_skip.get(uid, 0))
322 | if time_diff > 0:
323 | self.update_todo(vtodo, cal_uid, task=task, postpone=False, delay=time_diff)
324 | return False
325 | else:
326 | try:
327 | self._update_todo_skip.pop(uid)
328 | except KeyError:
329 | pass
330 |
331 | with self._sync_lock:
332 | self.calendars[cal_uid].local_server.update_todo_from_client(vtodo)
333 | self.initialize_todolist(uid=cal_uid)
334 | return True
335 |
336 | @background_task
337 | def delete_todo(self, task, vtodo, cal_uid):
338 | with self._sync_lock:
339 | self.calendars[cal_uid].local_server.delete_todo_from_client(vtodo)
340 | self.initialize_todolist(uid=cal_uid)
341 | return True
342 |
343 |
344 | server = SynchronizationServer()
345 |
--------------------------------------------------------------------------------
/abeluna/ui/menubar.ui:
--------------------------------------------------------------------------------
1 |
2 |
3 |
35 |
36 |
--------------------------------------------------------------------------------
/abeluna/util.py:
--------------------------------------------------------------------------------
1 | import datetime
2 |
3 | import icalendar
4 | import pytz
5 |
6 | from abeluna.settings import settings
7 |
8 |
9 | def generate_vtimezone(timezone=None, for_date=None):
10 | # In case the setting value changes.
11 | if timezone is None:
12 | timezone = settings.TIMEZONE
13 |
14 | def _vtimezone_without_dst(std, timezone):
15 | vtimezone = icalendar.Timezone(tzid=timezone)
16 | standard = icalendar.TimezoneStandard()
17 | utc_offset, dst_offset, tz_name = std[1]
18 | standard.add('dtstart', std[0])
19 | standard.add('tzoffsetfrom', utc_offset)
20 | standard.add('tzoffsetto', utc_offset)
21 | standard.add('tzname', tz_name)
22 | vtimezone.add_component(standard)
23 | return vtimezone
24 |
25 | def _vtimezone_with_dst(dst1, std1, dst2, std2, timezone):
26 | vtimezone = icalendar.Timezone(tzid=timezone)
27 | daylight = icalendar.TimezoneDaylight()
28 | utc_offset, dst_offset, tz_name = dst1[1]
29 | offsetfrom = std1[1][0]
30 | daylight.add('dtstart', dst1[0] + offsetfrom)
31 | daylight.add('rdate', dst1[0] + offsetfrom)
32 | daylight.add('rdate', dst2[0] + offsetfrom)
33 | daylight.add('tzoffsetfrom', offsetfrom)
34 | daylight.add('tzoffsetto', utc_offset)
35 | daylight.add('tzname', tz_name)
36 | vtimezone.add_component(daylight)
37 |
38 | standard = icalendar.TimezoneStandard()
39 | utc_offset, dst_offset, tz_name = std1[1]
40 | offsetfrom = dst1[1][0]
41 | standard.add('dtstart', std1[0] + offsetfrom)
42 | standard.add('rdate', std1[0] + offsetfrom)
43 | standard.add('rdate', std2[0] + offsetfrom)
44 | standard.add('tzoffsetfrom', offsetfrom)
45 | standard.add('tzoffsetto', utc_offset)
46 | standard.add('tzname', tz_name)
47 | vtimezone.add_component(standard)
48 | return vtimezone
49 |
50 | if not timezone or 'utc' in timezone.lower():
51 | return None
52 | if for_date is None:
53 | for_date = datetime.datetime.now()
54 | z = pytz.timezone(timezone)
55 | transitions = zip(z._utc_transition_times, z._transition_info)
56 | try:
57 | dst1, std1, dst2, std2 = filter(lambda x: x[0].year in (for_date.year, for_date.year + 1),
58 | transitions)
59 | if dst1[1][1].seconds == 0:
60 | return _vtimezone_with_dst(std1, dst1, std2, dst2, timezone)
61 | else:
62 | return _vtimezone_with_dst(dst1, std1, dst2, std2, timezone)
63 |
64 | except Exception:
65 | std = transitions[-1]
66 | if std[0].year > for_date.year:
67 | return None
68 | return _vtimezone_without_dst(std, timezone)
69 |
70 |
71 | def colour_text(text, colour):
72 | return '{text}'.format(colour=colour, text=text)
73 |
--------------------------------------------------------------------------------
/abeluna/widgets/__init__.py:
--------------------------------------------------------------------------------
1 | from abeluna.widgets.datetime_picker import DateTimePickerWidget
2 | from abeluna.widgets.dropdown_select import DropdownSelectWidget
3 | from abeluna.widgets.error_dialog import ErrorDialog
4 |
--------------------------------------------------------------------------------
/abeluna/widgets/datetime_picker.py:
--------------------------------------------------------------------------------
1 | import calendar
2 | import datetime
3 |
4 | import pytz
5 | from gi.repository import GObject, Gtk
6 |
7 | from abeluna.settings import settings
8 | from abeluna.widgets.dropdown_select import DropdownSelectWidget
9 |
10 |
11 | class DateTimePickerWidget(Gtk.Grid):
12 | __gsignals__ = {
13 | 'updated-date': (GObject.SignalFlags.RUN_LAST, None, ()),
14 | }
15 |
16 | def __init__(self, *args, **kwargs):
17 | self.visible_parts = kwargs.pop('visible_parts', 'ALL')
18 | selected_date = kwargs.pop('selected_date', None)
19 | self._tz = kwargs.pop('timezone', None)
20 |
21 | super().__init__(*args, **kwargs)
22 | self.set_column_spacing(10)
23 | self.set_row_spacing(10)
24 | self.set_border_width(5)
25 |
26 | self._create_widgets()
27 |
28 | self.set_selected_date(selected_date)
29 | self.set_visible_parts(self.visible_parts)
30 |
31 | def __str__(self):
32 | date = self.get_selected_date()
33 | if date is None:
34 | return 'Unset'
35 | else:
36 | if self.visible_parts == 'DATE':
37 | return date.strftime('%b %d, %Y')
38 | elif self.visible_parts == 'TIME':
39 | return date.strftime('%H:%M')
40 | else:
41 | return date.strftime('%b %d, %Y %H:%M')
42 |
43 | @property
44 | def now(self):
45 | return datetime.datetime.now(tz=self.tz) + datetime.timedelta(minutes=1)
46 |
47 | @property
48 | def tz(self):
49 | if self._tz is not None:
50 | return self._tz
51 | if self.get_time_only():
52 | return None
53 | return pytz.timezone(settings.TIMEZONE)
54 |
55 | def get_date_only(self):
56 | return self.visible_parts == 'DATE'
57 |
58 | def set_date_only(self, date_only):
59 | self.set_visible_parts('DATE' if date_only else 'ALL')
60 |
61 | def get_time_only(self):
62 | return self.visible_parts == 'TIME'
63 |
64 | def set_time_only(self, time_only):
65 | self.set_visible_parts('TIME' if time_only else 'ALL')
66 |
67 | def get_visible_parts(self):
68 | return self.visible_parts
69 |
70 | def set_visible_parts(self, visible_parts):
71 | self.visible_parts = visible_parts
72 | for child in self.get_children():
73 | self.remove(child)
74 |
75 | _set_button_x = 0
76 | if self.visible_parts in ('ALL', 'TIME'):
77 | _set_button_x = 3
78 | self.attach(self.hour_selector, 0, 0, 2, 3)
79 | self.attach_next_to(self.time_colon, self.hour_selector, Gtk.PositionType.RIGHT, 1, 3)
80 | self.attach_next_to(self.minute_selector, self.time_colon, Gtk.PositionType.RIGHT, 2, 3)
81 | if self.visible_parts in ('ALL', 'DATE'):
82 | _set_button_x = 7
83 | self.attach(self.year_selector, 6, 0, 3, 1)
84 | self.attach_next_to(self.month_selector, self.year_selector, Gtk.PositionType.BOTTOM, 3, 1)
85 | self.attach_next_to(self.day_selector, self.month_selector, Gtk.PositionType.BOTTOM, 3, 1)
86 |
87 | self.attach(self.set_button, _set_button_x, 4, 2, 1)
88 |
89 | # self.emit('updated-date')
90 |
91 | def get_selected_date(self):
92 | return self.selected_date
93 |
94 | def set_selected_date(self, selected_date):
95 | self.selected_date = selected_date
96 | if self.selected_date is not None:
97 | date = self.selected_date = self.selected_date.astimezone(tz=self.tz)
98 | else:
99 | date = self.now
100 |
101 | self.hour_selector.set_value(date.hour)
102 | self.minute_selector.set_value(date.minute)
103 | self.year_selector.set_value(date.year)
104 | self.month_selector.set_active(date.month - 1)
105 | self.day_selector.set_value(date.day)
106 |
107 | # self.emit('updated-date')
108 |
109 | def _create_widgets(self):
110 | self.hour_selector = Gtk.SpinButton(
111 | adjustment=Gtk.Adjustment(
112 | value=0,
113 | lower=0,
114 | upper=23,
115 | step_increment=1,
116 | page_increment=0,
117 | page_size=0,
118 | ),
119 | )
120 | self.hour_selector.set_wrap(True)
121 | self.hour_selector.set_orientation(Gtk.Orientation.VERTICAL)
122 |
123 | self.time_colon = Gtk.Label(label=':')
124 |
125 | self.minute_selector = Gtk.SpinButton(
126 | adjustment=Gtk.Adjustment(
127 | value=0,
128 | lower=0,
129 | upper=59,
130 | step_increment=1,
131 | page_increment=5,
132 | page_size=0,
133 | ),
134 | )
135 | self.minute_selector.set_wrap(True)
136 | self.minute_selector.set_orientation(Gtk.Orientation.VERTICAL)
137 | self.minute_selector.set_margin_end(20)
138 |
139 | self.year_selector = Gtk.SpinButton(
140 | adjustment=Gtk.Adjustment(
141 | value=self.now.year,
142 | lower=datetime.MINYEAR,
143 | upper=datetime.MAXYEAR,
144 | step_increment=1,
145 | page_increment=0,
146 | page_size=0,
147 | ),
148 | )
149 | self.year_selector.set_wrap(True)
150 |
151 | months = []
152 | for month_index in range(1, 13):
153 | months.append([str(month_index), datetime.date(1970, month_index, 1).strftime('%B')])
154 | self.month_selector = DropdownSelectWidget(options=months)
155 | self.month_selector.set_active(0)
156 |
157 | day_adjustment = Gtk.Adjustment(
158 | value=1,
159 | lower=1,
160 | upper=31,
161 | step_increment=1,
162 | page_increment=5,
163 | page_size=0,
164 | )
165 | self.day_selector = Gtk.SpinButton(adjustment=day_adjustment)
166 | self.day_selector.set_wrap(True)
167 | self.day_selector.set_hexpand(True)
168 |
169 | def on_changed(*args, **kwargs):
170 | month = self.month_selector.get_active_id()
171 | if month is None:
172 | return
173 | month = int(month)
174 | year = self.year_selector.get_value_as_int()
175 | weekday, num_days = calendar.monthrange(year, month)
176 | day_adjustment.set_upper(num_days)
177 | self.day_selector.update()
178 | self.month_selector.connect('changed', on_changed)
179 | self.year_selector.connect('value-changed', on_changed)
180 |
181 | self.set_button = Gtk.Button(label='Set!')
182 |
183 | def on_set_button_clicked(button):
184 | self.set_selected_date(
185 | datetime.datetime(
186 | year=self.year_selector.get_value_as_int(),
187 | month=int(self.month_selector.get_active_id()),
188 | day=self.day_selector.get_value_as_int(),
189 | hour=self.hour_selector.get_value_as_int(),
190 | minute=self.minute_selector.get_value_as_int(),
191 | tzinfo=self.tz,
192 | ),
193 | )
194 | self.emit('updated-date')
195 | self.set_button.connect('clicked', on_set_button_clicked)
196 | self.set_button.set_margin_top(20)
197 |
--------------------------------------------------------------------------------
/abeluna/widgets/dropdown_select.py:
--------------------------------------------------------------------------------
1 | from gi.repository import Gtk
2 |
3 |
4 | class DropdownSelectWidget(Gtk.ComboBox):
5 | def __init__(self, *args, **kwargs):
6 | options = kwargs.pop('options')
7 |
8 | if len(options) < 1:
9 | raise ValueError('no options')
10 |
11 | self.id_index = kwargs.pop('id_index', 0)
12 | self.value_index = kwargs.pop('value_index', 1)
13 |
14 | if not isinstance(options[0][self.id_index], str):
15 | raise ValueError('id must be of type string')
16 |
17 | self.store = Gtk.ListStore(*[type(idx) for idx in options[0]])
18 | for option in options:
19 | self.store.append(option)
20 | kwargs['model'] = self.store
21 |
22 | super().__init__(*args, **kwargs)
23 |
24 | renderer_text = Gtk.CellRendererText()
25 | self.pack_start(renderer_text, True)
26 | self.add_attribute(renderer_text, 'text', self.value_index)
27 |
28 | self.set_id_column(self.id_index)
29 |
--------------------------------------------------------------------------------
/abeluna/widgets/error_dialog.py:
--------------------------------------------------------------------------------
1 | from gi.repository import Gtk
2 |
3 |
4 | class ErrorDialog:
5 | def __init__(self, parent, text):
6 | self.dialog = Gtk.MessageDialog(
7 | transient_for=parent,
8 | flags=0,
9 | message_type=Gtk.MessageType.ERROR,
10 | buttons=Gtk.ButtonsType.OK,
11 | text=text,
12 | )
13 |
14 | def run_and_wait(self):
15 | self.dialog.run()
16 | self.dialog.destroy()
17 |
--------------------------------------------------------------------------------
/abeluna/windows/__init__.py:
--------------------------------------------------------------------------------
1 | from abeluna.windows.settings import SettingsWindow
2 | from abeluna.windows.todolist import TodoListWindow
3 |
--------------------------------------------------------------------------------
/abeluna/windows/settings.py:
--------------------------------------------------------------------------------
1 | import datetime
2 |
3 | import pytz
4 | from gi.repository import GObject, Gdk, Gtk
5 |
6 | from abeluna.settings import settings
7 | from abeluna.sync import Calendar, server
8 | from abeluna.widgets import DateTimePickerWidget, DropdownSelectWidget, ErrorDialog
9 |
10 |
11 | class CalendarEditor(Gtk.Grid):
12 | __gsignals__ = {
13 | 'updated-data': (GObject.SignalFlags.RUN_LAST, None, ()),
14 | }
15 |
16 | def __init__(self, *args, **kwargs):
17 | super().__init__(*args, **kwargs)
18 |
19 | self.data = {}
20 |
21 | self.set_column_spacing(5)
22 | self.set_row_spacing(5)
23 | self.set_border_width(10)
24 |
25 | self._create_widgets()
26 |
27 | def set_data(self, data):
28 | self.data = data
29 | self.skip_validation_toggle.set_active(False)
30 | self.is_local_calendar.set_active(False)
31 | self.server_name_entry.get_buffer().set_text(self.data.get('name', ''), -1)
32 | self.server_url_entry.get_buffer().set_text(self.data.get('url', ''), -1)
33 | self.server_username_entry.get_buffer().set_text(self.data.get('username', ''), -1)
34 | self.server_password_entry.get_buffer().set_text(self.data.get('password', ''), -1)
35 | if not self.data.get('url'):
36 | self.is_local_calendar.set_active(True)
37 | self.update_server_fields()
38 |
39 | def update_server_fields(self):
40 | is_active = not self.is_local_calendar.get_active()
41 | for obj in (self.server_url_entry, self.server_username_entry, self.server_password_entry):
42 | if not is_active:
43 | obj.get_buffer().set_text('', -1)
44 | obj.set_sensitive(is_active)
45 |
46 | def get_data(self):
47 | return self.data
48 |
49 | def _create_widgets(self):
50 | self.server_name_label = Gtk.Label(label='Calendar Name')
51 | self.server_name_entry = Gtk.Entry()
52 | self.attach(self.server_name_label, 0, 0, 2, 1)
53 | self.attach_next_to(self.server_name_entry, self.server_name_label, Gtk.PositionType.RIGHT, 4, 1)
54 |
55 | self.is_local_calendar_label = Gtk.Label(label='Local Calendar')
56 | self.is_local_calendar = Gtk.CheckButton()
57 | self.is_local_calendar.connect('toggled', lambda button: self.update_server_fields())
58 |
59 | self.attach_next_to(self.is_local_calendar_label, self.server_name_label, Gtk.PositionType.BOTTOM, 2, 1)
60 | self.attach_next_to(self.is_local_calendar, self.is_local_calendar_label, Gtk.PositionType.RIGHT, 4, 1)
61 |
62 | self.server_url_label = Gtk.Label(label='Server URL')
63 | self.server_url_entry = Gtk.Entry()
64 | self.attach_next_to(self.server_url_label, self.is_local_calendar_label, Gtk.PositionType.BOTTOM, 2, 1)
65 | self.attach_next_to(self.server_url_entry, self.server_url_label, Gtk.PositionType.RIGHT, 4, 1)
66 |
67 | self.server_username_label = Gtk.Label(label='Server Username')
68 | self.server_username_entry = Gtk.Entry()
69 | self.attach_next_to(self.server_username_label, self.server_url_label, Gtk.PositionType.BOTTOM, 2, 1)
70 | self.attach_next_to(self.server_username_entry, self.server_username_label, Gtk.PositionType.RIGHT, 4, 1)
71 |
72 | self.server_password_label = Gtk.Label(label='Server Password')
73 | self.server_password_entry = Gtk.Entry()
74 | self.server_password_entry.set_visibility(False)
75 | self.attach_next_to(self.server_password_label, self.server_username_label, Gtk.PositionType.BOTTOM, 2, 1)
76 | self.attach_next_to(self.server_password_entry, self.server_password_label, Gtk.PositionType.RIGHT, 4, 1)
77 |
78 | self.skip_validation_toggle = Gtk.CheckButton(label='Skip Validation')
79 | self.attach(self.skip_validation_toggle, 0, 5, 3, 1)
80 |
81 | self.validation_spinner = Gtk.Spinner()
82 | self.validation_spinner.set_hexpand(False)
83 | self.validation_spinner.set_vexpand(True)
84 | self.validation_spinner.set_halign(Gtk.Align.END)
85 | self.attach_next_to(self.validation_spinner, self.skip_validation_toggle, Gtk.PositionType.RIGHT, 1, 1)
86 |
87 | def save_button_clicked(button):
88 | self.data['name'] = self.server_name_entry.get_buffer().get_text()
89 | self.data['url'] = self.server_url_entry.get_buffer().get_text()
90 | self.data['username'] = self.server_username_entry.get_buffer().get_text()
91 | self.data['password'] = self.server_password_entry.get_buffer().get_text()
92 | self.emit('updated-data')
93 |
94 | self.save_button = Gtk.Button('Save!')
95 | self.save_button.connect('clicked', save_button_clicked)
96 |
97 | self.attach_next_to(self.save_button, self.validation_spinner, Gtk.PositionType.RIGHT, 2, 1)
98 |
99 |
100 | class SettingsWindow(Gtk.Window):
101 | def __init__(self, parent, active_child=None):
102 | Gtk.Window.__init__(self, title='Abeluna Settings')
103 | self.set_default_size(900, 400)
104 | self.parent = parent
105 | self.set_modal(True)
106 | self.set_transient_for(self.parent)
107 | self.set_destroy_with_parent(True)
108 | self.set_position(Gtk.WindowPosition.CENTER_ON_PARENT)
109 | self.connect('delete-event', lambda obj, event: self.destroy())
110 |
111 | self.main_grid = Gtk.Grid()
112 | self.main_grid.set_column_spacing(5)
113 | self.main_grid.set_row_spacing(5)
114 | self.main_grid.set_border_width(10)
115 | self.add(self.main_grid)
116 |
117 | self.main_stack = Gtk.Stack()
118 | self.main_stack_switcher = Gtk.StackSwitcher()
119 | self.main_stack_switcher.set_stack(self.main_stack)
120 | self.main_stack_switcher.set_orientation(Gtk.Orientation.VERTICAL)
121 |
122 | self.general_page_grid = Gtk.Grid()
123 | self.general_page_grid.set_column_homogeneous(True)
124 | self.general_page_grid.set_column_spacing(5)
125 | self.general_page_grid.set_row_spacing(5)
126 | self.general_page_grid.set_vexpand(True)
127 | self.general_page_grid.set_hexpand(True)
128 |
129 | self.timezone_label = Gtk.Label(label='Timezone')
130 | timezone_todo_store = Gtk.ListStore(str)
131 | for tz in pytz.common_timezones:
132 | timezone_todo_store.append([tz])
133 | self.timezone_combo = Gtk.ComboBox.new_with_model_and_entry(model=timezone_todo_store)
134 | self.timezone_combo.set_entry_text_column(0)
135 | self.timezone_combo.set_id_column(0)
136 |
137 | timezone_completion = Gtk.EntryCompletion()
138 | timezone_completion.set_model(timezone_todo_store)
139 | timezone_completion.set_text_column(0)
140 | timezone_completion.set_minimum_key_length(2)
141 |
142 | def match_timezone_selected(completion, todo_store, it):
143 | self.timezone_combo.set_active_id(todo_store[it][0])
144 | timezone_completion.connect('match-selected', match_timezone_selected)
145 |
146 | def match_timezone(completion, string, it):
147 | to_match = timezone_todo_store[it][0].lower()
148 | return all(word in to_match for word in string.lower().split())
149 | timezone_completion.set_match_func(match_timezone)
150 | self.timezone_combo.get_child().set_completion(timezone_completion)
151 | self.timezone_combo.set_active_id(settings.TIMEZONE)
152 |
153 | self.general_page_grid.attach(self.timezone_label, 0, 0, 2, 1)
154 | self.general_page_grid.attach_next_to(self.timezone_combo, self.timezone_label, Gtk.PositionType.RIGHT, 4, 1)
155 |
156 | self.autosync_label = Gtk.Label(label='Autosync Interval')
157 | self.autosync_selector = DropdownSelectWidget(options=[
158 | ('-1', 'Never'),
159 | ('10', 'Every 10 seconds'),
160 | ('30', 'Every 30 seconds'),
161 | ('60', 'Every minute'),
162 | ('600', 'Every 10 minutes'),
163 | ('1800', 'Every 30 minutes'),
164 | ('3600', 'Every hour'),
165 | ('21600', 'Every 6 hours'),
166 | ('86400', 'Every day'),
167 | ('604800', 'Every week'),
168 | ('2419200', 'Every 4 weeks'),
169 | ('1036800', 'Every year'),
170 | ])
171 | self.autosync_selector.set_active_id(settings.AUTOSYNC_INTERVAL)
172 | self.general_page_grid.attach_next_to(self.autosync_label, self.timezone_label, Gtk.PositionType.BOTTOM, 2, 1)
173 | self.general_page_grid.attach_next_to(self.autosync_selector, self.autosync_label, Gtk.PositionType.RIGHT, 4, 1)
174 |
175 | self.priority_label = Gtk.Label(label='On conflict')
176 | self.priority_selector = DropdownSelectWidget(options=[
177 | ('SERVER', 'Prioritize server'),
178 | ('CLIENT', 'Prioritize client'),
179 | ])
180 | self.priority_selector.set_active_id(settings.PRIORITIZE_ON_CONFLICT)
181 | self.general_page_grid.attach_next_to(self.priority_label, self.autosync_label, Gtk.PositionType.BOTTOM, 2, 1)
182 | self.general_page_grid.attach_next_to(self.priority_selector, self.priority_label, Gtk.PositionType.RIGHT, 4, 1)
183 |
184 | self.hide_completed_label = Gtk.Label(label='Hide completed tasks')
185 | self.hide_completed_selector = Gtk.CheckButton()
186 | self.hide_completed_selector.set_active(int(settings.HIDE_COMPLETED))
187 | self.general_page_grid.attach_next_to(
188 | self.hide_completed_label,
189 | self.priority_label,
190 | Gtk.PositionType.BOTTOM,
191 | 2,
192 | 1,
193 | )
194 | self.general_page_grid.attach_next_to(
195 | self.hide_completed_selector,
196 | self.hide_completed_label,
197 | Gtk.PositionType.RIGHT,
198 | 4,
199 | 1,
200 | )
201 |
202 | self.all_day_due_time_label = Gtk.Label('All day tasks due time')
203 | self.all_day_due_time_popover = Gtk.Popover()
204 | self.all_day_due_time_picker = DateTimePickerWidget(
205 | visible_parts='TIME',
206 | selected_date=datetime.datetime.strptime(settings.ALL_DAY_DUE_TIME, '%H:%M'),
207 | )
208 | self.all_day_due_time_popover.add(self.all_day_due_time_picker)
209 | self.all_day_due_time_button = Gtk.Button(label=str(self.all_day_due_time_picker))
210 |
211 | def on_clicked_all_day_due_time(obj):
212 | self.all_day_due_time_popover.set_relative_to(self.all_day_due_time_button)
213 | self.all_day_due_time_popover.show_all()
214 | self.all_day_due_time_popover.popup()
215 | self.all_day_due_time_button.connect('clicked', on_clicked_all_day_due_time)
216 |
217 | def on_update_all_day_due_time(obj):
218 | self.all_day_due_time_popover.popdown()
219 | self.all_day_due_time_button.set_label(str(self.all_day_due_time_picker))
220 | self.all_day_due_time_picker.connect('updated-date', on_update_all_day_due_time)
221 |
222 | self.general_page_grid.attach_next_to(
223 | self.all_day_due_time_label,
224 | self.hide_completed_label,
225 | Gtk.PositionType.BOTTOM,
226 | 2,
227 | 1,
228 | )
229 | self.general_page_grid.attach_next_to(
230 | self.all_day_due_time_button,
231 | self.all_day_due_time_label,
232 | Gtk.PositionType.RIGHT,
233 | 4,
234 | 1,
235 | )
236 |
237 | self.saved_label = Gtk.Label(label=' ')
238 | self.saved_label.set_xalign(0.95)
239 | self.saved_label.set_yalign(0.75)
240 |
241 | def clear_saved_label():
242 | self.saved_label.set_label(' ')
243 | return False
244 |
245 | def save_button_clicked(button):
246 | timezone = self.timezone_combo.get_active_id()
247 | autosync_interval = self.autosync_selector.get_active_id()
248 | priority = self.priority_selector.get_active_id()
249 | hide_completed = str(int(self.hide_completed_selector.get_active()))
250 | all_day_due_time = self.all_day_due_time_picker.get_selected_date().strftime('%H:%M')
251 |
252 | failed_settings = []
253 | for obj, name in (
254 | (timezone, 'timezone'),
255 | (autosync_interval, 'autosync interval'),
256 | (priority, 'priority'),
257 | (hide_completed, 'hidden completed tasks'),
258 | (all_day_due_time, 'all day due time'),
259 | ):
260 | if obj is None:
261 | failed_settings.append(name)
262 |
263 | if failed_settings:
264 | ErrorDialog(
265 | self,
266 | 'Setting for {} is invalid. Please try again.'.format(', '.join(failed_settings)),
267 | ).run_and_wait()
268 | else:
269 | rebuild_todolist = False
270 | if settings.TIMEZONE != timezone:
271 | settings.TIMEZONE = timezone
272 | rebuild_todolist = True
273 | if settings.HIDE_COMPLETED != hide_completed:
274 | settings.HIDE_COMPLETED = hide_completed
275 | rebuild_todolist = True
276 | if settings.ALL_DAY_DUE_TIME != all_day_due_time:
277 | settings.ALL_DAY_DUE_TIME = all_day_due_time
278 | rebuild_todolist = True
279 | if settings.AUTOSYNC_INTERVAL != autosync_interval:
280 | settings.AUTOSYNC_INTERVAL = autosync_interval
281 | server.restart_autosync_thread()
282 | settings.PRIORITIZE_ON_CONFLICT = priority
283 | settings.commit()
284 |
285 | if rebuild_todolist:
286 | self.parent.todolist_window.rebuild_todolist()
287 |
288 | self.saved_label.set_label('Saved!')
289 | GObject.timeout_add_seconds(5, clear_saved_label)
290 |
291 | self.save_button = Gtk.Button('Save!')
292 | self.save_button.set_margin_top(20)
293 | self.save_button.connect('clicked', save_button_clicked)
294 |
295 | self.general_page_grid.attach(self.save_button, 4, 6, 2, 1)
296 | self.general_page_grid.attach_next_to(self.saved_label, self.save_button, Gtk.PositionType.LEFT, 2, 1)
297 |
298 | padding = Gtk.Box()
299 | padding.set_hexpand(True)
300 | padding.set_vexpand(True)
301 |
302 | self.general_page_grid.attach_next_to(padding, self.save_button, Gtk.PositionType.TOP, 2, 1)
303 |
304 | self.main_stack.add_titled(self.general_page_grid, 'General', 'General settings')
305 |
306 | self.servers_page_grid = Gtk.Grid()
307 | self.servers_page_grid.set_column_homogeneous(True)
308 | self.servers_page_grid.set_column_spacing(5)
309 | self.servers_page_grid.set_row_spacing(5)
310 |
311 | self.server_todo_store = Gtk.ListStore(str, str, str, str)
312 |
313 | def on_server_order_changed(todo_tree_store, path=None, iter=None):
314 | it = todo_tree_store.get_iter_first()
315 | uids = []
316 | while it is not None:
317 | uid = todo_tree_store[it][3]
318 | if uid is not None:
319 | uids.append(uid)
320 | it = todo_tree_store.iter_next(it)
321 |
322 | settings.set_calendar_order(uids)
323 | settings.commit()
324 | server.refresh_calendars()
325 | self.parent.rebuild_calendarlist()
326 |
327 | self.server_todo_store.connect('row-deleted', on_server_order_changed)
328 |
329 | def rebuild_server_todo_store():
330 | self.server_todo_store.clear()
331 | for calendar in settings.ordered_calendars.values():
332 | self.server_todo_store.append([
333 | calendar['name'],
334 | calendar['url'] or 'Local Calendar',
335 | calendar['username'] or 'Local Calendar',
336 | calendar['uid'],
337 | ])
338 | rebuild_server_todo_store()
339 |
340 | self.server_view = Gtk.TreeView(model=self.server_todo_store)
341 | self.server_view.set_hexpand(True)
342 | self.server_view.set_headers_clickable(False)
343 | self.server_view.set_search_column(0)
344 | self.server_view.set_reorderable(True)
345 | for idx, name in enumerate(('Calendar Name', 'URL', 'Username')):
346 | self.server_view.append_column(Gtk.TreeViewColumn(name, Gtk.CellRendererText(), text=idx))
347 |
348 | self.server_editor_popover = Gtk.Popover()
349 | self.server_editor = CalendarEditor()
350 |
351 | def server_validation_checker(calendar, data):
352 | if hasattr(calendar, '_validated'):
353 | self.server_editor.save_button.set_sensitive(True)
354 | self.server_editor.validation_spinner.stop()
355 | if calendar._validated:
356 | settings.add_or_update_calendar(data)
357 | settings.commit()
358 | rebuild_server_todo_store()
359 | server.refresh_calendars()
360 | self.parent.rebuild_calendarlist()
361 | self.server_editor_popover.popdown()
362 | else:
363 | ErrorDialog(
364 | self,
365 | 'Could not connect to server. Please check your settings are correct.',
366 | ).run_and_wait()
367 | return False
368 | return True
369 |
370 | def on_data_updated(obj):
371 | data = self.server_editor.get_data()
372 | if not data['name']:
373 | ErrorDialog(self, 'Name cannot be empty.').run_and_wait()
374 | return
375 | elif (
376 | not self.server_editor.is_local_calendar.get_active() and
377 | not all(data[field] for field in ('url', 'username', 'password'))
378 | ):
379 | ErrorDialog(self, 'Please enter the URL and credentials for the remote server.').run_and_wait()
380 | return
381 |
382 | self.server_editor.save_button.set_sensitive(False)
383 | calendar = Calendar.from_dict(data)
384 | if self.server_editor.skip_validation_toggle.get_active():
385 | calendar._validated = True
386 | else:
387 | self.server_editor.validation_spinner.start()
388 | import threading
389 | threading.Thread(target=calendar.validate).start()
390 | GObject.timeout_add(100, server_validation_checker, calendar, data)
391 |
392 | self.server_editor.connect('updated-data', on_data_updated)
393 | self.server_editor_popover.add(self.server_editor)
394 |
395 | def _show_editor_on_row(path):
396 | uid = self.server_todo_store[path][3]
397 | self.server_editor.set_data(settings.CALENDARS[uid].copy())
398 |
399 | pos = self.server_view.get_cell_area(path, self.server_view.get_column(0))
400 | rect = Gdk.Rectangle()
401 | pos_x, pos_y = self.server_view.convert_bin_window_to_widget_coords(pos.x, pos.y)
402 |
403 | rect.x = 0
404 | rect.width = self.server_view.get_allocation().width
405 | rect.y = pos_y
406 | rect.height = pos.height
407 | self.server_editor_popover.set_relative_to(self.server_view)
408 | self.server_editor_popover.set_pointing_to(rect)
409 | self.server_editor_popover.set_position(Gtk.PositionType.BOTTOM)
410 | self.server_editor_popover.show_all()
411 | self.server_editor_popover.popup()
412 |
413 | def on_row_activated(todo_tree_view, path, column):
414 | _show_editor_on_row(path)
415 |
416 | self.server_view.connect('row-activated', on_row_activated)
417 |
418 | self.scrollable_server_view = Gtk.ScrolledWindow(vexpand=True)
419 | self.scrollable_server_view.set_propagate_natural_width(True)
420 | self.scrollable_server_view.set_shadow_type(type=Gtk.ShadowType.ETCHED_OUT)
421 | self.scrollable_server_view.add(self.server_view)
422 | self.servers_page_grid.attach(self.scrollable_server_view, 0, 0, 8, 6)
423 |
424 | self.server_add_button = Gtk.Button(label='Add')
425 | self.server_clone_button = Gtk.Button(label='Clone')
426 | self.server_edit_button = Gtk.Button(label='Modify')
427 | self.server_delete_button = Gtk.Button(label='Delete')
428 |
429 | self.server_clone_button.set_sensitive(False)
430 | self.server_edit_button.set_sensitive(False)
431 | self.server_delete_button.set_sensitive(False)
432 |
433 | def on_todo_tree_selection_changed(todo_tree_selection):
434 | current_it = todo_tree_selection.get_selected()[1]
435 | if current_it is None:
436 | self.server_clone_button.set_sensitive(False)
437 | self.server_edit_button.set_sensitive(False)
438 | self.server_delete_button.set_sensitive(False)
439 | else:
440 | self.server_clone_button.set_sensitive(True)
441 | self.server_edit_button.set_sensitive(True)
442 | self.server_delete_button.set_sensitive(True)
443 | self.server_view.get_selection().connect('changed', on_todo_tree_selection_changed)
444 |
445 | def on_server_create(button, clone_selected):
446 | if clone_selected:
447 | it = self.server_view.get_selection().get_selected()[1]
448 | if it is None:
449 | return
450 | uid = self.server_todo_store[it][3]
451 | data = settings.CALENDARS[uid].copy()
452 | data.pop('uid')
453 | else:
454 | data = {}
455 |
456 | self.server_editor.set_data(data)
457 | rect = Gdk.Rectangle()
458 | rect.width = button.get_allocation().width
459 | rect.height = button.get_allocation().height
460 |
461 | self.server_editor_popover.set_relative_to(button)
462 | self.server_editor_popover.set_pointing_to(rect)
463 | self.server_editor_popover.set_position(Gtk.PositionType.TOP)
464 | self.server_editor_popover.show_all()
465 | self.server_editor_popover.popup()
466 | self.server_add_button.connect('clicked', on_server_create, False)
467 | self.server_clone_button.connect('clicked', on_server_create, True)
468 |
469 | def on_server_edit(button):
470 | it = self.server_view.get_selection().get_selected()[1]
471 | if it is None:
472 | return
473 | _show_editor_on_row(self.server_todo_store.get_path(it))
474 | self.server_edit_button.connect('clicked', on_server_edit)
475 |
476 | def on_server_delete(button):
477 | it = self.server_view.get_selection().get_selected()[1]
478 | if it is None:
479 | return
480 | settings.delete_calendar(self.server_todo_store[it][3])
481 | settings.commit()
482 | rebuild_server_todo_store()
483 | server.refresh_calendars()
484 | self.parent.rebuild_calendarlist()
485 |
486 | self.server_delete_button.connect('clicked', on_server_delete)
487 |
488 | self.servers_page_grid.attach_next_to(
489 | self.server_add_button, self.scrollable_server_view, Gtk.PositionType.BOTTOM, 2, 1,
490 | )
491 | self.servers_page_grid.attach_next_to(
492 | self.server_clone_button, self.server_add_button, Gtk.PositionType.RIGHT, 2, 1,
493 | )
494 | self.servers_page_grid.attach_next_to(
495 | self.server_edit_button, self.server_clone_button, Gtk.PositionType.RIGHT, 2, 1,
496 | )
497 | self.servers_page_grid.attach_next_to(
498 | self.server_delete_button, self.server_edit_button, Gtk.PositionType.RIGHT, 2, 1,
499 | )
500 |
501 | self.main_stack.add_titled(self.servers_page_grid, 'Calendars', 'Calendar settings')
502 |
503 | def on_close_window(button):
504 | self.destroy()
505 |
506 | self.close_button = Gtk.Button(label='Close')
507 | self.close_button.connect('clicked', on_close_window)
508 |
509 | padding = Gtk.Box()
510 | padding.set_vexpand(True)
511 | self.main_grid.attach(self.main_stack_switcher, 0, 0, 3, 2)
512 | self.main_grid.attach_next_to(padding, self.main_stack_switcher, Gtk.PositionType.BOTTOM, 3, 1)
513 | self.main_grid.attach_next_to(self.close_button, padding, Gtk.PositionType.BOTTOM, 3, 1)
514 | self.main_grid.attach(self.main_stack, 3, 0, 5, 4)
515 |
516 | self.show_all()
517 |
518 | if active_child is not None:
519 | self.main_stack.set_visible_child_name(active_child)
520 |
--------------------------------------------------------------------------------
/abeluna/windows/todolist.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | import uuid
3 | from collections import defaultdict
4 |
5 | import humanize
6 | import icalendar
7 | import pytz
8 | from gi.repository import GObject, Gdk, Gtk
9 |
10 | from abeluna.settings import settings
11 | from abeluna.sync import server
12 | from abeluna.util import colour_text
13 | from abeluna.widgets import DateTimePickerWidget, DropdownSelectWidget
14 |
15 |
16 | DEFAULT_DATA = {
17 | 'uid': '',
18 | 'summary': 'Task Name',
19 | 'start_date': None,
20 | 'end_date': None,
21 | 'all_day': False,
22 | 'classification': 'PUBLIC',
23 | 'status': 'NEEDS-ACTION',
24 | 'progress': 0,
25 | 'priority': 0,
26 | 'categories': [],
27 | 'description': '',
28 | # Backend
29 | 'sequence': 0,
30 | 'created_date': None,
31 | 'completed_date': None,
32 | 'dtstamp': None,
33 | 'last_modified_date': None,
34 | 'related_to': None,
35 | 'hide_subtasks': 0,
36 | }
37 |
38 |
39 | class Todo:
40 | FIELDS = list(DEFAULT_DATA.keys())
41 | CLASS_OPTIONS = [
42 | ('PUBLIC', 'Show full event'),
43 | ('CONFIDENTIAL', 'Show only busy'),
44 | ('PRIVATE', 'Hide this task'),
45 | ]
46 |
47 | STATUS_OPTIONS = [
48 | ('NEEDS-ACTION', 'Needs Action'),
49 | ('COMPLETED', 'Completed'),
50 | ('IN-PROCESS', 'In Process'),
51 | ('CANCELLED', 'Cancelled'),
52 | ]
53 |
54 | DO_NOT_TRACK = ('last_modified_date', 'sequence', 'completed_date', 'dtstamp', 'created_date')
55 | DO_NOT_LOAD = ('uid', 'sequence', 'created_date', 'dtstamp', 'last_modified_date', 'hide_subtasks')
56 |
57 | UTC_DATE_FIELDS = ('created_date', 'completed_date', 'dtstamp', 'last_modified_date')
58 | LOCAL_DATE_FIELDS = ('start_date', 'end_date')
59 | DATE_FIELDS = UTC_DATE_FIELDS + LOCAL_DATE_FIELDS
60 |
61 | VTODO_MAPPING = (
62 | ('uid', 'UID'),
63 | ('summary', 'SUMMARY'),
64 | ('start_date', 'DTSTART'),
65 | ('end_date', 'DUE'),
66 | ('classification', 'CLASS'),
67 | ('status', 'STATUS'),
68 | ('progress', 'PERCENT-COMPLETE'),
69 | ('priority', 'PRIORITY'),
70 | ('categories', 'CATEGORIES'),
71 | ('description', 'DESCRIPTION'),
72 | ('sequence', 'SEQUENCE'),
73 | ('created_date', 'CREATED'),
74 | ('completed_date', 'COMPLETED'),
75 | ('dtstamp', 'DTSTAMP'),
76 | ('last_modified_date', 'LAST-MODIFIED'),
77 | ('related_to', 'RELATED-TO'),
78 | ('hide_subtasks', 'X-OC-HIDESUBTASKS'),
79 | )
80 |
81 | @classmethod
82 | def load_from_vtodo(cls, vtodo, load_all=True):
83 | kwargs = {}
84 |
85 | def _sanitize(val):
86 | if isinstance(val, icalendar.vText):
87 | return str(val)
88 | elif isinstance(val, icalendar.prop.vCategory):
89 | _tmp_vals = []
90 | for cat in val.cats:
91 | if isinstance(cat, icalendar.vText):
92 | _tmp_vals.append(str(cat))
93 | else:
94 | _tmp_vals.append(cat)
95 | return _tmp_vals
96 | return val
97 |
98 | def _normalize_datetime(date):
99 | dt = date.dt
100 | if not isinstance(dt, datetime.datetime):
101 | dt = datetime.datetime(year=dt.year, month=dt.month, day=dt.day)
102 | return dt.astimezone(pytz.timezone(settings.TIMEZONE))
103 |
104 | for field_model, field_vtodo in cls.VTODO_MAPPING:
105 | if not load_all and field_model in cls.DO_NOT_LOAD:
106 | continue
107 | try:
108 | val = vtodo[field_vtodo]
109 | except KeyError:
110 | pass
111 | else:
112 | kwargs[field_model] = _sanitize(val)
113 |
114 | for dt in ('start_date', 'end_date'):
115 | if dt in kwargs and not isinstance(kwargs[dt].dt, datetime.datetime):
116 | kwargs['all_day'] = True
117 | for dt in cls.DATE_FIELDS:
118 | if dt in kwargs:
119 | kwargs[dt] = _normalize_datetime(kwargs[dt])
120 |
121 | return cls(vtodo=vtodo, **kwargs)
122 |
123 | def update_vtodo(self):
124 | _fields = self.fields.copy()
125 |
126 | def _sanitize(field, val):
127 | if field == 'categories':
128 | return icalendar.prop.vCategory(val)
129 | return val
130 |
131 | for field_list, timezone in (
132 | (self.UTC_DATE_FIELDS, pytz.UTC),
133 | (self.LOCAL_DATE_FIELDS, pytz.timezone(settings.TIMEZONE)),
134 | ):
135 | for dt_field in field_list:
136 | if _fields[dt_field] is not None:
137 | _fields[dt_field] = _fields[dt_field].astimezone(timezone)
138 |
139 | if _fields['all_day']:
140 | for dt in ('start_date', 'end_date'):
141 | if _fields[dt] is not None:
142 | _fields[dt] = _fields[dt].date()
143 |
144 | for field_model, field_vtodo in self.VTODO_MAPPING:
145 | val = _fields[field_model]
146 | try:
147 | self.vtodo.pop(field_vtodo)
148 | except KeyError:
149 | pass
150 | if val not in (None, '', []):
151 | self.vtodo.add(field_vtodo, _sanitize(field_model, val))
152 |
153 | def now(self, aware):
154 | if aware:
155 | return datetime.datetime.now(pytz.timezone(settings.TIMEZONE))
156 | else:
157 | return datetime.datetime.now()
158 |
159 | def __init__(self, **kwargs):
160 | self.vtodo = kwargs.pop('vtodo', None)
161 | self.callback_mapping = defaultdict(list)
162 | self.fields = DEFAULT_DATA.copy()
163 | self.fields['uid'] = uuid.uuid4().hex
164 | self.fields['created_date'] = self.fields['dtstamp'] = self.fields['last_modified_date'] = self.now(aware=True)
165 | self.fields.update(**kwargs)
166 |
167 | if self.vtodo is None:
168 | self.vtodo = icalendar.Todo()
169 | self.update_vtodo()
170 |
171 | def on_complete():
172 | if self.completed_date is not None and not self.completed:
173 | self.completed_date = None
174 | elif self.completed_date is None and self.completed:
175 | self.completed_date = self.now(aware=True)
176 | self.connect('progress', on_complete)
177 | self.connect('status', on_complete)
178 |
179 | def update():
180 | _now = self.now(aware=True)
181 | if (
182 | self.last_modified_date is not None and
183 | abs(self.last_modified_date - _now).total_seconds() > 60
184 | ):
185 | self.sequence += 1
186 | self.last_modified_date = _now
187 |
188 | self.connect_to_all(update)
189 |
190 | def connect(self, field, callback, *args, **kwargs):
191 | self.callback_mapping[field].append((callback, args, kwargs))
192 |
193 | def connect_to_all(self, callback, *args, **kwargs):
194 | for field in self.FIELDS:
195 | if field not in self.DO_NOT_TRACK:
196 | self.connect(field, callback, *args, **kwargs)
197 |
198 | def __getattr__(self, field):
199 | if field in self.FIELDS:
200 | return self.fields[field]
201 | raise AttributeError()
202 |
203 | def __setattr__(self, field, value):
204 | if field in self.FIELDS:
205 | if value != self.fields[field]:
206 | self.fields[field] = value
207 | self.update_vtodo()
208 | for cb, args, kwargs, in self.callback_mapping[field]:
209 | cb(*args, **kwargs)
210 | else:
211 | super().__setattr__(field, value)
212 |
213 | def __getitem__(self, field):
214 | return getattr(self, field)
215 |
216 | def __setitem__(self, field, value):
217 | setattr(self, field, value)
218 |
219 | @property
220 | def completed(self):
221 | return self.status == 'COMPLETED' and self.progress == 100
222 |
223 | @completed.setter
224 | def completed(self, value):
225 | if value:
226 | self.status = 'COMPLETED'
227 | self.progress = 100
228 | else:
229 | self.status = 'NEEDS-ACTION'
230 | self.progress = 0
231 |
232 | @property
233 | def time_display(self):
234 | def _convert_datetime(dt):
235 | if dt is None:
236 | return None
237 | return dt.astimezone(pytz.timezone(settings.TIMEZONE)).replace(tzinfo=None)
238 |
239 | _now = _convert_datetime(self.now(aware=True))
240 |
241 | def _time_or_date_display(dt):
242 | if abs(_now - dt) < datetime.timedelta(days=30):
243 | return humanize.naturaltime(dt, when=_now)
244 | else:
245 | return humanize.naturaldate(dt)
246 |
247 | if self.completed:
248 | if self.completed_date is None:
249 | return ''
250 |
251 | return colour_text(
252 | 'Completed {}'.format(_time_or_date_display(_convert_datetime(self.completed_date))),
253 | '#00c900',
254 | )
255 | if self.status == 'CANCELLED':
256 | return 'Cancelled'
257 |
258 | if self.start_date is None and self.end_date is None:
259 | return ''
260 |
261 | _start = _convert_datetime(self.start_date)
262 | _end = _convert_datetime(self.end_date)
263 | if _start is not None and _start > _now:
264 | return 'Starts {}'.format(_time_or_date_display(_start))
265 | elif _end is not None:
266 | if self.all_day and abs(_now - _end) < datetime.timedelta(days=1):
267 | _end = datetime.datetime.combine(
268 | date=_end.date(),
269 | time=datetime.datetime.strptime(settings.ALL_DAY_DUE_TIME, '%H:%M').time(),
270 | )
271 |
272 | if _end < _now:
273 | return colour_text(
274 | 'Ended {}'.format(_time_or_date_display(_end)),
275 | 'red',
276 | )
277 | else:
278 | return colour_text(
279 | 'Ends {}'.format(_time_or_date_display(_end)),
280 | 'orange',
281 | )
282 | return ''
283 |
284 | @property
285 | def sort_value(self):
286 | _GAP = 2**34
287 | _MIN_BOUND = 0
288 | _COMPLETED_BOUND = _GAP
289 | _PRIORITY_BOUND = _GAP * 2
290 | _END_DATE_BOUND = _GAP * 3
291 |
292 | _now = self.now(aware=True)
293 | if self.completed:
294 | if self.completed_date is None:
295 | return _MIN_BOUND
296 | return _COMPLETED_BOUND - int((_now - self.completed_date).total_seconds() // 60)
297 | elif self.status == 'CANCELLED':
298 | return _COMPLETED_BOUND + 1
299 | elif self.status == 'IN-PROCESS':
300 | return _PRIORITY_BOUND + 1
301 | elif self.end_date is not None:
302 | return _END_DATE_BOUND - int((self.end_date - _now).total_seconds() // 60)
303 | elif self.priority:
304 | return _PRIORITY_BOUND - self.priority
305 | return _COMPLETED_BOUND + 2
306 |
307 |
308 | CHAR_LONG_LIMIT = 2048
309 | CHAR_SHORT_LIMIT = 48
310 |
311 |
312 | class TodoEditor(Gtk.Grid):
313 | def __init__(self, *args, **kwargs):
314 | _data = kwargs.pop('data', None)
315 | _uid = kwargs.pop('uid', None)
316 | super().__init__(*args, **kwargs)
317 | self.data = None
318 | self.uid = None
319 |
320 | self.set_column_homogeneous(True)
321 | self.set_column_spacing(5)
322 | self.set_row_spacing(5)
323 | self.set_border_width(10)
324 |
325 | self._create_widgets()
326 |
327 | self.set_uid(_uid)
328 | self.set_data(_data)
329 |
330 | def set_data(self, _data):
331 | if _data is None:
332 | self.data = None
333 | self.uid = None
334 | for child in self.get_children():
335 | child.hide()
336 | return
337 |
338 | if self.uid != _data['uid']:
339 | return
340 |
341 | self.data = _data
342 | self._update_widgets(_data)
343 | self.show_all()
344 |
345 | def set_uid(self, uid):
346 | self.uid = uid
347 |
348 | def _update_widgets(self, _data):
349 | self.summary_label.set_text(_data['summary'])
350 | self.startdate_picker.set_selected_date(_data['start_date'])
351 | self.startdate_picker.set_date_only(_data['all_day'])
352 | self.enddate_picker.set_selected_date(_data['end_date'])
353 | self.enddate_picker.set_date_only(_data['all_day'])
354 | self.allday_toggle.set_active(_data['all_day'])
355 | self.classification_combobox.set_active_id(_data['classification'])
356 | self.status_combobox.set_active_id(_data['status'])
357 | self.progress_slider.set_value(_data['progress'])
358 | self.priority_slider.set_value(_data['priority'])
359 | self.category_view.set_text(', '.join(_data['categories']) or '-')
360 | self.description_view.get_buffer().set_text(_data['description'])
361 | self.update_datepicker_labels()
362 |
363 | def update_datepicker_labels(self):
364 | self.startdate_button.set_label(str(self.startdate_picker))
365 | self.enddate_button.set_label(str(self.enddate_picker))
366 |
367 | def _create_widgets(self):
368 | self.summary_label = Gtk.Entry()
369 | self.summary_label.get_buffer().set_max_length(CHAR_SHORT_LIMIT)
370 | self.summary_label.set_width_chars(CHAR_SHORT_LIMIT // 4)
371 |
372 | def on_edited_summary(obj):
373 | self.data['summary'] = obj.get_buffer().get_text()
374 | self.summary_label.connect('changed', on_edited_summary)
375 |
376 | self.attach(self.summary_label, 0, 0, 3, 1)
377 |
378 | self.startdate_label = Gtk.Label(label='Start date')
379 | self.startdate_popover = Gtk.Popover()
380 | self.startdate_picker = DateTimePickerWidget()
381 | self.startdate_popover.add(self.startdate_picker)
382 |
383 | self.startdate_button = Gtk.Button(label=str(self.startdate_picker))
384 |
385 | def on_clicked_startdate(obj):
386 | self.startdate_picker.set_selected_date(self.data['start_date'])
387 | self.startdate_popover.set_relative_to(self.startdate_button)
388 | self.startdate_popover.show_all()
389 | self.startdate_popover.popup()
390 | self.startdate_button.connect('clicked', on_clicked_startdate)
391 |
392 | def on_update_selected_startdate(obj):
393 | start_date = obj.get_selected_date()
394 | if start_date is not None and self.data['end_date'] is not None and start_date > self.data['end_date']:
395 | self.data['end_date'] = start_date
396 | self.enddate_picker.set_selected_date(self.data['end_date'])
397 | self.data['start_date'] = start_date
398 | self.startdate_popover.popdown()
399 | self.update_datepicker_labels()
400 | self.startdate_picker.connect('updated-date', on_update_selected_startdate)
401 |
402 | def on_delete_startdate(obj):
403 | self.data['start_date'] = None
404 | self.startdate_picker.set_selected_date(None)
405 | self.update_datepicker_labels()
406 | self.startdate_delete = Gtk.Button.new_from_icon_name('user-trash-symbolic', 4)
407 | self.startdate_delete.connect('clicked', on_delete_startdate)
408 |
409 | self.attach_next_to(self.startdate_label, self.summary_label, Gtk.PositionType.BOTTOM, 2, 1)
410 | self.attach_next_to(self.startdate_button, self.startdate_label, Gtk.PositionType.RIGHT, 3, 1)
411 | self.attach_next_to(self.startdate_delete, self.startdate_button, Gtk.PositionType.RIGHT, 1, 1)
412 |
413 | self.enddate_label = Gtk.Label(label='End date')
414 | self.enddate_popover = Gtk.Popover()
415 | self.enddate_picker = DateTimePickerWidget()
416 | self.enddate_popover.add(self.enddate_picker)
417 |
418 | self.enddate_button = Gtk.Button(label=str(self.enddate_picker))
419 |
420 | def on_clicked_enddate(obj):
421 | self.enddate_picker.set_selected_date(self.data['end_date'])
422 | self.enddate_popover.set_relative_to(self.enddate_button)
423 | self.enddate_popover.show_all()
424 | self.enddate_popover.popup()
425 | self.enddate_button.connect('clicked', on_clicked_enddate)
426 |
427 | def on_update_selected_enddate(obj):
428 | end_date = obj.get_selected_date()
429 | if end_date is not None and self.data['start_date'] is not None and end_date < self.data['start_date']:
430 | self.data['start_date'] = end_date
431 | self.startdate_picker.set_selected_date(self.data['start_date'])
432 | self.data['end_date'] = end_date
433 | self.enddate_popover.popdown()
434 | self.update_datepicker_labels()
435 | self.enddate_picker.connect('updated-date', on_update_selected_enddate)
436 |
437 | def on_delete_enddate(obj):
438 | self.data['end_date'] = None
439 | self.enddate_picker.set_selected_date(None)
440 | self.update_datepicker_labels()
441 | self.enddate_delete = Gtk.Button.new_from_icon_name('user-trash-symbolic', 4)
442 | self.enddate_delete.connect('clicked', on_delete_enddate)
443 |
444 | self.attach_next_to(self.enddate_label, self.startdate_label, Gtk.PositionType.BOTTOM, 2, 1)
445 | self.attach_next_to(self.enddate_button, self.enddate_label, Gtk.PositionType.RIGHT, 3, 1)
446 | self.attach_next_to(self.enddate_delete, self.enddate_button, Gtk.PositionType.RIGHT, 1, 1)
447 |
448 | self.allday_label = Gtk.Label(label='All day?')
449 | self.allday_toggle = Gtk.Switch()
450 |
451 | def on_activate_allday(obj, gparam):
452 | self.data['all_day'] = obj.get_active()
453 | self.enddate_picker.set_date_only(self.data['all_day'])
454 | self.startdate_picker.set_date_only(self.data['all_day'])
455 | self.update_datepicker_labels()
456 | self.allday_toggle.connect('notify::active', on_activate_allday)
457 | self.allday_toggle.set_halign(Gtk.Align.START)
458 |
459 | self.attach_next_to(self.allday_label, self.enddate_label, Gtk.PositionType.BOTTOM, 2, 1)
460 | self.attach_next_to(self.allday_toggle, self.allday_label, Gtk.PositionType.RIGHT, 1, 1)
461 |
462 | self.classification_label = Gtk.Label(label='Classification')
463 | self.classification_combobox = DropdownSelectWidget(options=Todo.CLASS_OPTIONS)
464 |
465 | def on_changed_classification(obj):
466 | self.data['classification'] = obj.get_active_id()
467 | self.classification_combobox.connect('changed', on_changed_classification)
468 |
469 | self.attach_next_to(self.classification_label, self.allday_label, Gtk.PositionType.BOTTOM, 2, 1)
470 | self.attach_next_to(self.classification_combobox, self.classification_label, Gtk.PositionType.RIGHT, 4, 1)
471 |
472 | self.status_label = Gtk.Label(label='Status')
473 | self.status_combobox = DropdownSelectWidget(options=Todo.STATUS_OPTIONS)
474 |
475 | def on_changed_status(obj):
476 | self.data['status'] = obj.get_active_id()
477 | self.status_combobox.connect('changed', on_changed_status)
478 |
479 | self.attach_next_to(self.status_label, self.classification_label, Gtk.PositionType.BOTTOM, 2, 1)
480 | self.attach_next_to(self.status_combobox, self.status_label, Gtk.PositionType.RIGHT, 4, 1)
481 |
482 | self.progress_label = Gtk.Label(label='Progress')
483 | self.progress_slider = Gtk.Scale(
484 | orientation=Gtk.Orientation.HORIZONTAL,
485 | adjustment=Gtk.Adjustment(
486 | value=0,
487 | lower=0,
488 | upper=100,
489 | step_increment=1,
490 | page_increment=10,
491 | page_size=0,
492 | ),
493 | )
494 | self.progress_slider.set_value_pos(Gtk.PositionType.LEFT)
495 | self.progress_slider.set_digits(0)
496 | self.progress_slider.set_hexpand(True)
497 | self.progress_slider.set_valign(Gtk.Align.START)
498 |
499 | def on_changed_progress(obj):
500 | self.data['progress'] = obj.get_value()
501 | self.progress_slider.connect('value-changed', on_changed_progress)
502 | self.attach_next_to(self.progress_label, self.status_label, Gtk.PositionType.BOTTOM, 2, 1)
503 | self.attach_next_to(self.progress_slider, self.progress_label, Gtk.PositionType.RIGHT, 4, 1)
504 |
505 | self.priority_label = Gtk.Label(label='Priority')
506 | self.priority_slider = Gtk.Scale(
507 | orientation=Gtk.Orientation.HORIZONTAL,
508 | adjustment=Gtk.Adjustment(
509 | value=0,
510 | lower=0,
511 | upper=9,
512 | step_increment=1,
513 | page_increment=0,
514 | page_size=0,
515 | ),
516 | )
517 | self.priority_slider.set_value_pos(Gtk.PositionType.LEFT)
518 | self.priority_slider.set_digits(0)
519 | self.priority_slider.set_hexpand(True)
520 | self.priority_slider.set_valign(Gtk.Align.START)
521 |
522 | def on_changed_priority(obj):
523 | self.data['priority'] = obj.get_value()
524 | self.priority_slider.connect('value-changed', on_changed_priority)
525 | self.attach_next_to(self.priority_label, self.progress_label, Gtk.PositionType.BOTTOM, 2, 1)
526 | self.attach_next_to(self.priority_slider, self.priority_label, Gtk.PositionType.RIGHT, 4, 1)
527 |
528 | self.category_label = Gtk.Label(label='Categories')
529 | self.category_label.set_margin_bottom(10)
530 | self.category_view = Gtk.Label()
531 | self.attach_next_to(self.category_label, self.priority_label, Gtk.PositionType.BOTTOM, 2, 1)
532 | self.attach_next_to(self.category_view, self.category_label, Gtk.PositionType.RIGHT, 4, 1)
533 |
534 | self.description_label = Gtk.Label(label='Summary')
535 | self.description_window = Gtk.ScrolledWindow()
536 | self.description_window.set_hexpand(True)
537 | self.description_window.set_vexpand(True)
538 | self.description_window.set_shadow_type(type=Gtk.ShadowType.ETCHED_IN)
539 | self.description_view = Gtk.TextView()
540 | self.description_view.set_border_width(5)
541 | self.description_view.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
542 |
543 | def on_changed_description(obj):
544 | self.data['description'] = obj.get_text(obj.get_start_iter(), obj.get_end_iter(), include_hidden_chars=True)
545 |
546 | def limit_text_before(obj, it, text, length):
547 | cnt = obj.get_char_count()
548 | if cnt == CHAR_LONG_LIMIT:
549 | obj.stop_emission_by_name('insert-text')
550 |
551 | def limit_text_after(obj, it, text, length):
552 | cnt = obj.get_char_count()
553 | if cnt > CHAR_LONG_LIMIT:
554 | totrim_begin = obj.get_iter_at_offset(CHAR_LONG_LIMIT)
555 | totrim_end = obj.get_end_iter()
556 | obj.delete(totrim_begin, totrim_end)
557 | it.assign(totrim_begin)
558 | self.description_view.get_buffer().connect('insert-text', limit_text_before)
559 | self.description_view.get_buffer().connect_after('insert-text', limit_text_after)
560 | self.description_view.get_buffer().connect('end-user-action', on_changed_description)
561 |
562 | self.description_window.add(self.description_view)
563 | self.attach_next_to(self.description_label, self.category_label, Gtk.PositionType.BOTTOM, 2, 1)
564 | self.attach_next_to(self.description_window, self.description_label, Gtk.PositionType.RIGHT, 4, 1)
565 |
566 |
567 | class TodoListWindow(Gtk.Grid):
568 | def __init__(self):
569 | super().__init__()
570 |
571 | self.set_column_homogeneous(True)
572 | self.set_column_spacing(5)
573 |
574 | self.store = Gtk.TreeStore(str, str, bool, int, str, str, GObject.TYPE_UINT64)
575 | self.data = {}
576 | self.todo_uid_to_iter = {}
577 | self._reset_old_path = None
578 | self._current_calendar = None
579 |
580 | self.sorted_store = Gtk.TreeModelSort(model=self.store)
581 | self.sorted_store.set_sort_column_id(6, Gtk.SortType.DESCENDING)
582 |
583 | self.tree_view = Gtk.TreeView(model=self.sorted_store)
584 | self.tree_view.set_level_indentation(2)
585 | self.tree_view.set_headers_visible(False)
586 |
587 | column_summary = Gtk.TreeViewColumn('Task Name', Gtk.CellRendererText(), text=0)
588 | column_summary.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE)
589 | column_summary.set_expand(True)
590 | self.tree_view.append_column(column_summary)
591 |
592 | column_display = Gtk.TreeViewColumn('Display', Gtk.CellRendererText(), markup=1)
593 | self.tree_view.append_column(column_display)
594 |
595 | renderer_completed = Gtk.CellRendererToggle()
596 | column_completed = Gtk.TreeViewColumn('Completed', renderer_completed, active=2)
597 | renderer_completed.connect('toggled', self.todo_completion_toggle)
598 | self.tree_view.append_column(column_completed)
599 |
600 | column_progress = Gtk.TreeViewColumn('Progress', Gtk.CellRendererProgress(text=''), value=3)
601 | self.tree_view.append_column(column_progress)
602 |
603 | column_pixbuf = Gtk.TreeViewColumn('Actions', Gtk.CellRendererPixbuf(), icon_name=4)
604 | self.tree_view.append_column(column_pixbuf)
605 |
606 | self.tree_view.connect('button-press-event', self.tree_view_button_press)
607 | self.tree_view.connect('row-activated', self.tree_view_row_activated)
608 | self.tree_view.connect('row-collapsed', self.tree_view_row_visibility_changed, True)
609 | self.tree_view.connect('row-expanded', self.tree_view_row_visibility_changed, False)
610 | self.tree_view.get_selection().connect('changed', self.tree_selection_changed)
611 | self.tree_view.set_enable_tree_lines(True)
612 | self.tree_view.set_enable_search(True)
613 | self.tree_view.set_search_column(0)
614 | self.tree_view.set_tooltip_column(0)
615 | self.todo_scrollable_view = Gtk.ScrolledWindow(vexpand=True)
616 | self.todo_scrollable_view.set_propagate_natural_width(True)
617 | self.todo_scrollable_view.set_shadow_type(type=Gtk.ShadowType.ETCHED_OUT)
618 | self.todo_scrollable_view.add(self.tree_view)
619 | self.todo_scrollable_view.get_hadjustment().connect('value-changed', self.reset_action_popover)
620 | self.todo_scrollable_view.get_vadjustment().connect('value-changed', self.reset_action_popover)
621 | self.attach(self.todo_scrollable_view, 0, 0, 7, 1)
622 |
623 | self.editor_view = TodoEditor()
624 | self.attach_next_to(self.editor_view, self.todo_scrollable_view, Gtk.PositionType.RIGHT, 6, 1)
625 |
626 | self.popover = Gtk.Popover()
627 | self.popover_grid = Gtk.ListBox()
628 | button_delete = Gtk.Button(label='Delete')
629 | image_delete = Gtk.Image.new_from_icon_name('user-trash-symbolic', 4)
630 | button_delete.set_image(image_delete)
631 | button_delete.set_always_show_image(True)
632 |
633 | def on_todo_delete(obj):
634 | def propagate_delete(cur_iter):
635 | uid = self.store[cur_iter][5]
636 |
637 | server.delete_todo(self.data[uid].vtodo, self._current_calendar)
638 | del self.todo_uid_to_iter[uid]
639 | del self.data[uid]
640 |
641 | for child_iter in self.iterate_children(cur_iter):
642 | propagate_delete(child_iter)
643 |
644 | uid = self.popover._attached_uid
645 | it = self.todo_uid_to_iter[uid]
646 | propagate_delete(it)
647 | self.store.remove(it)
648 | self.reset_action_popover()
649 |
650 | button_delete.connect('clicked', on_todo_delete)
651 | self.popover_grid.add(button_delete)
652 |
653 | button_clone_subtask = Gtk.Button(label='Clone')
654 | button_clone_subtask.set_image(Gtk.Image.new_from_icon_name('edit-copy-symbolic', 4))
655 | button_clone_subtask.set_always_show_image(True)
656 |
657 | def on_clone_subtask(obj):
658 | self.clone_todo(attached_uid=self.popover._attached_uid)
659 | button_clone_subtask.connect('clicked', on_clone_subtask)
660 | self.popover_grid.add(button_clone_subtask)
661 |
662 | button_add_subtask = Gtk.Button(label='Add subtask')
663 | button_add_subtask.set_image(Gtk.Image.new_from_icon_name('list-add-symbolic', 4))
664 | button_add_subtask.set_always_show_image(True)
665 |
666 | def on_add_subtask(obj):
667 | self.new_todo(parent_uid=self.popover._attached_uid)
668 | button_add_subtask.connect('clicked', on_add_subtask)
669 | self.popover_grid.add(button_add_subtask)
670 |
671 | self.popover.add(self.popover_grid)
672 |
673 | GObject.timeout_add_seconds(30, self.update_natural_dates)
674 |
675 | def update_natural_dates(self):
676 | for uid, it in self.todo_uid_to_iter.items():
677 | self.store[it][1] = self.data[uid].time_display
678 | return True
679 |
680 | def rebuild_todolist(self):
681 | path_iter = self.tree_view.get_selection().get_selected()[1]
682 | if path_iter is None:
683 | _currently_selected_uid = None
684 | else:
685 | _currently_selected_uid = self.sorted_store[path_iter][5]
686 |
687 | self.store.clear()
688 | self.data.clear()
689 | self.todo_uid_to_iter.clear()
690 |
691 | if self._current_calendar is not None:
692 | adjacency_list = defaultdict(list)
693 |
694 | for vtodo in server.todolist[self._current_calendar]:
695 | todo = Todo.load_from_vtodo(vtodo)
696 | if todo.completed and int(settings.HIDE_COMPLETED):
697 | continue
698 | self.data[str(vtodo['UID'])] = todo
699 |
700 | for todo in self.data.values():
701 | parent = todo['related_to']
702 | if parent not in self.data:
703 | parent = None
704 | adjacency_list[parent].append(todo.uid)
705 |
706 | def add_todos_to_store(current_todo=None):
707 | for todo in adjacency_list[current_todo]:
708 | self.todo_uid_to_iter[todo] = self.attach_todo(self.todo_uid_to_iter.get(current_todo, None), todo)
709 | add_todos_to_store(todo)
710 | add_todos_to_store()
711 |
712 | for todo_uid in adjacency_list[None]:
713 | self.update_tree_view_row_visibility(self.todo_uid_to_iter[todo_uid])
714 |
715 | for todo in self.data.values():
716 | self.connect_todo(todo)
717 |
718 | try:
719 | self.tree_view.get_selection().select_iter(
720 | self.sorted_store.convert_child_iter_to_iter(self.todo_uid_to_iter[_currently_selected_uid])[1],
721 | )
722 | except KeyError:
723 | self.tree_selection_changed()
724 |
725 | def attach_todo(self, parent, uid):
726 | _data = self.data[uid]
727 | return self.store.append(
728 | parent,
729 | [
730 | _data.summary,
731 | _data.time_display,
732 | _data.completed,
733 | _data.progress,
734 | 'applications-system-symbolic',
735 | uid,
736 | _data.sort_value,
737 | ],
738 | )
739 |
740 | def new_todo(self, parent_uid=None, new_todo=None):
741 | if self._current_calendar is None:
742 | return
743 |
744 | if new_todo is None:
745 | new_todo = Todo(related_to=parent_uid)
746 |
747 | parent_it = self.todo_uid_to_iter.get(parent_uid, None)
748 | self.data[new_todo.uid] = new_todo
749 | todo_it = self.todo_uid_to_iter[new_todo.uid] = self.attach_todo(parent_it, new_todo.uid)
750 |
751 | server.update_todo(new_todo.vtodo, self._current_calendar)
752 | self.connect_todo(new_todo)
753 |
754 | path = self.sorted_store.convert_child_path_to_path(self.store.get_path(todo_it))
755 | self.tree_view.expand_to_path(path)
756 | self.tree_view.set_cursor(path, None, False)
757 |
758 | def clone_todo(self, attached_uid=None):
759 | try:
760 | data = self.data[attached_uid]
761 | except KeyError:
762 | return
763 |
764 | cloned_todo = Todo.load_from_vtodo(icalendar.Todo.from_ical(data.vtodo.to_ical()), load_all=False)
765 | self.new_todo(cloned_todo.related_to, new_todo=cloned_todo)
766 |
767 | def connect_todo(self, todo):
768 | path = self.store.get_path(self.todo_uid_to_iter[todo.uid])
769 | for field in ('progress', 'status', 'summary'):
770 | todo.connect(field, self.editor_view.set_data, todo)
771 | todo.connect('progress', self.tree_view_update_progress, path)
772 | todo.connect('progress', self.update_todo_completion, path)
773 | todo.connect('status', self.update_todo_completion, path)
774 | todo.connect('summary', self.tree_view_update_summary, path)
775 | todo.connect('start_date', self.tree_view_update_date, path)
776 | todo.connect('end_date', self.tree_view_update_date, path)
777 | todo.connect('all_day', self.tree_view_update_date, path)
778 | todo.connect('progress', self.tree_view_update_date, path)
779 | todo.connect('status', self.tree_view_update_date, path)
780 | todo.connect('progress', self.tree_view_update_sort, path)
781 | todo.connect('status', self.tree_view_update_sort, path)
782 | todo.connect('priority', self.tree_view_update_sort, path)
783 | todo.connect('start_date', self.tree_view_update_sort, path)
784 | todo.connect('end_date', self.tree_view_update_sort, path)
785 | todo.connect('all_day', self.tree_view_update_sort, path)
786 | todo.connect_to_all(server.update_todo, todo.vtodo, self._current_calendar)
787 |
788 | def reset_action_popover(self, *args):
789 | self.popover.popdown()
790 |
791 | @property
792 | def current_calendar(self):
793 | return self._current_calendar
794 |
795 | @current_calendar.setter
796 | def current_calendar(self, uid):
797 | self._current_calendar = uid
798 | self.rebuild_todolist()
799 |
800 | def iterate_children(self, it):
801 | child_iter = self.store.iter_children(it)
802 | while child_iter is not None:
803 | yield child_iter
804 | child_iter = self.store.iter_next(child_iter)
805 |
806 | def update_todo_completion(self, path):
807 | # Implicit recursion through signals
808 | value = self.store[path][2] = self.data[self.store[path][5]].completed
809 | for child_iter in self.iterate_children(self.store.get_iter(path)):
810 | self.store[child_iter][2] = self.data[self.store[child_iter][5]].completed = value
811 |
812 | def todo_completion_toggle(self, widget, path):
813 | if isinstance(path, str):
814 | path = Gtk.TreePath.new_from_string(path)
815 | path = self.sorted_store.convert_path_to_child_path(path)
816 |
817 | uid = self.store[path][5]
818 | self.data[uid].completed = not self.data[uid].completed
819 | self.update_todo_completion(path)
820 | self._reset_old_path = self.sorted_store.convert_child_path_to_path(path)
821 |
822 | def tree_selection_changed(self, tree_selection=None):
823 | if tree_selection is None:
824 | tree_selection = self.tree_view.get_selection()
825 |
826 | if self._reset_old_path is not None:
827 | tree_selection.select_path(self._reset_old_path)
828 | self._reset_old_path = None
829 | return
830 |
831 | path_iter = tree_selection.get_selected()[1]
832 | if path_iter is None:
833 | self.editor_view.set_data(None)
834 | return
835 |
836 | uid = self.sorted_store[path_iter][5]
837 | _data = self.data[uid]
838 |
839 | self.editor_view.set_uid(uid)
840 | self.editor_view.set_data(_data)
841 |
842 | def update_tree_view_row_visibility(self, it, show=True):
843 | show &= not int(self.data[self.store[it][5]]['hide_subtasks'])
844 | if not show:
845 | return
846 |
847 | self.tree_view.expand_row(
848 | self.sorted_store.convert_child_path_to_path(self.store.get_path(it)),
849 | open_all=False,
850 | )
851 | for child_iter in self.iterate_children(it):
852 | self.update_tree_view_row_visibility(child_iter, show)
853 |
854 | def tree_view_row_visibility_changed(self, tree_view, it, path, hide):
855 | _data = self.data[self.sorted_store[path][5]]
856 | _data.hide_subtasks = int(hide)
857 | self.update_tree_view_row_visibility(self.todo_uid_to_iter[_data.uid], not hide)
858 |
859 | def tree_view_update_summary(self, path):
860 | _data = self.data[self.store[path][5]]
861 | self.store[path][0] = _data.summary
862 |
863 | def tree_view_update_date(self, path):
864 | _data = self.data[self.store[path][5]]
865 | self.store[path][1] = _data.time_display
866 |
867 | def tree_view_update_progress(self, path):
868 | _data = self.data[self.store[path][5]]
869 | self.store[path][3] = _data.progress
870 |
871 | def tree_view_update_sort(self, path):
872 | _data = self.data[self.store[path][5]]
873 | self.store[path][6] = _data.sort_value
874 |
875 | def tree_view_row_activated(self, tree_view, path, column):
876 | if tree_view.row_expanded(path):
877 | tree_view.collapse_row(path)
878 | else:
879 | tree_view.expand_row(path, open_all=False)
880 |
881 | def tree_view_button_press(self, tree_view, event):
882 | if event.type == Gdk.EventType.BUTTON_PRESS:
883 | try:
884 | path, column, cell_x, cell_y = tree_view.get_path_at_pos(event.x, event.y)
885 | except Exception:
886 | self.reset_action_popover()
887 | return
888 | if column.get_title() != 'Actions' and event.button != 3:
889 | self.reset_action_popover()
890 | else:
891 | pos = tree_view.get_cell_area(path, column)
892 | rect = Gdk.Rectangle()
893 | pos_x, pos_y = tree_view.convert_bin_window_to_widget_coords(pos.x, pos.y)
894 | if column.get_title() == 'Actions':
895 | rect.x = pos_x
896 | rect.width = pos.width
897 | else:
898 | rect.x = event.x
899 |
900 | rect.y = pos_y
901 | rect.height = pos.height // 2
902 | self.popover.set_position(Gtk.PositionType.BOTTOM)
903 | self.popover.set_relative_to(tree_view)
904 | self.popover.set_pointing_to(rect)
905 | self.popover.set_modal(False)
906 |
907 | self.popover._attached_uid = self.sorted_store[path][5]
908 |
909 | self.popover.show_all()
910 | self.popover.popup()
911 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | pygobject
2 | humanize
3 | icalendar
4 | caldav
5 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | from setuptools import find_packages, setup
2 |
3 | with open('README.md') as f:
4 | readme = f.read()
5 |
6 | setup(
7 | name='abeluna',
8 | version='1.1.3',
9 | entry_points={
10 | 'gui_scripts': [
11 | 'abeluna = abeluna.main:main',
12 | ],
13 | },
14 | author='Evan Zhang',
15 | install_requires=['pygobject', 'humanize', 'icalendar', 'caldav'],
16 | include_package_data=True,
17 | description='A simple GUI to-do/task manager with CalDAV support.',
18 | long_description=readme,
19 | long_description_content_type='text/markdown',
20 | url='https://github.com/Ninjaclasher/abeluna',
21 | packages=find_packages(),
22 | classifiers=[
23 | 'Development Status :: 4 - Beta',
24 | 'Environment :: X11 Applications :: GTK',
25 | 'Intended Audience :: End Users/Desktop',
26 | 'License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)',
27 | 'Operating System :: POSIX :: Linux',
28 | 'Programming Language :: Python',
29 | 'Programming Language :: Python :: 3.7',
30 | 'Programming Language :: Python :: 3.8',
31 | 'Programming Language :: Python :: 3.9',
32 | 'Programming Language :: Python :: 3.10',
33 | ],
34 | )
35 |
--------------------------------------------------------------------------------