From 7626f4dd9c4570ef74ac6c288ede88ed218b39dc Mon Sep 17 00:00:00 2001 From: Frank Faulstich Date: Wed, 12 Aug 2026 10:45:12 +0200 Subject: [PATCH 1/4] Create a PHP Server Fixes #537 --- README.md | 80 +- TimeControl.spec | 16 +- TimeTrackerMCP_Server.py | 14 +- TimeTrackerREST_Server.py | 4 + TimeTrackerSOAP_Server.py | 21 +- data.json | 42 +- locale/cs/LC_MESSAGES/timetracker.mo | Bin 29004 -> 34067 bytes locale/cs/LC_MESSAGES/timetracker.po | 939 ++++++++++++-------- locale/de/LC_MESSAGES/timetracker.mo | Bin 29730 -> 34889 bytes locale/de/LC_MESSAGES/timetracker.po | 947 ++++++++++++-------- locale/en/LC_MESSAGES/timetracker.mo | Bin 27099 -> 31921 bytes locale/en/LC_MESSAGES/timetracker.po | 938 ++++++++++++-------- locale/es/LC_MESSAGES/timetracker.mo | Bin 29409 -> 34691 bytes locale/es/LC_MESSAGES/timetracker.po | 949 ++++++++++++-------- locale/fr/LC_MESSAGES/timetracker.mo | Bin 30347 -> 35626 bytes locale/fr/LC_MESSAGES/timetracker.po | 947 ++++++++++++-------- locale/timetracker.pot | 927 +++++++++++-------- php-server/README.md | 172 ++++ php-server/check-login.sh | 37 + php-server/check-oplog.py | 156 ++++ php-server/check-sync-apply.py | 301 +++++++ php-server/check-sync-cycle.py | 316 +++++++ php-server/tc/.htaccess | 21 + php-server/tc/index.php | 198 +++++ php-server/tc/lib/.htaccess | 11 + php-server/tc/lib/auth.php | 247 ++++++ php-server/tc/lib/http.php | 87 ++ php-server/tc/lib/oplog.php | 323 +++++++ php-server/tc/lib/store.php | 156 ++++ php-server/tc/setup.php | 427 +++++++++ php-server/tcprobe/.htaccess | 26 + php-server/tcprobe/tcprobe.php | 327 +++++++ sl/SL_Menu.py | 316 ++++++- tests/test_TimeTracker.py | 475 +++++++++- tests/test_TimeTrackerMCP_Server.py | 18 +- tests/test_TimeTrackerREST_Server.py | 26 +- tests/test_TimeTrackerSOAP_Server.py | 20 +- tests/test_repo_hygiene.py | 96 ++ tests/test_sync_apply.py | 871 ++++++++++++++++++ tests/test_sync_client.py | 375 ++++++++ tests/test_sync_emit.py | 325 +++++++ tests/test_sync_engine.py | 1224 ++++++++++++++++++++++++++ tests/test_sync_outbox.py | 395 +++++++++ tt/TimeTracker.py | 626 +++++++++++-- tt/filelock.py | 88 ++ tt/sync_apply.py | 533 +++++++++++ tt/sync_client.py | 360 ++++++++ tt/sync_engine.py | 735 ++++++++++++++++ tt/sync_outbox.py | 286 ++++++ 49 files changed, 13038 insertions(+), 2360 deletions(-) create mode 100644 php-server/README.md create mode 100755 php-server/check-login.sh create mode 100755 php-server/check-oplog.py create mode 100644 php-server/check-sync-apply.py create mode 100644 php-server/check-sync-cycle.py create mode 100644 php-server/tc/.htaccess create mode 100644 php-server/tc/index.php create mode 100644 php-server/tc/lib/.htaccess create mode 100644 php-server/tc/lib/auth.php create mode 100644 php-server/tc/lib/http.php create mode 100644 php-server/tc/lib/oplog.php create mode 100644 php-server/tc/lib/store.php create mode 100644 php-server/tc/setup.php create mode 100644 php-server/tcprobe/.htaccess create mode 100644 php-server/tcprobe/tcprobe.php create mode 100644 tests/test_repo_hygiene.py create mode 100644 tests/test_sync_apply.py create mode 100644 tests/test_sync_client.py create mode 100644 tests/test_sync_emit.py create mode 100644 tests/test_sync_engine.py create mode 100644 tests/test_sync_outbox.py create mode 100644 tt/filelock.py create mode 100644 tt/sync_apply.py create mode 100644 tt/sync_client.py create mode 100644 tt/sync_engine.py create mode 100644 tt/sync_outbox.py diff --git a/README.md b/README.md index f4d5862..46ec8f7 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ A simple, object-oriented Python application for tracking time spent on projects - [Usage ⚙️](#usage-️) - [Running the Streamlit GUI](#running-the-streamlit-gui) - [MCP Server 🤖](#mcp-server-) + - [Synchronising Two Machines 🔄](#synchronising-two-machines-) - [Building the Documentation 📚](#building-the-documentation-) - [Data Storage 🗄️](#data-storage-️) - [Contributing 🤝](#contributing-) @@ -44,6 +45,8 @@ A simple, object-oriented Python application for tracking time spent on projects **Local Data Storage:** All project data and time entries are saved in a `data.json` file in the application's directory. +**Synchronisation (optional):** Keep one person's `data.json` in step across their own two or three computers, via a small PHP server you host yourself. Off by default, and everything above works exactly the same without it — see [Synchronising Two Machines](#synchronising-two-machines-) below. + **Automatic Updates:** The app checks GitHub for a new version once per session and, if one is available, shows a notification right under the version number on every screen, with a one-click button that downloads, installs, and restarts into it. **Interface:** @@ -122,19 +125,27 @@ The application can be configured via the `config.json` file. "mcp_transport": "http", "mcp_port": 8700, "data_file": "data.json", - "css_file": "style.css" + "css_file": "style.css", + "sync": { + "enabled": false, + "base_url": "https://example.com/tc/", + "interval_minutes": 5 + } } ``` - **`update.github_repo`**: The GitHub repository (username/reponame) to check for new versions. - **`language`**: The user interface language ("en", "de", "fr", "es", "cs"). - **`soap_port`**: The port on which the SOAP server listens (default: 8600). -- **`rest_port`**: The port on which the REST server listens (default: 8800). See [REST API](#rest-api-). +- **`rest_port`**: The port on which the REST server listens (default: 8800). See [examples/REST](examples/REST) for runnable client examples. - **`mcp_server_enabled`**: Whether `TimeTrackerSL_GUI.py` also starts the MCP server when `mcp_transport` is `"http"` (default: `false`). See [MCP Server](#mcp-server-). - **`mcp_transport`**: `"http"` or `"stdio"` (default: `"http"`). See [MCP Server](#mcp-server-). - **`mcp_port`**: The port on which the MCP server listens when using the `"http"` transport (default: 8700). +- **`sync`**: Optional, and absent by default — which means off. `enabled` switches synchronisation on, `base_url` is the address of your own server, and `interval_minutes` is how often it runs in the background (default: 5). See [Synchronising Two Machines](#synchronising-two-machines-). + +All of these MCP settings can also be changed from the GUI, under **Settings → MCP Server Settings**, and the sync settings under **Settings → Sync Server Settings**. -All of these MCP settings can also be changed from the GUI, under **Settings → MCP Server Settings**. +Your sync **username and password are deliberately not in this file.** Signing in stores an access token in the per-user configuration directory instead — `%APPDATA%\TimeControl\` on Windows, `~/.config/TimeControl/` elsewhere. That way you can copy `config.json` to your second machine to give it the same server without handing it your credentials, and the token never travels with the project directory into a backup or a repository. --- @@ -196,7 +207,7 @@ python TimeTrackerMCP_Server.py - **Email import:** `fetch_emails_to_tasks` (requires email import to be configured, see above). - **Misc:** `get_version`. -`update_task` only changes the fields you actually pass — in particular, a task's due date is preserved automatically if you don't specify one, since updating it always requires re-sending the current value under the hood. +`update_task` only changes the fields you actually pass — a task's due date included, so omitting it leaves it as it is. Removing a due date is a separate request: pass `clear_due_date`. > ⚠️ **Destructive tools:** `delete_task`, `delete_all_closed_tasks`, and `delete_main_project` permanently delete data and cannot be undone. An MCP client should always confirm with you before calling them. @@ -217,6 +228,46 @@ Use the absolute path to the script - Claude Desktop (like most MCP clients) lau Claude Desktop then starts and stops the server itself - it does not need to be running beforehand, and the GUI does not start a second copy of it (see above). Alternatively, with `"mcp_transport": "http"` and the server running (either via the GUI or stand-alone), point Claude Desktop at the Streamable HTTP endpoint, `http://127.0.0.1:8700/mcp` (adjust the port to match `mcp_port`), instead. Consult Claude Desktop's current documentation for the exact configuration steps, since these have changed between versions. +## Synchronising Two Machines 🔄 + +TimeControl can keep **one person's** `data.json` in step across their own two or three computers — a desktop and a laptop, say. It is entirely optional and off by default: without it the application works exactly as it always has, storing everything locally and talking to nobody. + +This is deliberately **not** a collaboration feature. There is one document per account, and it is yours. + +### What you need + +A `data.json` cannot simply be copied back and forth — whichever copy is written last would silently destroy the other machine's afternoon. So the machines exchange *intentions* ("set the priority of task X to 3") through a small server that you host, which keeps them in an append-only log and hands each machine whatever it has not seen yet. + +That server is in [`php-server/`](php-server/). It is plain PHP with no database and no dependencies, and it runs on ordinary shared web hosting — the kind with an FTP login and no shell access. Installation, the security model and the exact API are described in [php-server/README.md](php-server/README.md). + +### Setting it up + +1. Upload the contents of `php-server/tc/` to your web space and run the installer once, following [php-server/README.md](php-server/README.md). Create yourself an account while you are there. +2. In the GUI, open **Settings → Sync Server Settings**. +3. Enter the **server address** (it must start with `https://`), tick **Enable synchronisation**, and press **Save**. Save it before signing in — the sign-in reads the address from disk, not from the text box. +4. Enter your **username** and **password** and press **Sign in**. +5. Repeat steps 2–4 on the second machine. + +Whichever machine reaches an empty server first offers what it already has. A machine joining later offers its own document too, so nothing built up before you switched synchronisation on is left behind. + +### How it behaves + +Synchronisation runs in the background, every few minutes and whenever you switch to a different view. **Nothing in the interface ever waits for it** — a server that has gone away costs you a sync, never a pause. Changes you make while offline queue up and go out when the connection returns. + +When the same task is edited on both machines, changes to *different* fields both survive; for the *same* field, whichever reached the server later wins. Starting work on one machine ends a session left running on the other, at the moment the new one began, so no stretch of time is counted twice. + +**Deleting a task discards its recorded hours on both machines.** That is what deleting has always done locally, and both sides have to agree or the two documents drift apart. If work was booked on the other machine and had not yet been sent when you deleted the task, it is gone — the app says so rather than letting it pass unnoticed, but it cannot bring it back. + +The status line under the version number appears only when something needs you — a sign-in that has expired, or time that was discarded. **Settings → Sync Server Settings** shows when the last sync ran and how much is still waiting to be sent. + +### Limitations worth knowing + +Only the GUI drives synchronisation. Changes made through the MCP, REST or SOAP interfaces are recorded and queued, but they leave the machine when the GUI is running. + +The server's log currently grows without bound; compaction is planned but not yet implemented, so a machine that has been away for a very long time replays a lot of history to catch up. + +--- + ## Building the Documentation 📚 This project uses Sphinx to generate documentation from the docstrings in the source code. @@ -248,19 +299,30 @@ The `data.json` file has the following structure: ```json { + "schema_version": 2, + "next_id": 7, "projects": [ { + "uid": "9f3a1c40b27e5d81", "main_project_name": "Example Main Project", + "status": "open", + "last_started": "YYYY-MM-DDTHH:MM:SS.ffffff", "tasks": [ { + "uid": "1b7c9e02a4d6f835", + "id": 3, "task_name": "Example Task 1", "status": "open", + "priority": 0, + "last_started": "YYYY-MM-DDTHH:MM:SS.ffffff", "time_entries": [ { + "uid": "c5e8017da39b642f", "start_time": "YYYY-MM-DDTHH:MM:SS.ffffff", "end_time": "YYYY-MM-DDTHH:MM:SS.ffffff" }, { + "uid": "77aa10bc9e3d5f24", "start_time": "YYYY-MM-DDTHH:MM:SS.ffffff" // "end_time" is missing if the entry is still active } @@ -270,12 +332,22 @@ The `data.json` file has the following structure: ] }, // ... other main projects + ], + "_deleted": [ + { "uid": "0e4d8f21ab6c37e9", "kind": "task", "at": "YYYY-MM-DDTHH:MM:SS.ffffff" } ] } ``` Time entries are stored in **ISO 8601 format** (e.g., `"2025-09-12T09:30:00.123456"`). If an `end_time` is missing for a `time_entry`, it means that time tracking is currently active for that task. +Older files are migrated automatically the first time they are opened; nothing needs to be done by hand. The fields added in schema 2 exist for [synchronisation](#synchronising-two-machines-) and are harmless without it: + +- **`uid`** — a 16-character identifier on every project, task and time entry, generated where the object was created and never reused. It is what lets two machines agree that they are talking about the same task even though each numbers its own. +- **`id`** — the short integer handle used by the GUI and the MCP/REST/SOAP calls. Local to one machine, and the same task may carry different ones on different computers. +- **`last_started`** — when work on this project or task last began, so "most recently used" survives a merge rather than depending on the order of a list. +- **`_deleted`** — a record of what has been deleted, kept for 90 days. Without it a deletion here plus any edit there would resurrect the object on the next sync, and again on every sync after that. + --- ## Contributing 🤝 diff --git a/TimeControl.spec b/TimeControl.spec index 14a65bc..a4f72f7 100644 --- a/TimeControl.spec +++ b/TimeControl.spec @@ -27,7 +27,21 @@ datas = [ ('locale', 'locale'), ] binaries = [] -hiddenimports = ['TimeTrackerMCP_Server', 'tt.TimeTracker'] +# sl/SL_Menu.py is carried in `datas`, which PyInstaller copies verbatim +# without ever scanning it for imports - so everything only that file reaches +# has to be named here or it is simply left out of the build. The sync +# modules degrade quietly when missing (SL_Menu catches ImportError and sets +# SYNC_AVAILABLE = False), which is exactly why their absence would go +# unnoticed until somebody wondered why the packaged build never syncs. +hiddenimports = [ + 'TimeTrackerMCP_Server', + 'tt.TimeTracker', + 'tt.sync_client', + 'tt.sync_engine', + 'tt.sync_apply', + 'tt.sync_outbox', + 'tt.filelock', +] # collect_all() pulls in a package's submodules, data files (including its # own dist-info metadata, which streamlit's importlib.metadata-based version diff --git a/TimeTrackerMCP_Server.py b/TimeTrackerMCP_Server.py index c7b05fe..2e3a43c 100644 --- a/TimeTrackerMCP_Server.py +++ b/TimeTrackerMCP_Server.py @@ -402,22 +402,11 @@ def update_task( if current_task is None: return f"Error: Task '{task_name}' not found in project '{main_project_name}'." - # update_task() always overwrites due_date with whatever is passed, even - # if that's None - so an explicit omission has to be resolved to the - # task's current value here rather than left as None, or it would be - # silently cleared as a side effect of changing something unrelated. - if clear_due_date: - final_due_date = None - elif due_date is not None: - final_due_date = due_date - else: - final_due_date = current_task.get('due_date') - success = tracker.update_task( main_project_name, task_name, new_task_name=new_task_name, - due_date=final_due_date, + due_date=due_date, today=today, note=note, status=status, @@ -426,6 +415,7 @@ def update_task( userdefined_days=userdefined_days, priority=priority, task_id=current_task.get('id'), + clear_due_date=clear_due_date, ) if success: return f"Task '{task_name}' updated." diff --git a/TimeTrackerREST_Server.py b/TimeTrackerREST_Server.py index ea19665..aa70e33 100644 --- a/TimeTrackerREST_Server.py +++ b/TimeTrackerREST_Server.py @@ -116,6 +116,9 @@ class UpdateTaskRequest(BaseModel): frequency: Optional[str] = None userdefined_days: Optional[int] = None priority: Optional[int] = Field(default=None, ge=0, le=9) + # A PATCH omits what it doesn't want to change, so an absent due_date + # cannot double as "remove the due date" - that needs its own field. + clear_due_date: bool = False class MoveTaskRequest(BaseModel): @@ -294,6 +297,7 @@ def update_task(main_project_name: str, task_name: str, body: UpdateTaskRequest, body.userdefined_days, body.priority, task_id=task_id, + clear_due_date=body.clear_due_date, ) return SuccessResult(success=updated) diff --git a/TimeTrackerSOAP_Server.py b/TimeTrackerSOAP_Server.py index d980b6b..42138e5 100644 --- a/TimeTrackerSOAP_Server.py +++ b/TimeTrackerSOAP_Server.py @@ -232,16 +232,19 @@ def rename_task(ctx, main_project_name, old_name, new_name, task_id=None): return ctx.udc.rename_task(main_project_name, old_name, new_name, task_id=task_id) return ctx.udc.rename_task(main_project_name, old_name, new_name) - @rpc(Unicode, Unicode, Unicode, Unicode, Boolean, Unicode, Unicode, Boolean, Unicode, Integer, Integer, Integer, _returns=Boolean) - def update_task(ctx, main_project_name, old_name, new_name=None, due_date=None, today=None, note=None, status=None, recurring=None, frequency=None, userdefined_days=None, task_id=None, priority=None): - # priority is appended after task_id (rather than grouped with the - # other content fields before it) so existing positional callers that - # already pass task_id as the 11th argument aren't shifted - spyne - # dispatches @rpc args purely by position, so inserting a new - # parameter anywhere but the end would silently break them. + @rpc(Unicode, Unicode, Unicode, Unicode, Boolean, Unicode, Unicode, Boolean, Unicode, Integer, Integer, Integer, Boolean, _returns=Boolean) + def update_task(ctx, main_project_name, old_name, new_name=None, due_date=None, today=None, note=None, status=None, recurring=None, frequency=None, userdefined_days=None, task_id=None, priority=None, clear_due_date=None): + # priority and clear_due_date are appended after task_id (rather than + # grouped with the other content fields before them) so existing + # positional callers that already pass task_id as the 11th argument + # aren't shifted - spyne dispatches @rpc args purely by position, so + # inserting a new parameter anywhere but the end would silently break + # them. An omitted due_date leaves the current one alone, so removing + # a due date is requested with clear_due_date; spyne passes None for + # any argument the caller left out, hence the bool(). if task_id is not None: - return ctx.udc.update_task(main_project_name, old_name, new_name, due_date, today, note, status, recurring, frequency, userdefined_days, priority=priority, task_id=task_id) - return ctx.udc.update_task(main_project_name, old_name, new_name, due_date, today, note, status, recurring, frequency, userdefined_days, priority=priority) + return ctx.udc.update_task(main_project_name, old_name, new_name, due_date, today, note, status, recurring, frequency, userdefined_days, priority=priority, task_id=task_id, clear_due_date=bool(clear_due_date)) + return ctx.udc.update_task(main_project_name, old_name, new_name, due_date, today, note, status, recurring, frequency, userdefined_days, priority=priority, clear_due_date=bool(clear_due_date)) @rpc(Unicode, Unicode, Unicode, Unicode, _returns=OperationResultModel) def move_task(ctx, old_main, task_name, new_main, task_id=None): diff --git a/data.json b/data.json index ac3e05b..7e9dc7c 100644 --- a/data.json +++ b/data.json @@ -8,22 +8,27 @@ "time_entries": [ { "start_time": "2025-09-23T14:59:56.164582", - "end_time": "2025-09-23T15:03:57.621970" + "end_time": "2025-09-23T15:03:57.621970", + "uid": "ecfc4573b7854aa0" }, { "start_time": "2025-09-23T15:06:24.656467", - "end_time": "2025-09-16T12:27:56.221747" + "end_time": "2025-09-16T12:27:56.221747", + "uid": "0b90989c240d40b4" }, { "start_time": "2025-09-16T12:27:56.222770", - "end_time": "2025-09-16T12:28:01.839785" + "end_time": "2025-09-16T12:28:01.839785", + "uid": "77e8b875225a4cc2" }, { "start_time": "2025-09-17T12:09:04.645565", - "end_time": "2025-09-17T12:09:17.821338" + "end_time": "2025-09-17T12:09:17.821338", + "uid": "545b602b97904a58" }, { - "start_time": "2025-10-31T08:09:59.292314" + "start_time": "2025-10-31T08:09:59.292314", + "uid": "a96e24f3f26c4631" } ], "status": "closed", @@ -35,9 +40,13 @@ "userdefined_days": 1, "task_name": "Sub Test 1", "id": 1, - "priority": 0 + "priority": 0, + "uid": "93b37f66458b4257", + "last_started": "2025-10-31T08:09:59.292314" } - ] + ], + "uid": "447f8c25e7214b5a", + "last_started": "2025-10-31T08:09:59.292314" }, { "main_project_name": "Test 3", @@ -47,7 +56,8 @@ "time_entries": [ { "start_time": "2025-09-23T15:03:57.623331", - "end_time": "2025-09-23T15:05:02.930081" + "end_time": "2025-09-23T15:05:02.930081", + "uid": "54d8ffbe3ca2443e" } ], "status": "open", @@ -59,15 +69,23 @@ "userdefined_days": 1, "task_name": "Sub Test 2", "id": 2, - "priority": 0 + "priority": 0, + "uid": "4a3e2dcca4bb47dd", + "last_started": "2025-09-23T15:03:57.623331" } - ] + ], + "uid": "823cf196386d44fb", + "last_started": "2025-09-23T15:03:57.623331" }, { "main_project_name": "Test 4", "status": "open", - "tasks": [] + "tasks": [], + "uid": "ab5f4711e92341e0", + "last_started": null } ], - "next_id": 3 + "next_id": 3, + "_deleted": [], + "schema_version": 2 } \ No newline at end of file diff --git a/locale/cs/LC_MESSAGES/timetracker.mo b/locale/cs/LC_MESSAGES/timetracker.mo index 9ce9bd0b8542acd06a13e95d7e656190d54fed0c..b33e7147d2f8d96080b6dc2a6156fc534007d74d 100644 GIT binary patch delta 12563 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 H3x@p{aN|(x diff --git a/locale/cs/LC_MESSAGES/timetracker.po b/locale/cs/LC_MESSAGES/timetracker.po index 61fa0d0..bef13ce 100644 --- a/locale/cs/LC_MESSAGES/timetracker.po +++ b/locale/cs/LC_MESSAGES/timetracker.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Project-Id-Version: TimeControl\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-08-06 10:27+0200\n" +"POT-Creation-Date: 2026-08-11 17:36+0200\n" "PO-Revision-Date: 2024-05-15 12:00+0200\n" "Last-Translator: Frank Faulstich\n" "Language-Team: Czech\n" @@ -16,445 +16,523 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;\n" -#: sl/SL_Menu.py:278 sl/SL_Menu.py:1005 sl/SL_Menu.py:2343 sl/SL_Menu.py:2515 +#: sl/SL_Menu.py:294 sl/SL_Menu.py:1134 sl/SL_Menu.py:2645 sl/SL_Menu.py:2817 msgid "Priority" msgstr "Priorita" -#: sl/SL_Menu.py:462 +#: sl/SL_Menu.py:547 #, python-brace-format msgid "Version {version}" msgstr "Verze {version}" -#: sl/SL_Menu.py:467 update.py:49 +#: sl/SL_Menu.py:552 update.py:115 #, python-brace-format msgid "A new version ({version}) is available." msgstr "Je k dispozici nová verze ({version})." -#: sl/SL_Menu.py:469 +#: sl/SL_Menu.py:554 msgid "Restart and install the update" msgstr "Restartovat a nainstalovat aktualizaci" -#: sl/SL_Menu.py:470 +#: sl/SL_Menu.py:555 msgid "Downloading and installing update..." msgstr "Stahuji a instaluji aktualizaci..." -#: sl/SL_Menu.py:501 +#: sl/SL_Menu.py:590 +#, python-brace-format +msgid "" +"{count} time entries were discarded because the task they belonged to had " +"been deleted on another machine." +msgstr "{count} časových záznamů bylo zahozeno, protože úkol, ke kterému patřily, byl smazán na jiném počítači." + +#: sl/SL_Menu.py:613 +#, python-brace-format +msgid "Synchronisation is paused: {reason}" +msgstr "Synchronizace je pozastavena: {reason}" + +#: sl/SL_Menu.py:635 msgid "New" msgstr "Nové" -#: sl/SL_Menu.py:502 +#: sl/SL_Menu.py:636 msgid "New Project" msgstr "Nový projekt" -#: sl/SL_Menu.py:505 +#: sl/SL_Menu.py:639 msgid "New Task" msgstr "Nový úkol" -#: sl/SL_Menu.py:510 +#: sl/SL_Menu.py:644 msgid "Project & Task Management" msgstr "Správa projektů a úkolů" -#: sl/SL_Menu.py:511 sl/SL_Menu.py:1217 +#: sl/SL_Menu.py:645 sl/SL_Menu.py:1349 msgid "Main Project Management" msgstr "Hlavní správa projektů" -#: sl/SL_Menu.py:512 sl/SL_Menu.py:1233 sl/SL_Menu.py:1628 +#: sl/SL_Menu.py:646 sl/SL_Menu.py:1365 sl/SL_Menu.py:1930 msgid "Add Project" msgstr "Přidat projekt" -#: sl/SL_Menu.py:515 sl/SL_Menu.py:1236 sl/SL_Menu.py:1976 +#: sl/SL_Menu.py:649 sl/SL_Menu.py:1368 sl/SL_Menu.py:2278 msgid "List Projects" msgstr "Vypsat projekty" -#: sl/SL_Menu.py:518 sl/SL_Menu.py:1239 sl/SL_Menu.py:1991 +#: sl/SL_Menu.py:652 sl/SL_Menu.py:1371 sl/SL_Menu.py:2293 msgid "Rename Project" msgstr "Přejmenovat projekt" -#: sl/SL_Menu.py:521 sl/SL_Menu.py:1242 sl/SL_Menu.py:2106 sl/SL_Menu.py:2119 +#: sl/SL_Menu.py:655 sl/SL_Menu.py:1374 sl/SL_Menu.py:2408 sl/SL_Menu.py:2421 msgid "Close Project" msgstr "Zavřít projekt" -#: sl/SL_Menu.py:524 sl/SL_Menu.py:1245 sl/SL_Menu.py:2136 sl/SL_Menu.py:2149 +#: sl/SL_Menu.py:658 sl/SL_Menu.py:1377 sl/SL_Menu.py:2438 sl/SL_Menu.py:2451 msgid "Re-open Project" msgstr "Znovu otevřít projekt" -#: sl/SL_Menu.py:527 sl/SL_Menu.py:1248 sl/SL_Menu.py:2166 sl/SL_Menu.py:2180 +#: sl/SL_Menu.py:661 sl/SL_Menu.py:1380 sl/SL_Menu.py:2468 sl/SL_Menu.py:2482 msgid "Delete Project" msgstr "Smazat projekt" -#: sl/SL_Menu.py:530 sl/SL_Menu.py:1251 sl/SL_Menu.py:2197 +#: sl/SL_Menu.py:664 sl/SL_Menu.py:1383 sl/SL_Menu.py:2499 msgid "List Inactive Projects" msgstr "Seznam neaktivních projektů" -#: sl/SL_Menu.py:533 sl/SL_Menu.py:1254 +#: sl/SL_Menu.py:667 sl/SL_Menu.py:1386 msgid "Demote Project to Task" msgstr "Degradovat projekt na úkol" -#: sl/SL_Menu.py:536 sl/SL_Menu.py:1257 sl/SL_Menu.py:2259 +#: sl/SL_Menu.py:670 sl/SL_Menu.py:1389 sl/SL_Menu.py:2561 msgid "List Completed Projects" msgstr "Vypsat dokončené projekty" -#: sl/SL_Menu.py:540 sl/SL_Menu.py:1219 sl/SL_Menu.py:1270 +#: sl/SL_Menu.py:674 sl/SL_Menu.py:1351 sl/SL_Menu.py:1402 msgid "Task Management" msgstr "Správa úkolů" -#: sl/SL_Menu.py:541 sl/SL_Menu.py:1272 sl/SL_Menu.py:2277 sl/SL_Menu.py:2308 -#: sl/SL_Menu.py:2383 +#: sl/SL_Menu.py:675 sl/SL_Menu.py:1404 sl/SL_Menu.py:2579 sl/SL_Menu.py:2610 +#: sl/SL_Menu.py:2685 msgid "Add Task" msgstr "Přidat úkol" -#: sl/SL_Menu.py:544 sl/SL_Menu.py:1275 sl/SL_Menu.py:2026 +#: sl/SL_Menu.py:678 sl/SL_Menu.py:1407 sl/SL_Menu.py:2328 msgid "List Tasks" msgstr "Vypsat úkoly" -#: sl/SL_Menu.py:547 sl/SL_Menu.py:1278 sl/SL_Menu.py:2059 +#: sl/SL_Menu.py:681 sl/SL_Menu.py:1410 sl/SL_Menu.py:2361 msgid "Rename Task" msgstr "Přejmenovat úkol" -#: sl/SL_Menu.py:550 sl/SL_Menu.py:1281 sl/SL_Menu.py:1643 sl/SL_Menu.py:1669 -#: sl/SL_Menu.py:1848 +#: sl/SL_Menu.py:684 sl/SL_Menu.py:1413 sl/SL_Menu.py:1945 sl/SL_Menu.py:1971 +#: sl/SL_Menu.py:2150 msgid "Close Task" msgstr "Uzavřít úkol" -#: sl/SL_Menu.py:553 sl/SL_Menu.py:1284 sl/SL_Menu.py:1688 sl/SL_Menu.py:1714 +#: sl/SL_Menu.py:687 sl/SL_Menu.py:1416 sl/SL_Menu.py:1990 sl/SL_Menu.py:2016 msgid "Re-open Task" msgstr "Znovu otevřít úkol" -#: sl/SL_Menu.py:556 sl/SL_Menu.py:1287 sl/SL_Menu.py:1733 sl/SL_Menu.py:1760 +#: sl/SL_Menu.py:690 sl/SL_Menu.py:1419 sl/SL_Menu.py:2035 sl/SL_Menu.py:2062 msgid "Delete Task" msgstr "Smazat úkol" -#: sl/SL_Menu.py:559 sl/SL_Menu.py:1290 sl/SL_Menu.py:1779 sl/SL_Menu.py:1815 +#: sl/SL_Menu.py:693 sl/SL_Menu.py:1422 sl/SL_Menu.py:2081 sl/SL_Menu.py:2117 msgid "Move Task" msgstr "Přesunout úkol" -#: sl/SL_Menu.py:562 sl/SL_Menu.py:1293 sl/SL_Menu.py:1834 +#: sl/SL_Menu.py:696 sl/SL_Menu.py:1425 sl/SL_Menu.py:2136 msgid "List Inactive Tasks" msgstr "Vypsat neaktivní úkoly" -#: sl/SL_Menu.py:565 sl/SL_Menu.py:1296 sl/SL_Menu.py:1861 +#: sl/SL_Menu.py:699 sl/SL_Menu.py:1428 sl/SL_Menu.py:2163 msgid "List All Closed Tasks" msgstr "Vypsat všechny uzavřené úkoly" -#: sl/SL_Menu.py:568 sl/SL_Menu.py:727 sl/SL_Menu.py:813 sl/SL_Menu.py:1027 -#: sl/SL_Menu.py:1299 sl/SL_Menu.py:2411 sl/SL_Menu.py:2431 sl/SL_Menu.py:2466 +#: sl/SL_Menu.py:702 sl/SL_Menu.py:861 sl/SL_Menu.py:945 sl/SL_Menu.py:1155 +#: sl/SL_Menu.py:1431 sl/SL_Menu.py:2713 sl/SL_Menu.py:2733 sl/SL_Menu.py:2768 msgid "Edit Task" msgstr "Upravit úkol" -#: sl/SL_Menu.py:571 sl/SL_Menu.py:1302 sl/SL_Menu.py:1886 +#: sl/SL_Menu.py:705 sl/SL_Menu.py:1434 sl/SL_Menu.py:2188 msgid "Delete All Closed Tasks" msgstr "Smazat všechny uzavřené úkoly" -#: sl/SL_Menu.py:574 sl/SL_Menu.py:1305 sl/SL_Menu.py:1928 +#: sl/SL_Menu.py:708 sl/SL_Menu.py:1437 sl/SL_Menu.py:2230 msgid "Promote Task to Project" msgstr "Povýšit úkol na projekt" -#: sl/SL_Menu.py:579 +#: sl/SL_Menu.py:713 msgid "Today View" msgstr "Dnes Zobrazit" -#: sl/SL_Menu.py:584 sl/SL_Menu.py:643 +#: sl/SL_Menu.py:718 sl/SL_Menu.py:777 msgid "Task Planning" msgstr "Plánování úkolů" -#: sl/SL_Menu.py:589 sl/SL_Menu.py:1062 +#: sl/SL_Menu.py:723 sl/SL_Menu.py:1189 msgid "E-Mail Task Assignment" msgstr "Zadání úkolu E-" -#: sl/SL_Menu.py:594 sl/SL_Menu.py:723 sl/SL_Menu.py:809 sl/SL_Menu.py:1023 +#: sl/SL_Menu.py:728 sl/SL_Menu.py:857 sl/SL_Menu.py:941 sl/SL_Menu.py:1151 msgid "Start work on task" msgstr "Začít práci na úkolu" -#: sl/SL_Menu.py:599 +#: sl/SL_Menu.py:733 msgid "Show current work" msgstr "Zobrazit aktuální práci" -#: sl/SL_Menu.py:604 +#: sl/SL_Menu.py:738 msgid "Stop current work" msgstr "Zastavit aktuální práci" -#: sl/SL_Menu.py:606 +#: sl/SL_Menu.py:740 msgid "Work session stopped successfully." msgstr "Pracovní relace byla úspěšně zastavena." -#: sl/SL_Menu.py:608 +#: sl/SL_Menu.py:742 msgid "No active work session to stop." msgstr "Žádná aktivní pracovní relace k zastavení." -#: sl/SL_Menu.py:612 sl/SL_Menu.py:1318 +#: sl/SL_Menu.py:746 sl/SL_Menu.py:1450 msgid "Reporting" msgstr "Reportování" -#: sl/SL_Menu.py:613 sl/SL_Menu.py:1321 +#: sl/SL_Menu.py:747 sl/SL_Menu.py:1453 msgid "Daily Report (Today)" msgstr "Denní zpráva (dnes)" -#: sl/SL_Menu.py:618 sl/SL_Menu.py:1327 sl/SL_Menu.py:2661 +#: sl/SL_Menu.py:752 sl/SL_Menu.py:1459 sl/SL_Menu.py:2967 msgid "Daily Report (Specific Day)" msgstr "Denní zpráva (konkrétní den)" -#: sl/SL_Menu.py:621 sl/SL_Menu.py:1330 sl/SL_Menu.py:2680 +#: sl/SL_Menu.py:755 sl/SL_Menu.py:1462 sl/SL_Menu.py:2986 msgid "Date Range Report" msgstr "Zpráva za období" -#: sl/SL_Menu.py:624 sl/SL_Menu.py:1333 sl/SL_Menu.py:2707 sl/SL_Menu.py:2738 +#: sl/SL_Menu.py:758 sl/SL_Menu.py:1465 sl/SL_Menu.py:3013 sl/SL_Menu.py:3044 msgid "Detailed Task Report" msgstr "Podrobná zpráva o úkolu" -#: sl/SL_Menu.py:627 sl/SL_Menu.py:1336 sl/SL_Menu.py:2766 +#: sl/SL_Menu.py:761 sl/SL_Menu.py:1468 sl/SL_Menu.py:3072 msgid "Detailed Project Report" msgstr "Podrobná zpráva o projektu" -#: sl/SL_Menu.py:630 sl/SL_Menu.py:1339 sl/SL_Menu.py:2793 +#: sl/SL_Menu.py:764 sl/SL_Menu.py:1471 sl/SL_Menu.py:3099 msgid "Detailed Daily Report" msgstr "Podrobná denní zpráva" -#: sl/SL_Menu.py:635 sl/SL_Menu.py:1356 +#: sl/SL_Menu.py:769 sl/SL_Menu.py:1518 msgid "Settings" msgstr "Nastavení" -#: sl/SL_Menu.py:648 sl/SL_Menu.py:668 sl/SL_Menu.py:734 sl/SL_Menu.py:820 -#: sl/SL_Menu.py:1177 sl/SL_Menu.py:2338 sl/SL_Menu.py:2509 +#: sl/SL_Menu.py:782 sl/SL_Menu.py:802 sl/SL_Menu.py:868 sl/SL_Menu.py:952 +#: sl/SL_Menu.py:1304 sl/SL_Menu.py:2640 sl/SL_Menu.py:2811 msgid "Today" msgstr "Dnes" -#: sl/SL_Menu.py:649 sl/SL_Menu.py:669 +#: sl/SL_Menu.py:783 sl/SL_Menu.py:803 msgid "Tomorrow" msgstr "Zítra" -#: sl/SL_Menu.py:650 sl/SL_Menu.py:670 +#: sl/SL_Menu.py:784 sl/SL_Menu.py:804 msgid "Weekly overview" msgstr "Týdenní přehled" -#: sl/SL_Menu.py:651 sl/SL_Menu.py:671 +#: sl/SL_Menu.py:785 sl/SL_Menu.py:805 msgid "Overdue tasks" msgstr "Zpožděné úkoly" -#: sl/SL_Menu.py:652 sl/SL_Menu.py:672 +#: sl/SL_Menu.py:786 sl/SL_Menu.py:806 msgid "Unplanned tasks" msgstr "Neplánované úkoly" -#: sl/SL_Menu.py:653 +#: sl/SL_Menu.py:787 msgid "All" msgstr "Vše" -#: sl/SL_Menu.py:660 +#: sl/SL_Menu.py:794 msgid "Filter" msgstr "Filtr" -#: sl/SL_Menu.py:679 +#: sl/SL_Menu.py:813 msgid "Tasks" msgstr "Úkoly" -#: sl/SL_Menu.py:686 tt/TimeTracker.py:1479 tt/TimeTracker.py:1577 +#: sl/SL_Menu.py:820 tt/TimeTracker.py:1937 tt/TimeTracker.py:2035 msgid "Friday" msgstr "Pátek" -#: sl/SL_Menu.py:686 tt/TimeTracker.py:1479 tt/TimeTracker.py:1577 +#: sl/SL_Menu.py:820 tt/TimeTracker.py:1937 tt/TimeTracker.py:2035 msgid "Monday" msgstr "Pondělí" -#: sl/SL_Menu.py:686 tt/TimeTracker.py:1479 tt/TimeTracker.py:1577 +#: sl/SL_Menu.py:820 tt/TimeTracker.py:1937 tt/TimeTracker.py:2035 msgid "Saturday" msgstr "Sobota" -#: sl/SL_Menu.py:686 tt/TimeTracker.py:1479 tt/TimeTracker.py:1577 +#: sl/SL_Menu.py:820 tt/TimeTracker.py:1937 tt/TimeTracker.py:2035 msgid "Sunday" msgstr "Neděle" -#: sl/SL_Menu.py:686 tt/TimeTracker.py:1479 tt/TimeTracker.py:1577 +#: sl/SL_Menu.py:820 tt/TimeTracker.py:1937 tt/TimeTracker.py:2035 msgid "Thursday" msgstr "Čtvrtek" -#: sl/SL_Menu.py:686 tt/TimeTracker.py:1479 tt/TimeTracker.py:1577 +#: sl/SL_Menu.py:820 tt/TimeTracker.py:1937 tt/TimeTracker.py:2035 msgid "Tuesday" msgstr "Úterý" -#: sl/SL_Menu.py:686 tt/TimeTracker.py:1479 tt/TimeTracker.py:1577 +#: sl/SL_Menu.py:820 tt/TimeTracker.py:1937 tt/TimeTracker.py:2035 msgid "Wednesday" msgstr "Středa" -#: sl/SL_Menu.py:747 sl/SL_Menu.py:833 sl/SL_Menu.py:885 sl/SL_Menu.py:1034 -#: sl/SL_Menu.py:2511 +#: sl/SL_Menu.py:880 sl/SL_Menu.py:964 sl/SL_Menu.py:1015 sl/SL_Menu.py:1162 +#: sl/SL_Menu.py:2813 msgid "Done" msgstr "Hotovo" -#: sl/SL_Menu.py:797 sl/SL_Menu.py:993 +#: sl/SL_Menu.py:929 sl/SL_Menu.py:1122 msgid "Due" msgstr "Termín" -#: sl/SL_Menu.py:848 +#: sl/SL_Menu.py:978 msgid "No tasks found." msgstr "Nebyly nalezeny žádné úkoly." -#: sl/SL_Menu.py:850 sl/SL_Menu.py:1206 sl/SL_Menu.py:1224 sl/SL_Menu.py:1263 -#: sl/SL_Menu.py:1311 sl/SL_Menu.py:1345 sl/SL_Menu.py:1615 sl/SL_Menu.py:1648 -#: sl/SL_Menu.py:1659 sl/SL_Menu.py:1693 sl/SL_Menu.py:1704 sl/SL_Menu.py:1738 -#: sl/SL_Menu.py:1749 sl/SL_Menu.py:1784 sl/SL_Menu.py:1795 sl/SL_Menu.py:1803 -#: sl/SL_Menu.py:1854 sl/SL_Menu.py:1879 sl/SL_Menu.py:1901 sl/SL_Menu.py:1933 -#: sl/SL_Menu.py:1944 sl/SL_Menu.py:1984 sl/SL_Menu.py:1996 sl/SL_Menu.py:2031 -#: sl/SL_Menu.py:2052 sl/SL_Menu.py:2064 sl/SL_Menu.py:2075 sl/SL_Menu.py:2111 -#: sl/SL_Menu.py:2141 sl/SL_Menu.py:2171 sl/SL_Menu.py:2211 sl/SL_Menu.py:2223 -#: sl/SL_Menu.py:2270 sl/SL_Menu.py:2282 sl/SL_Menu.py:2416 sl/SL_Menu.py:2435 -#: sl/SL_Menu.py:2448 sl/SL_Menu.py:2463 sl/SL_Menu.py:2600 sl/SL_Menu.py:2610 -#: sl/SL_Menu.py:2654 sl/SL_Menu.py:2673 sl/SL_Menu.py:2700 sl/SL_Menu.py:2712 -#: sl/SL_Menu.py:2724 sl/SL_Menu.py:2743 sl/SL_Menu.py:2759 sl/SL_Menu.py:2771 -#: sl/SL_Menu.py:2786 sl/SL_Menu.py:2805 sl/SL_Menu.py:2846 +#: sl/SL_Menu.py:980 sl/SL_Menu.py:1338 sl/SL_Menu.py:1356 sl/SL_Menu.py:1395 +#: sl/SL_Menu.py:1443 sl/SL_Menu.py:1477 sl/SL_Menu.py:1917 sl/SL_Menu.py:1950 +#: sl/SL_Menu.py:1961 sl/SL_Menu.py:1995 sl/SL_Menu.py:2006 sl/SL_Menu.py:2040 +#: sl/SL_Menu.py:2051 sl/SL_Menu.py:2086 sl/SL_Menu.py:2097 sl/SL_Menu.py:2105 +#: sl/SL_Menu.py:2156 sl/SL_Menu.py:2181 sl/SL_Menu.py:2203 sl/SL_Menu.py:2235 +#: sl/SL_Menu.py:2246 sl/SL_Menu.py:2286 sl/SL_Menu.py:2298 sl/SL_Menu.py:2333 +#: sl/SL_Menu.py:2354 sl/SL_Menu.py:2366 sl/SL_Menu.py:2377 sl/SL_Menu.py:2413 +#: sl/SL_Menu.py:2443 sl/SL_Menu.py:2473 sl/SL_Menu.py:2513 sl/SL_Menu.py:2525 +#: sl/SL_Menu.py:2572 sl/SL_Menu.py:2584 sl/SL_Menu.py:2718 sl/SL_Menu.py:2737 +#: sl/SL_Menu.py:2750 sl/SL_Menu.py:2765 sl/SL_Menu.py:2906 sl/SL_Menu.py:2916 +#: sl/SL_Menu.py:2960 sl/SL_Menu.py:2979 sl/SL_Menu.py:3006 sl/SL_Menu.py:3018 +#: sl/SL_Menu.py:3030 sl/SL_Menu.py:3049 sl/SL_Menu.py:3065 sl/SL_Menu.py:3077 +#: sl/SL_Menu.py:3092 sl/SL_Menu.py:3111 sl/SL_Menu.py:3152 msgid "Back" msgstr "Zpět" -#: sl/SL_Menu.py:861 +#: sl/SL_Menu.py:991 msgid "Today's Tasks" msgstr "Dnešní úkoly" -#: sl/SL_Menu.py:880 sl/SL_Menu.py:2639 +#: sl/SL_Menu.py:1010 sl/SL_Menu.py:2945 msgid "Current Active Work" msgstr "Aktuální aktivní práce" -#: sl/SL_Menu.py:882 sl/SL_Menu.py:2652 +#: sl/SL_Menu.py:1012 sl/SL_Menu.py:2958 msgid "No active work session." msgstr "Žádná aktivní pracovní relace." -#: sl/SL_Menu.py:899 +#: sl/SL_Menu.py:1028 msgid "Edit current task" msgstr "Upravit aktuální úkol" -#: sl/SL_Menu.py:923 +#: sl/SL_Menu.py:1052 msgid "Show only open tasks" msgstr "Zobrazit pouze otevřené úkoly" -#: sl/SL_Menu.py:936 +#: sl/SL_Menu.py:1065 msgid "Sort by priority" msgstr "Řadit podle priority" -#: sl/SL_Menu.py:1008 sl/SL_Menu.py:2343 sl/SL_Menu.py:2515 +#: sl/SL_Menu.py:1137 sl/SL_Menu.py:2645 sl/SL_Menu.py:2817 msgid "0 (lowest) to 9 (highest)" msgstr "0 (nejnižší) až 9 (nejvyšší)" -#: sl/SL_Menu.py:1049 +#: sl/SL_Menu.py:1176 msgid "No open tasks for today." msgstr "Žádné otevřené úkoly na dnešek." -#: sl/SL_Menu.py:1051 +#: sl/SL_Menu.py:1178 msgid "No tasks for today." msgstr "Žádné úkoly na dnešek." -#: sl/SL_Menu.py:1055 +#: sl/SL_Menu.py:1182 msgid "Exit" msgstr "Konec" -#: sl/SL_Menu.py:1068 +#: sl/SL_Menu.py:1195 msgid "Fetching emails..." msgstr "Načítání e-mailů..." -#: sl/SL_Menu.py:1071 +#: sl/SL_Menu.py:1198 #, python-brace-format msgid "Error fetching emails: {error}" msgstr "Chyba při načítání e-mailů: {error}" -#: sl/SL_Menu.py:1074 +#: sl/SL_Menu.py:1201 #, python-brace-format msgid "{count} new tasks created from emails." msgstr "{count} nových úkolů vytvořených z e-mailů." -#: sl/SL_Menu.py:1076 +#: sl/SL_Menu.py:1203 msgid "No new emails found." msgstr "Nebyly nalezeny žádné nové e-maily." -#: sl/SL_Menu.py:1101 +#: sl/SL_Menu.py:1228 #, python-brace-format msgid "{remaining} of {total} emails still to process" msgstr "{remaining} z {total} e-mailů zbývá zpracovat" -#: sl/SL_Menu.py:1105 +#: sl/SL_Menu.py:1232 msgid "No unassigned email tasks available." msgstr "Nejsou k dispozici žádné nepřiřazené e-mailové úkoly." -#: sl/SL_Menu.py:1117 +#: sl/SL_Menu.py:1244 msgid "Assign Project" msgstr "Přiřadit projekt" -#: sl/SL_Menu.py:1128 +#: sl/SL_Menu.py:1255 msgid "Are you sure you want to delete this task?" msgstr "Opravdu chcete smazat tento úkol?" -#: sl/SL_Menu.py:1131 +#: sl/SL_Menu.py:1258 msgid "Yes, delete" msgstr "Ano, smazat" -#: sl/SL_Menu.py:1136 +#: sl/SL_Menu.py:1263 msgid "No, cancel" msgstr "Ne, zrušit" -#: sl/SL_Menu.py:1143 +#: sl/SL_Menu.py:1270 msgid "Delete" msgstr "Smazat" -#: sl/SL_Menu.py:1147 +#: sl/SL_Menu.py:1274 msgid "Edit Details" msgstr "Upravit podrobnosti" -#: sl/SL_Menu.py:1155 sl/SL_Menu.py:2495 +#: sl/SL_Menu.py:1282 sl/SL_Menu.py:2797 msgid "Task Name" msgstr "Název úkolu" -#: sl/SL_Menu.py:1164 sl/SL_Menu.py:2499 +#: sl/SL_Menu.py:1291 sl/SL_Menu.py:2801 msgid "Due Date" msgstr "Termín" -#: sl/SL_Menu.py:1172 sl/SL_Menu.py:2503 +#: sl/SL_Menu.py:1299 sl/SL_Menu.py:2805 msgid "Clear" msgstr "Vymazat" -#: sl/SL_Menu.py:1179 sl/SL_Menu.py:2371 sl/SL_Menu.py:2544 +#: sl/SL_Menu.py:1306 sl/SL_Menu.py:2673 sl/SL_Menu.py:2846 msgid "Notes (Markdown)" msgstr "Poznámky (označení)" -#: sl/SL_Menu.py:1198 +#: sl/SL_Menu.py:1330 msgid "Task details updated successfully." msgstr "Podrobnosti úkolu byly úspěšně aktualizovány." -#: sl/SL_Menu.py:1204 +#: sl/SL_Menu.py:1336 msgid "Error updating task details." msgstr "Při aktualizaci podrobností úkolu došlo k chybě." -#: sl/SL_Menu.py:1215 sl/SL_Menu.py:1231 +#: sl/SL_Menu.py:1347 sl/SL_Menu.py:1363 msgid "Project Management" msgstr "Správa projektů" -#: sl/SL_Menu.py:1359 +#: sl/SL_Menu.py:1490 +msgid "No server address is set. Enter one above and save it first." +msgstr "Není nastavena adresa serveru. Zadejte ji výše a uložte." + +#: sl/SL_Menu.py:1491 +msgid "" +"The address must start with https:// - a token sent over plain HTTP could be " +"read by anyone on the way." +msgstr "" +"Adresa musí začínat https:// – token odeslaný přes nešifrované HTTP by mohl " +"cestou kdokoli přečíst." + +#: sl/SL_Menu.py:1493 +msgid "Please enter both a username and a password." +msgstr "Zadejte prosím uživatelské jméno i heslo." + +#: sl/SL_Menu.py:1494 +msgid "Wrong username or password." +msgstr "Nesprávné uživatelské jméno nebo heslo." + +#: sl/SL_Menu.py:1495 +msgid "Too many sign-in attempts on the server. Try again in a minute." +msgstr "Příliš mnoho pokusů o přihlášení. Zkuste to za minutu znovu." + +#: sl/SL_Menu.py:1496 +msgid "The server's certificate could not be verified." +msgstr "Certifikát serveru se nepodařilo ověřit." + +#: sl/SL_Menu.py:1497 +msgid "The server did not answer in time." +msgstr "Server neodpověděl včas." + +#: sl/SL_Menu.py:1498 +msgid "The server could not be reached. Check the address and your connection." +msgstr "Server není dostupný. Zkontrolujte adresu a připojení." + +#: sl/SL_Menu.py:1499 +msgid "" +"The address answered, but not like a TimeControl sync server. Check that it " +"points at the right directory." +msgstr "" +"Adresa odpověděla, ale ne jako synchronizační server TimeControl. " +"Zkontrolujte, zda ukazuje na správný adresář." + +#: sl/SL_Menu.py:1501 +msgid "The server is reachable but has not been set up yet." +msgstr "Server je dostupný, ale ještě není nastaven." + +#: sl/SL_Menu.py:1503 +msgid "This device is not signed in to the server." +msgstr "Toto zařízení není přihlášeno k serveru." + +#: sl/SL_Menu.py:1504 sl/SL_Menu.py:1887 +msgid "This device is no longer signed in. Please sign in again." +msgstr "Toto zařízení už není přihlášeno. Přihlaste se prosím znovu." + +#: sl/SL_Menu.py:1505 +msgid "The synchronisation files on this computer could not be written." +msgstr "Synchronizační soubory na tomto počítači se nepodařilo zapsat." + +#: sl/SL_Menu.py:1507 +#, python-brace-format +msgid "Sign-in failed ({code})." +msgstr "Přihlášení selhalo ({code})." + +#: sl/SL_Menu.py:1521 msgid "Change Language" msgstr "Změnit jazyk" -#: sl/SL_Menu.py:1377 +#: sl/SL_Menu.py:1539 msgid "Select Language" msgstr "Vybrat jazyk" -#: sl/SL_Menu.py:1378 sl/SL_Menu.py:1418 sl/SL_Menu.py:1459 sl/SL_Menu.py:1472 -#: sl/SL_Menu.py:1492 sl/SL_Menu.py:1526 sl/SL_Menu.py:1549 sl/SL_Menu.py:1604 +#: sl/SL_Menu.py:1540 sl/SL_Menu.py:1580 sl/SL_Menu.py:1621 sl/SL_Menu.py:1634 +#: sl/SL_Menu.py:1654 sl/SL_Menu.py:1688 sl/SL_Menu.py:1711 sl/SL_Menu.py:1766 +#: sl/SL_Menu.py:1804 msgid "Save" msgstr "Uložit" -#: sl/SL_Menu.py:1384 +#: sl/SL_Menu.py:1546 msgid "" "Language changed. Please restart the application for the changes to take " "effect." msgstr "Jazyk byl změněn. Restartujte aplikaci, aby se změny projevily." -#: sl/SL_Menu.py:1387 +#: sl/SL_Menu.py:1549 msgid "Restore Previous Version" msgstr "Obnovit předchozí verzi" -#: sl/SL_Menu.py:1390 +#: sl/SL_Menu.py:1552 msgid "The 'update' module is not available. This feature is disabled." msgstr "Modul 'aktualizace' není k dispozici. Tato funkce je zakázána." -#: sl/SL_Menu.py:1392 +#: sl/SL_Menu.py:1554 #, python-brace-format msgid "No previous version backup '{filename}' found." msgstr "Nebyla nalezena žádná záloha předchozí verze{filename}." -#: sl/SL_Menu.py:1394 +#: sl/SL_Menu.py:1556 msgid "" "This will restore the application to the previously backed-up version. The " "application will then restart. You may need to manually refresh your browser " @@ -464,35 +542,35 @@ msgstr "" "restartuje. Pokud se prohlížeč automaticky znovu nepřipojí, možná budete " "muset aktualizovat ručně." -#: sl/SL_Menu.py:1395 +#: sl/SL_Menu.py:1557 msgid "Restore and Restart" msgstr "Obnovit a restartovat" -#: sl/SL_Menu.py:1396 +#: sl/SL_Menu.py:1558 msgid "Restoring and restarting..." msgstr "Obnovování a restartování..." -#: sl/SL_Menu.py:1399 +#: sl/SL_Menu.py:1561 msgid "Restore complete. Please restart the application." msgstr "Obnovení dokončeno. Restartujte aplikaci." -#: sl/SL_Menu.py:1401 +#: sl/SL_Menu.py:1563 msgid "Change Data Storage Location" msgstr "Změnit umístění úložiště dat" -#: sl/SL_Menu.py:1403 +#: sl/SL_Menu.py:1565 msgid "Current data file" msgstr "Aktuální datový soubor" -#: sl/SL_Menu.py:1406 +#: sl/SL_Menu.py:1568 msgid "New Path for data file" msgstr "Nová cesta pro datový soubor" -#: sl/SL_Menu.py:1412 +#: sl/SL_Menu.py:1574 msgid "Move existing data to the new location" msgstr "Přesunout existující data do nového umístění" -#: sl/SL_Menu.py:1415 +#: sl/SL_Menu.py:1577 msgid "" "If unchecked, the old data file will remain, and a new empty one might be " "created at the new location on restart." @@ -500,11 +578,11 @@ msgstr "" "Pokud není zaškrtnuto, starý datový soubor zůstane a při restartu může být " "vytvořen nový prázdný v novém umístění." -#: sl/SL_Menu.py:1422 +#: sl/SL_Menu.py:1584 msgid "Please enter a new path." msgstr "Zadejte prosím novou cestu." -#: sl/SL_Menu.py:1430 +#: sl/SL_Menu.py:1592 msgid "" "Error: For security, the data file must be located within the application " "directory." @@ -512,12 +590,12 @@ msgstr "" "Chyba: Z důvodu zabezpečení musí být datový soubor umístěn v adresáři " "aplikace." -#: sl/SL_Menu.py:1434 +#: sl/SL_Menu.py:1596 #, python-brace-format msgid "Error: The directory '{dir}' does not exist." msgstr "Chyba: Adresář '{dir}' neexistuje." -#: sl/SL_Menu.py:1439 +#: sl/SL_Menu.py:1601 msgid "" "Storage location updated. Please restart the application for the changes to " "take effect." @@ -525,147 +603,147 @@ msgstr "" "Umístění úložiště aktualizováno. Restartujte aplikaci, aby se změny " "projevily." -#: sl/SL_Menu.py:1444 +#: sl/SL_Menu.py:1606 msgid "Data moved successfully." msgstr "Data byla úspěšně přesunuta." -#: sl/SL_Menu.py:1446 +#: sl/SL_Menu.py:1608 #, python-brace-format msgid "Error moving data: {error}" msgstr "Chyba při přesunu dat: {error}" -#: sl/SL_Menu.py:1453 +#: sl/SL_Menu.py:1615 msgid "Report Format" msgstr "Formát zprávy" -#: sl/SL_Menu.py:1458 +#: sl/SL_Menu.py:1620 msgid "Select Format" msgstr "Vyberte formát" -#: sl/SL_Menu.py:1463 +#: sl/SL_Menu.py:1625 msgid "Report format updated." msgstr "Formát zprávy byl aktualizován." -#: sl/SL_Menu.py:1466 +#: sl/SL_Menu.py:1628 msgid "Streamlit Port Settings" msgstr "Nastavení streamovaného portu" -#: sl/SL_Menu.py:1468 +#: sl/SL_Menu.py:1630 msgid "Current Streamlit Port" msgstr "Aktuální port streamovaného proudu" -#: sl/SL_Menu.py:1471 +#: sl/SL_Menu.py:1633 msgid "New Port" msgstr "Nový port" -#: sl/SL_Menu.py:1476 +#: sl/SL_Menu.py:1638 #, python-brace-format msgid "Port updated to {port}. Please restart Streamlit." msgstr "Port aktualizován na {port}. Restartujte prosím Streamlit." -#: sl/SL_Menu.py:1479 +#: sl/SL_Menu.py:1641 msgid "Email Settings" msgstr "Nastavení e-mailu" -#: sl/SL_Menu.py:1485 +#: sl/SL_Menu.py:1647 msgid "Enable email import" msgstr "Povolit import e-mailů" -#: sl/SL_Menu.py:1486 +#: sl/SL_Menu.py:1648 msgid "IMAP Server" msgstr "Server IMAP" -#: sl/SL_Menu.py:1487 +#: sl/SL_Menu.py:1649 msgid "Port" msgstr "Port" -#: sl/SL_Menu.py:1488 +#: sl/SL_Menu.py:1650 sl/SL_Menu.py:1896 msgid "Username" msgstr "Uživatelské jméno" -#: sl/SL_Menu.py:1489 +#: sl/SL_Menu.py:1651 sl/SL_Menu.py:1897 msgid "Password" msgstr "Heslo" -#: sl/SL_Menu.py:1490 +#: sl/SL_Menu.py:1652 msgid "Use SSL" msgstr "Použít SSL" -#: sl/SL_Menu.py:1503 +#: sl/SL_Menu.py:1665 msgid "Email settings saved." msgstr "Nastavení e-mailu bylo uloženo." -#: sl/SL_Menu.py:1506 +#: sl/SL_Menu.py:1668 msgid "Change CSS Style" msgstr "Změnit styl CSS" -#: sl/SL_Menu.py:1508 +#: sl/SL_Menu.py:1670 msgid "Current CSS file" msgstr "Aktuální soubor CSS" -#: sl/SL_Menu.py:1525 +#: sl/SL_Menu.py:1687 msgid "Select CSS File" msgstr "Vybrat soubor CSS" -#: sl/SL_Menu.py:1531 +#: sl/SL_Menu.py:1693 msgid "" "CSS style updated. Please restart the application for the changes to take " "effect." msgstr "Styl CSS byl aktualizován. Pro projevení změn restartujte aplikaci." -#: sl/SL_Menu.py:1534 +#: sl/SL_Menu.py:1696 msgid "Change View Mode" msgstr "Změnit režim zobrazení" -#: sl/SL_Menu.py:1538 +#: sl/SL_Menu.py:1700 msgid "App Window (Webview)" msgstr "Okno aplikace (Webview)" -#: sl/SL_Menu.py:1538 +#: sl/SL_Menu.py:1700 msgid "System Browser" msgstr "Systémový prohlížeč" -#: sl/SL_Menu.py:1548 +#: sl/SL_Menu.py:1710 msgid "Select View Mode" msgstr "Vyberte režim zobrazení" -#: sl/SL_Menu.py:1555 +#: sl/SL_Menu.py:1717 msgid "" "View mode updated. Please restart the application for the changes to take " "effect." msgstr "" "Režim zobrazení byl aktualizován. Pro projevení změn restartujte aplikaci." -#: sl/SL_Menu.py:1558 +#: sl/SL_Menu.py:1720 msgid "MCP Server Settings" msgstr "Nastavení serveru MCP" -#: sl/SL_Menu.py:1560 +#: sl/SL_Menu.py:1722 msgid "HTTP (Streamable HTTP)" msgstr "HTTP (Streamable HTTP)" -#: sl/SL_Menu.py:1561 +#: sl/SL_Menu.py:1723 msgid "stdio (recommended for Claude Desktop)" msgstr "stdio (doporučeno pro Claude Desktop)" -#: sl/SL_Menu.py:1575 +#: sl/SL_Menu.py:1737 msgid "Transport" msgstr "Přenos" -#: sl/SL_Menu.py:1586 +#: sl/SL_Menu.py:1748 msgid "Enable MCP server" msgstr "Povolit server MCP" -#: sl/SL_Menu.py:1589 +#: sl/SL_Menu.py:1751 msgid "" "Not used with stdio - the MCP client starts and stops the server itself." msgstr "Nepoužívá se s stdio – klient MCP server spouští a zastavuje sám." -#: sl/SL_Menu.py:1592 +#: sl/SL_Menu.py:1754 msgid "Port (HTTP only)" msgstr "Port (pouze HTTP)" -#: sl/SL_Menu.py:1599 +#: sl/SL_Menu.py:1761 msgid "" "With stdio, the app does not start the MCP server itself - the MCP client " "(e.g. Claude Desktop) launches it directly, and the port is ignored." @@ -673,7 +751,7 @@ msgstr "" "Se stdio aplikace nespouští server MCP sama – klient MCP (např. Claude " "Desktop) jej spouští přímo a port se ignoruje." -#: sl/SL_Menu.py:1610 +#: sl/SL_Menu.py:1772 msgid "" "MCP server settings saved. Please restart the application for the changes to " "take effect." @@ -681,132 +759,235 @@ msgstr "" "Nastavení serveru MCP bylo uloženo. Restartujte aplikaci, aby se změny " "projevily." -#: sl/SL_Menu.py:1624 +#: sl/SL_Menu.py:1775 +msgid "Sync Server Settings" +msgstr "Nastavení synchronizačního serveru" + +#: sl/SL_Menu.py:1777 +msgid "" +"The sync client is unavailable because the 'requests' package is missing." +msgstr "Synchronizace není dostupná, protože chybí balíček „requests“." + +#: sl/SL_Menu.py:1788 +msgid "Server address" +msgstr "Adresa serveru" + +#: sl/SL_Menu.py:1793 +msgid "Enable synchronisation" +msgstr "Povolit synchronizaci" + +#: sl/SL_Menu.py:1795 +msgid "Without this, TimeControl works entirely locally, exactly as before." +msgstr "Bez toho pracuje TimeControl zcela lokálně, přesně jako dosud." + +#: sl/SL_Menu.py:1798 +msgid "Sync every (minutes)" +msgstr "Synchronizovat každých (minut)" + +#: sl/SL_Menu.py:1802 +msgid "Synchronisation also runs whenever you switch to a different view." +msgstr "Synchronizace proběhne také při každé změně zobrazení." + +#: sl/SL_Menu.py:1816 +msgid "Sync server settings saved." +msgstr "Nastavení synchronizačního serveru bylo uloženo." + +#: sl/SL_Menu.py:1842 +#, python-brace-format +msgid "Last synchronised at {time}." +msgstr "Naposledy synchronizováno {time}." + +#: sl/SL_Menu.py:1845 +msgid "Not synchronised yet." +msgstr "Zatím nesynchronizováno." + +#: sl/SL_Menu.py:1847 +#, python-brace-format +msgid "{count} changes are waiting to be sent." +msgstr "{count} změn čeká na odeslání." + +#: sl/SL_Menu.py:1853 +#, python-brace-format +msgid "Signed in as {user}." +msgstr "Přihlášen jako {user}." + +#: sl/SL_Menu.py:1855 +#, python-brace-format +msgid "Access expires on {date}." +msgstr "Přístup vyprší {date}." + +#: sl/SL_Menu.py:1859 +msgid "Check connection" +msgstr "Zkontrolovat připojení" + +#: sl/SL_Menu.py:1861 sl/SL_Menu.py:1879 sl/SL_Menu.py:1899 +msgid "Contacting the server..." +msgstr "Kontaktuji server..." + +#: sl/SL_Menu.py:1869 +msgid "The server answered." +msgstr "Server odpověděl." + +#: sl/SL_Menu.py:1878 +msgid "Sign out" +msgstr "Odhlásit se" + +#: sl/SL_Menu.py:1881 +msgid "Signed out on this device." +msgstr "Na tomto zařízení odhlášeno." + +#: sl/SL_Menu.py:1889 +#, python-brace-format +msgid "The server could not be reached ({reason})." +msgstr "Server není dostupný ({reason})." + +#: sl/SL_Menu.py:1893 +msgid "" +"Signing in stores an access token for this device only. It is kept outside " +"the project directory and is never written to config.json." +msgstr "" +"Při přihlášení se uloží přístupový token pouze pro toto zařízení. Je uložen " +"mimo adresář projektu a nikdy se nezapisuje do config.json." + +#: sl/SL_Menu.py:1898 +msgid "Sign in" +msgstr "Přihlásit se" + +#: sl/SL_Menu.py:1906 +msgid "Signed in successfully." +msgstr "Přihlášení proběhlo úspěšně." + +#: sl/SL_Menu.py:1912 +#, python-brace-format +msgid "This device: {name} ({uid})" +msgstr "Toto zařízení: {name} ({uid})" + +#: sl/SL_Menu.py:1926 msgid "Add New Project" msgstr "Přidat nový projekt" -#: sl/SL_Menu.py:1627 +#: sl/SL_Menu.py:1929 msgid "Name of the project" msgstr "Název projektu" -#: sl/SL_Menu.py:1631 +#: sl/SL_Menu.py:1933 #, python-brace-format msgid "Project '{name}' added." msgstr "Projekt '{name}' byl přidán." -#: sl/SL_Menu.py:1635 sl/SL_Menu.py:1681 sl/SL_Menu.py:1726 sl/SL_Menu.py:1772 -#: sl/SL_Menu.py:1827 sl/SL_Menu.py:1921 sl/SL_Menu.py:1969 sl/SL_Menu.py:2019 -#: sl/SL_Menu.py:2099 sl/SL_Menu.py:2129 sl/SL_Menu.py:2159 sl/SL_Menu.py:2190 -#: sl/SL_Menu.py:2252 sl/SL_Menu.py:2292 sl/SL_Menu.py:2404 sl/SL_Menu.py:2423 -#: sl/SL_Menu.py:2584 sl/SL_Menu.py:2632 +#: sl/SL_Menu.py:1937 sl/SL_Menu.py:1983 sl/SL_Menu.py:2028 sl/SL_Menu.py:2074 +#: sl/SL_Menu.py:2129 sl/SL_Menu.py:2223 sl/SL_Menu.py:2271 sl/SL_Menu.py:2321 +#: sl/SL_Menu.py:2401 sl/SL_Menu.py:2431 sl/SL_Menu.py:2461 sl/SL_Menu.py:2492 +#: sl/SL_Menu.py:2554 sl/SL_Menu.py:2594 sl/SL_Menu.py:2706 sl/SL_Menu.py:2725 +#: sl/SL_Menu.py:2890 sl/SL_Menu.py:2938 msgid "Cancel" msgstr "Zrušit" -#: sl/SL_Menu.py:1647 sl/SL_Menu.py:1692 sl/SL_Menu.py:1737 sl/SL_Menu.py:1783 -#: sl/SL_Menu.py:1932 sl/SL_Menu.py:2063 sl/SL_Menu.py:2222 sl/SL_Menu.py:2415 +#: sl/SL_Menu.py:1949 sl/SL_Menu.py:1994 sl/SL_Menu.py:2039 sl/SL_Menu.py:2085 +#: sl/SL_Menu.py:2234 sl/SL_Menu.py:2365 sl/SL_Menu.py:2524 sl/SL_Menu.py:2717 msgid "No open projects found." msgstr "Nebyly nalezeny žádné otevřené projekty." -#: sl/SL_Menu.py:1653 sl/SL_Menu.py:1698 sl/SL_Menu.py:1743 sl/SL_Menu.py:1938 -#: sl/SL_Menu.py:2001 sl/SL_Menu.py:2036 sl/SL_Menu.py:2069 sl/SL_Menu.py:2118 -#: sl/SL_Menu.py:2148 sl/SL_Menu.py:2178 sl/SL_Menu.py:2605 sl/SL_Menu.py:2717 -#: sl/SL_Menu.py:2778 +#: sl/SL_Menu.py:1955 sl/SL_Menu.py:2000 sl/SL_Menu.py:2045 sl/SL_Menu.py:2240 +#: sl/SL_Menu.py:2303 sl/SL_Menu.py:2338 sl/SL_Menu.py:2371 sl/SL_Menu.py:2420 +#: sl/SL_Menu.py:2450 sl/SL_Menu.py:2480 sl/SL_Menu.py:2911 sl/SL_Menu.py:3023 +#: sl/SL_Menu.py:3084 msgid "Select Project" msgstr "Vybrat projekt" -#: sl/SL_Menu.py:1658 +#: sl/SL_Menu.py:1960 #, python-brace-format msgid "No open tasks to close in '{name}'." msgstr "Žádné otevřené úkoly k uzavření v '{name}'." -#: sl/SL_Menu.py:1665 sl/SL_Menu.py:1710 sl/SL_Menu.py:1755 sl/SL_Menu.py:1809 -#: sl/SL_Menu.py:1950 sl/SL_Menu.py:2079 sl/SL_Menu.py:2438 sl/SL_Menu.py:2616 -#: sl/SL_Menu.py:2748 +#: sl/SL_Menu.py:1967 sl/SL_Menu.py:2012 sl/SL_Menu.py:2057 sl/SL_Menu.py:2111 +#: sl/SL_Menu.py:2252 sl/SL_Menu.py:2381 sl/SL_Menu.py:2740 sl/SL_Menu.py:2922 +#: sl/SL_Menu.py:3054 msgid "Select Task" msgstr "Vybrat úkol" -#: sl/SL_Menu.py:1675 +#: sl/SL_Menu.py:1977 #, python-brace-format msgid "Task '{sub_name}' in '{main_name}' has been closed." msgstr "Úloha '{sub_name}' v '{main_name}' byla uzavřena." -#: sl/SL_Menu.py:1679 sl/SL_Menu.py:1724 sl/SL_Menu.py:1770 +#: sl/SL_Menu.py:1981 sl/SL_Menu.py:2026 sl/SL_Menu.py:2072 msgid "Error: Main project or task not found." msgstr "Chyba: Hlavní projekt nebo úkol nebyl nalezen." -#: sl/SL_Menu.py:1703 +#: sl/SL_Menu.py:2005 #, python-brace-format msgid "No closed tasks to reopen in '{name}'." msgstr "Žádné uzavřené úlohy k opětovnému otevření v '{name}'." -#: sl/SL_Menu.py:1720 +#: sl/SL_Menu.py:2022 #, python-brace-format msgid "Task '{sub_name}' in '{main_name}' has been reopened." msgstr "Úloha '{sub_name}' v '{main_name}' byla znovu otevřena." -#: sl/SL_Menu.py:1748 +#: sl/SL_Menu.py:2050 #, python-brace-format msgid "No open tasks to delete in '{name}'." msgstr "Žádné otevřené úkoly k odstranění v '{name}'." -#: sl/SL_Menu.py:1759 +#: sl/SL_Menu.py:2061 msgid "This action cannot be undone." msgstr "Tuto akci nelze vrátit zpět." -#: sl/SL_Menu.py:1766 +#: sl/SL_Menu.py:2068 #, python-brace-format msgid "Task '{sub_name}' deleted from '{main_name}'." msgstr "Úkol '{sub_name}' byl smazán z '{main_name}'." -#: sl/SL_Menu.py:1789 +#: sl/SL_Menu.py:2091 msgid "Select Source Project" msgstr "Vybrat zdrojový projekt" -#: sl/SL_Menu.py:1794 +#: sl/SL_Menu.py:2096 #, python-brace-format msgid "No tasks found in '{name}'." msgstr "V '{name}' nebyly nalezeny žádné úkoly." -#: sl/SL_Menu.py:1802 +#: sl/SL_Menu.py:2104 msgid "No other projects available to move to." msgstr "Žádné další projekty k přesunu." -#: sl/SL_Menu.py:1813 sl/SL_Menu.py:2238 +#: sl/SL_Menu.py:2115 sl/SL_Menu.py:2540 msgid "Select Target Project" msgstr "Vybrat cílový projekt" -#: sl/SL_Menu.py:1821 +#: sl/SL_Menu.py:2123 #, python-brace-format msgid "Task '{sub}' moved from '{src}' to '{dst}'." msgstr "Úloha '{sub}' přesunuta z '{src}' do '{dst}'." -#: sl/SL_Menu.py:1825 +#: sl/SL_Menu.py:2127 msgid "Error: Could not move task." msgstr "Chyba: Úkol nelze přesunout." -#: sl/SL_Menu.py:1836 sl/SL_Menu.py:2199 +#: sl/SL_Menu.py:2138 sl/SL_Menu.py:2501 msgid "Weeks of inactivity" msgstr "Týdny neaktivity" -#: sl/SL_Menu.py:1841 +#: sl/SL_Menu.py:2143 #, python-brace-format msgid "Inactive Tasks (> {weeks} weeks):" msgstr "Neaktivní úkoly (> {weeks} týdnů):" -#: sl/SL_Menu.py:1846 sl/SL_Menu.py:2207 +#: sl/SL_Menu.py:2148 sl/SL_Menu.py:2509 msgid "Last Activity" msgstr "Poslední aktivita" -#: sl/SL_Menu.py:1852 +#: sl/SL_Menu.py:2154 #, python-brace-format msgid "No tasks found inactive for more than {weeks} weeks." msgstr "Žádné úkoly nebyly nalezeny neaktivní déle než {weeks} týdnů." -#: sl/SL_Menu.py:1877 sl/SL_Menu.py:1900 +#: sl/SL_Menu.py:2179 sl/SL_Menu.py:2202 msgid "No closed tasks found." msgstr "Nebyly nalezeny žádné uzavřené úkoly." -#: sl/SL_Menu.py:1905 +#: sl/SL_Menu.py:2207 #, python-brace-format msgid "" "Are you sure you want to delete {count} closed tasks? This action cannot be " @@ -814,25 +995,25 @@ msgid "" msgstr "" "Opravdu chcete smazat{count}uzavřené úkoly? Tuto akci nelze vrátit zpět." -#: sl/SL_Menu.py:1907 +#: sl/SL_Menu.py:2209 msgid "Show projects to delete" msgstr "Zobrazit projekty k odstranění" -#: sl/SL_Menu.py:1911 +#: sl/SL_Menu.py:2213 msgid "Delete All" msgstr "Smazat vše" -#: sl/SL_Menu.py:1917 +#: sl/SL_Menu.py:2219 #, python-brace-format msgid "Successfully deleted {count} tasks." msgstr "Úspěšně odstraněno {count} úkolů." -#: sl/SL_Menu.py:1943 +#: sl/SL_Menu.py:2245 #, python-brace-format msgid "No open tasks to promote in '{name}'." msgstr "Žádné otevřené úkoly k povýšení v '{name}'." -#: sl/SL_Menu.py:1954 +#: sl/SL_Menu.py:2256 msgid "" "This will create a new Project with the task's name and move all time " "entries to a 'General' task within it." @@ -840,96 +1021,96 @@ msgstr "" "Tím vytvoříte nový projekt s názvem úkolu a přesunete všechny časové záznamy " "do úkolu „Obecné“ v něm." -#: sl/SL_Menu.py:1956 +#: sl/SL_Menu.py:2258 msgid "Promote to Project" msgstr "Povýšit na projekt" -#: sl/SL_Menu.py:1980 sl/SL_Menu.py:2044 +#: sl/SL_Menu.py:2282 sl/SL_Menu.py:2346 msgid "closed" msgstr "uzavřeno" -#: sl/SL_Menu.py:1983 sl/SL_Menu.py:2030 sl/SL_Menu.py:2170 sl/SL_Menu.py:2711 -#: sl/SL_Menu.py:2770 +#: sl/SL_Menu.py:2285 sl/SL_Menu.py:2332 sl/SL_Menu.py:2472 sl/SL_Menu.py:3017 +#: sl/SL_Menu.py:3076 msgid "No projects found." msgstr "Nebyly nalezeny žádné projekty." -#: sl/SL_Menu.py:1995 +#: sl/SL_Menu.py:2297 msgid "No open projects to rename." msgstr "Žádné otevřené projekty k přejmenování." -#: sl/SL_Menu.py:2004 sl/SL_Menu.py:2084 +#: sl/SL_Menu.py:2306 sl/SL_Menu.py:2386 msgid "New Name" msgstr "Nové jméno" -#: sl/SL_Menu.py:2005 sl/SL_Menu.py:2085 +#: sl/SL_Menu.py:2307 sl/SL_Menu.py:2387 msgid "Rename" msgstr "Přejmenovat" -#: sl/SL_Menu.py:2009 sl/SL_Menu.py:2089 +#: sl/SL_Menu.py:2311 sl/SL_Menu.py:2391 msgid "Please enter a new name." msgstr "Zadejte prosím nový název." -#: sl/SL_Menu.py:2011 sl/SL_Menu.py:2091 +#: sl/SL_Menu.py:2313 sl/SL_Menu.py:2393 msgid "New name is the same as the old name." msgstr "Nový název je stejný jako starý název." -#: sl/SL_Menu.py:2013 +#: sl/SL_Menu.py:2315 #, python-brace-format msgid "Project '{old_name}' successfully renamed to '{new_name}'." msgstr "Projekt '{old_name}' byl úspěšně přejmenován na '{new_name}'." -#: sl/SL_Menu.py:2017 +#: sl/SL_Menu.py:2319 #, python-brace-format msgid "Error: Could not rename. The new name '{new_name}' might already exist." msgstr "Chyba: Nelze přejmenovat. Nový název '{new_name}' již možná existuje." -#: sl/SL_Menu.py:2041 +#: sl/SL_Menu.py:2343 #, python-brace-format msgid "Tasks for '{name}':" msgstr "Úkoly pro '{name}':" -#: sl/SL_Menu.py:2050 sl/SL_Menu.py:2742 +#: sl/SL_Menu.py:2352 sl/SL_Menu.py:3048 #, python-brace-format msgid "No tasks found for '{name}'." msgstr "Pro '{name}' nebyly nalezeny žádné úkoly." -#: sl/SL_Menu.py:2074 +#: sl/SL_Menu.py:2376 #, python-brace-format msgid "No open tasks to rename in '{name}'." msgstr "Žádné otevřené úkoly k přejmenování v '{name}'." -#: sl/SL_Menu.py:2093 +#: sl/SL_Menu.py:2395 #, python-brace-format msgid "Task '{old_name}' renamed to '{new_name}'." msgstr "Úkol '{old_name}' byl přejmenován na '{new_name}'." -#: sl/SL_Menu.py:2097 +#: sl/SL_Menu.py:2399 msgid "Error: Could not rename. The new name might already exist." msgstr "Chyba: Nelze přejmenovat. Nový název již možná existuje." -#: sl/SL_Menu.py:2110 +#: sl/SL_Menu.py:2412 msgid "No open projects to close." msgstr "Žádné otevřené projekty k uzavření." -#: sl/SL_Menu.py:2123 +#: sl/SL_Menu.py:2425 #, python-brace-format msgid "Project '{name}' has been closed." msgstr "Projekt '{name}' byl uzavřen." -#: sl/SL_Menu.py:2127 sl/SL_Menu.py:2157 sl/SL_Menu.py:2188 +#: sl/SL_Menu.py:2429 sl/SL_Menu.py:2459 sl/SL_Menu.py:2490 msgid "Error: Project not found." msgstr "Chyba: Projekt nenalezen." -#: sl/SL_Menu.py:2140 +#: sl/SL_Menu.py:2442 msgid "No closed projects to reopen." msgstr "Žádné uzavřené projekty k opětovnému otevření." -#: sl/SL_Menu.py:2153 +#: sl/SL_Menu.py:2455 #, python-brace-format msgid "Project '{name}' has been reopened." msgstr "Projekt '{name}' byl znovu otevřen." -#: sl/SL_Menu.py:2179 +#: sl/SL_Menu.py:2481 msgid "" "This action cannot be undone. All associated tasks and time entries will be " "deleted." @@ -937,246 +1118,255 @@ msgstr "" "Tuto akci nelze vrátit zpět. Všechny související úkoly a časové záznamy " "budou smazány." -#: sl/SL_Menu.py:2184 +#: sl/SL_Menu.py:2486 #, python-brace-format msgid "Project '{name}' has been deleted." msgstr "Projekt '{name}' byl smazán." -#: sl/SL_Menu.py:2204 +#: sl/SL_Menu.py:2506 #, python-brace-format msgid "Inactive Projects (> {weeks} weeks):" msgstr "Neaktivní projekty (> {weeks} týdnů):" -#: sl/SL_Menu.py:2209 +#: sl/SL_Menu.py:2511 #, python-brace-format msgid "No projects found inactive for more than {weeks} weeks." msgstr "" "Žádné projekty nebyly nalezeny jako neaktivní po dobu delší než{weeks}týdnů." -#: sl/SL_Menu.py:2218 sl/SL_Menu.py:2241 +#: sl/SL_Menu.py:2520 sl/SL_Menu.py:2543 msgid "Demote Project" msgstr "Degradovat projekt" -#: sl/SL_Menu.py:2230 +#: sl/SL_Menu.py:2532 msgid "Select Project to Demote" msgstr "Vyberte projekt ke snížení úrovně" -#: sl/SL_Menu.py:2236 +#: sl/SL_Menu.py:2538 msgid "No other projects available to demote into." msgstr "" "Nejsou k dispozici žádné další projekty, na které by bylo možné snížit " "úroveň." -#: sl/SL_Menu.py:2239 +#: sl/SL_Menu.py:2541 #, python-brace-format msgid "This will convert '{src}' into a task of '{dst}'." msgstr "Toto převede '{src}' na úlohu '{dst}'." -#: sl/SL_Menu.py:2264 +#: sl/SL_Menu.py:2566 msgid "Projects with only closed or no tasks:" msgstr "Projekty pouze s uzavřenými nebo žádnými úkoly:" -#: sl/SL_Menu.py:2268 +#: sl/SL_Menu.py:2570 msgid "No completed projects found." msgstr "Nebyly nalezeny žádné dokončené projekty." -#: sl/SL_Menu.py:2277 sl/SL_Menu.py:2411 sl/SL_Menu.py:2707 +#: sl/SL_Menu.py:2579 sl/SL_Menu.py:2713 sl/SL_Menu.py:3013 msgid "Step 1: Select Project" msgstr "Krok 1: Vyberte projekt" -#: sl/SL_Menu.py:2281 sl/SL_Menu.py:2599 +#: sl/SL_Menu.py:2583 sl/SL_Menu.py:2905 msgid "No open projects found. Please add one first." msgstr "Nebyly nalezeny žádné otevřené projekty. Nejprve přidejte jednu." -#: sl/SL_Menu.py:2286 sl/SL_Menu.py:2418 sl/SL_Menu.py:2647 +#: sl/SL_Menu.py:2588 sl/SL_Menu.py:2720 sl/SL_Menu.py:2953 msgid "Project" msgstr "Project" -#: sl/SL_Menu.py:2288 sl/SL_Menu.py:2419 sl/SL_Menu.py:2443 sl/SL_Menu.py:2719 +#: sl/SL_Menu.py:2590 sl/SL_Menu.py:2721 sl/SL_Menu.py:2745 sl/SL_Menu.py:3025 msgid "Next" msgstr "Další" -#: sl/SL_Menu.py:2303 sl/SL_Menu.py:2733 +#: sl/SL_Menu.py:2605 sl/SL_Menu.py:3039 msgid "No project selected. Please start again." msgstr "Nebyl vybrán žádný projekt. Začněte prosím znovu." -#: sl/SL_Menu.py:2308 +#: sl/SL_Menu.py:2610 msgid "To Project:" msgstr "To Project:" -#: sl/SL_Menu.py:2329 +#: sl/SL_Menu.py:2631 msgid "Name of the new task" msgstr "Name of the new task" -#: sl/SL_Menu.py:2335 +#: sl/SL_Menu.py:2637 msgid "Due date" msgstr "Datum splatnosti" -#: sl/SL_Menu.py:2341 sl/SL_Menu.py:2513 +#: sl/SL_Menu.py:2643 sl/SL_Menu.py:2815 msgid "Recurring" msgstr "Opakující se" -#: sl/SL_Menu.py:2357 sl/SL_Menu.py:2528 +#: sl/SL_Menu.py:2659 sl/SL_Menu.py:2830 msgid "daily" msgstr "denně" -#: sl/SL_Menu.py:2357 sl/SL_Menu.py:2528 +#: sl/SL_Menu.py:2659 sl/SL_Menu.py:2830 msgid "monthly" msgstr "měsíčně" -#: sl/SL_Menu.py:2357 sl/SL_Menu.py:2528 +#: sl/SL_Menu.py:2659 sl/SL_Menu.py:2830 msgid "on all business days" msgstr "ve všech pracovních dnech" -#: sl/SL_Menu.py:2357 sl/SL_Menu.py:2528 +#: sl/SL_Menu.py:2659 sl/SL_Menu.py:2830 msgid "userdefined" msgstr "uživatelsky definované" -#: sl/SL_Menu.py:2357 sl/SL_Menu.py:2528 +#: sl/SL_Menu.py:2659 sl/SL_Menu.py:2830 msgid "weekly" msgstr "týdně" -#: sl/SL_Menu.py:2361 sl/SL_Menu.py:2537 +#: sl/SL_Menu.py:2663 sl/SL_Menu.py:2839 msgid "Frequency" msgstr "Frekvence" -#: sl/SL_Menu.py:2365 sl/SL_Menu.py:2540 +#: sl/SL_Menu.py:2667 sl/SL_Menu.py:2842 msgid "Days" msgstr "Dny" -#: sl/SL_Menu.py:2369 sl/SL_Menu.py:2542 +#: sl/SL_Menu.py:2671 sl/SL_Menu.py:2844 msgid "Edit" msgstr "Upravit" -#: sl/SL_Menu.py:2369 sl/SL_Menu.py:2542 +#: sl/SL_Menu.py:2671 sl/SL_Menu.py:2844 msgid "Preview" msgstr "Náhled" -#: sl/SL_Menu.py:2374 sl/SL_Menu.py:2547 +#: sl/SL_Menu.py:2676 sl/SL_Menu.py:2849 msgid "No notes provided." msgstr "Nebyly zadány žádné poznámky." -#: sl/SL_Menu.py:2379 sl/SL_Menu.py:2552 +#: sl/SL_Menu.py:2681 sl/SL_Menu.py:2854 msgid "A due date is required for recurring tasks." msgstr "Pro opakující se úkoly je vyžadováno datum dokončení." -#: sl/SL_Menu.py:2387 +#: sl/SL_Menu.py:2689 msgid "Please enter a name." msgstr "Zadejte prosím jméno." -#: sl/SL_Menu.py:2399 +#: sl/SL_Menu.py:2701 #, python-brace-format msgid "Task '{sub_name}' added to '{main_name}'." msgstr "Úkol '{sub_name}' byl přidán do '{main_name}'." -#: sl/SL_Menu.py:2431 sl/SL_Menu.py:2738 +#: sl/SL_Menu.py:2733 sl/SL_Menu.py:3044 msgid "Step 2: Select Task from" msgstr "Krok 2: Vyberte úkol z" -#: sl/SL_Menu.py:2434 +#: sl/SL_Menu.py:2736 msgid "No open tasks found." msgstr "Nebyly nalezeny žádné otevřené úkoly." -#: sl/SL_Menu.py:2462 +#: sl/SL_Menu.py:2764 msgid "Task not found." msgstr "Úkol nenalezen." -#: sl/SL_Menu.py:2556 +#: sl/SL_Menu.py:2858 msgid "Save Changes" msgstr "Uložit změny" -#: sl/SL_Menu.py:2575 +#: sl/SL_Menu.py:2881 msgid "Task updated successfully." msgstr "Úloha byla úspěšně aktualizována." -#: sl/SL_Menu.py:2581 +#: sl/SL_Menu.py:2887 msgid "Error: Could not update task." msgstr "Chyba: Úlohu nelze aktualizovat." -#: sl/SL_Menu.py:2594 +#: sl/SL_Menu.py:2900 msgid "Start Work on Task" msgstr "Zahájit práci na úloze" -#: sl/SL_Menu.py:2609 +#: sl/SL_Menu.py:2915 #, python-brace-format msgid "No open tasks to start work on in '{name}'." msgstr "" "Žádné otevřené úkoly, na kterých by bylo možné začít pracovat v '{name}'." -#: sl/SL_Menu.py:2620 +#: sl/SL_Menu.py:2926 msgid "Start Work" msgstr "Začít práci" -#: sl/SL_Menu.py:2626 +#: sl/SL_Menu.py:2932 #, python-brace-format msgid "Work started on '{task_name}' in project '{main_name}'." msgstr "Zahájena práce na '{task_name}' v projektu '{main_name}'." -#: sl/SL_Menu.py:2630 +#: sl/SL_Menu.py:2936 msgid "Error starting work." msgstr "Chyba při zahájení práce." -#: sl/SL_Menu.py:2648 +#: sl/SL_Menu.py:2954 msgid "Task" msgstr "Úloha" -#: sl/SL_Menu.py:2649 +#: sl/SL_Menu.py:2955 msgid "Started at" msgstr "Zahájeno v" -#: sl/SL_Menu.py:2650 tt/TimeTracker.py:1454 +#: sl/SL_Menu.py:2956 tt/TimeTracker.py:1912 msgid "Duration" msgstr "Trvání" -#: sl/SL_Menu.py:2664 sl/SL_Menu.py:2796 +#: sl/SL_Menu.py:2970 sl/SL_Menu.py:3102 msgid "Select Date" msgstr "Vyberte datum" -#: sl/SL_Menu.py:2665 sl/SL_Menu.py:2689 sl/SL_Menu.py:2753 sl/SL_Menu.py:2779 -#: sl/SL_Menu.py:2797 +#: sl/SL_Menu.py:2971 sl/SL_Menu.py:2995 sl/SL_Menu.py:3059 sl/SL_Menu.py:3085 +#: sl/SL_Menu.py:3103 msgid "Generate Report" msgstr "Vytvořit zprávu" -#: sl/SL_Menu.py:2685 +#: sl/SL_Menu.py:2991 msgid "Start Date" msgstr "Datum zahájení" -#: sl/SL_Menu.py:2687 +#: sl/SL_Menu.py:2993 msgid "End Date" msgstr "Datum ukončení" -#: sl/SL_Menu.py:2693 +#: sl/SL_Menu.py:2999 msgid "Error: The start date cannot be after the end date." msgstr "Chyba: Počáteční datum nemůže být po koncovém datu." -#: sl/SL_Menu.py:2812 +#: sl/SL_Menu.py:3118 msgid "Report Result" msgstr "Výsledek zprávy" -#: sl/SL_Menu.py:2841 +#: sl/SL_Menu.py:3147 msgid "Export Report" msgstr "Export zprávy" -#: tt/TimeTracker.py:91 +#: tt/TimeTracker.py:191 #, python-brace-format msgid "Warning: Could not read {file}. Error: {error}" msgstr "Varování: Nelze přečíst soubor {file}. Chyba: {error}" -#: tt/TimeTracker.py:107 +#: tt/TimeTracker.py:207 msgid "Some required packages are missing. Attempting to install them..." msgstr "Některé požadované balíčky chybí. Pokouším se je nainstalovat..." -#: tt/TimeTracker.py:110 +#: tt/TimeTracker.py:210 #, python-brace-format msgid "Installing {package}..." msgstr "Instaluji {package}..." -#: tt/TimeTracker.py:114 +#: tt/TimeTracker.py:217 #, python-brace-format msgid "Failed to install {package}. Continuing without it." msgstr "Nepodařilo se nainstalovat {package}. Pokračování bez toho." -#: tt/TimeTracker.py:118 +#: tt/TimeTracker.py:220 +#, python-brace-format +msgid "" +"Timed out installing {package} (no internet connection?). Continuing without " +"it." +msgstr "" +"Vypršel časový limit při instalaci {package} (chybí připojení k internetu?). " +"Pokračuji bez něj." + +#: tt/TimeTracker.py:224 msgid "" "\n" "Dependencies installed successfully." @@ -1184,11 +1374,11 @@ msgstr "" "\n" "Závislosti byly úspěšně nainstalovány." -#: tt/TimeTracker.py:119 +#: tt/TimeTracker.py:225 msgid "Please restart the application for the changes to take effect." msgstr "Prosím, restartujte aplikaci, aby se změny projevily." -#: tt/TimeTracker.py:122 +#: tt/TimeTracker.py:228 #, python-brace-format msgid "" "\n" @@ -1197,21 +1387,21 @@ msgstr "" "\n" "Upozornění: Některé závislosti nelze nainstalovat:{packages}" -#: tt/TimeTracker.py:124 +#: tt/TimeTracker.py:230 #, python-brace-format msgid "An unexpected error occurred during dependency check: {error}" msgstr "Během kontroly závislostí došlo k neočekávané chybě: {error}" -#: tt/TimeTracker.py:251 +#: tt/TimeTracker.py:452 msgid "Info: Report content has been copied to the clipboard." msgstr "Info: Obsah zprávy byl zkopírován do schránky." -#: tt/TimeTracker.py:253 +#: tt/TimeTracker.py:454 #, python-brace-format msgid "Warning: Could not copy to clipboard. Error: {error}" msgstr "Varování: Nelze kopírovat do schránky. Chyba: {error}" -#: tt/TimeTracker.py:255 +#: tt/TimeTracker.py:456 msgid "" "Warning: Could not copy to clipboard. Please install 'pyperclip' (`pip " "install pyperclip`)." @@ -1219,56 +1409,56 @@ msgstr "" "Varování: Nelze kopírovat do schránky. Prosím, nainstalujte 'pyperclip' " "(`pip install pyperclip`)." -#: tt/TimeTracker.py:275 +#: tt/TimeTracker.py:476 #, python-brace-format msgid "{hours} hours ({dlp} DLP)" msgstr "{hours} hodin ({dlp} DLP)" -#: tt/TimeTracker.py:877 tt/TimeTracker.py:917 +#: tt/TimeTracker.py:1218 tt/TimeTracker.py:1263 #, python-brace-format msgid "Source main project '{name}' not found." msgstr "Zdrojový hlavní projekt '{name}' nebyl nalezen." -#: tt/TimeTracker.py:879 +#: tt/TimeTracker.py:1220 #, python-brace-format msgid "Destination main project '{name}' not found." msgstr "Cílový hlavní projekt '{name}' nebyl nalezen." -#: tt/TimeTracker.py:891 +#: tt/TimeTracker.py:1237 #, python-brace-format msgid "Task '{task_name}' moved successfully." msgstr "Úloha '{task_name}' byla úspěšně přesunuta." -#: tt/TimeTracker.py:892 tt/TimeTracker.py:927 tt/TimeTracker.py:1416 +#: tt/TimeTracker.py:1238 tt/TimeTracker.py:1273 tt/TimeTracker.py:1874 #, python-brace-format msgid "Task '{task_name}' not found in '{main_name}'." msgstr "Úloha '{task_name}' nebyla nalezena v '{main_name}'." -#: tt/TimeTracker.py:911 +#: tt/TimeTracker.py:1257 #, python-brace-format msgid "A main project named '{name}' already exists." msgstr "Hlavní projekt s názvem '{name}' již existuje." -#: tt/TimeTracker.py:936 +#: tt/TimeTracker.py:1305 msgid "General" msgstr "Obecné" -#: tt/TimeTracker.py:940 +#: tt/TimeTracker.py:1343 #, python-brace-format msgid "Task '{task_name}' was promoted to a new main project." msgstr "Úloha '{task_name}' byla povýšena na nový hlavní projekt." -#: tt/TimeTracker.py:967 +#: tt/TimeTracker.py:1370 #, python-brace-format msgid "Main project to demote '{name}' not found." msgstr "Hlavní projekt ke snížení úrovně '{name}' nebyl nalezen." -#: tt/TimeTracker.py:969 +#: tt/TimeTracker.py:1372 #, python-brace-format msgid "New parent main project '{name}' not found." msgstr "Nový nadřazený hlavní projekt '{name}' nebyl nalezen." -#: tt/TimeTracker.py:994 +#: tt/TimeTracker.py:1427 #, python-brace-format msgid "" "Main project '{demoted_name}' was demoted to a sub-project under " @@ -1277,42 +1467,42 @@ msgstr "" "Hlavní projekt '{demoted_name}' byl degradován na dílčí projekt pod " "'{parent_name}'." -#: tt/TimeTracker.py:1076 +#: tt/TimeTracker.py:1521 msgid "Email import is not enabled." msgstr "Import e-mailů není povolen." -#: tt/TimeTracker.py:1085 +#: tt/TimeTracker.py:1530 msgid "Email settings are incomplete." msgstr "Nastavení e-mailu jsou neúplná." -#: tt/TimeTracker.py:1098 +#: tt/TimeTracker.py:1543 msgid "Error searching emails." msgstr "Chyba při vyhledávání e-mailů." -#: tt/TimeTracker.py:1113 +#: tt/TimeTracker.py:1558 msgid "No Subject" msgstr "Bez předmětu" -#: tt/TimeTracker.py:1173 +#: tt/TimeTracker.py:1631 msgid "Unknown Task" msgstr "Neznámý úkol" -#: tt/TimeTracker.py:1373 +#: tt/TimeTracker.py:1831 #, python-brace-format msgid "- {name}: {hours} hours" msgstr "- {name}: {hours} hodin" -#: tt/TimeTracker.py:1381 +#: tt/TimeTracker.py:1839 #, python-brace-format msgid "## {name} ({hours} hours)\n" msgstr "## {name} ({hours} hodin)\n" -#: tt/TimeTracker.py:1390 +#: tt/TimeTracker.py:1848 #, python-brace-format msgid "# Daily Time Report: {date}\n" msgstr "# Denní zpráva o čase: {date}\n" -#: tt/TimeTracker.py:1391 +#: tt/TimeTracker.py:1849 #, python-brace-format msgid "" "\n" @@ -1321,104 +1511,104 @@ msgstr "" "\n" "**Celkový denní čas: {hours} hodin**" -#: tt/TimeTracker.py:1395 tt/TimeTracker.py:1708 +#: tt/TimeTracker.py:1853 tt/TimeTracker.py:2166 #, python-brace-format msgid "No time tracked for {date}." msgstr "Pro den {date} nebyl zaznamenán žádný čas." -#: tt/TimeTracker.py:1412 tt/TimeTracker.py:1507 +#: tt/TimeTracker.py:1870 tt/TimeTracker.py:1965 #, python-brace-format msgid "Main project '{name}' not found." msgstr "Hlavní projekt '{name}' nebyl nalezen." -#: tt/TimeTracker.py:1420 +#: tt/TimeTracker.py:1878 #, python-brace-format msgid "No time entries found for task '{task_name}'." msgstr "Pro úkol '{task_name}' nebyly nalezeny žádné časové záznamy." -#: tt/TimeTracker.py:1453 tt/TimeTracker.py:1683 +#: tt/TimeTracker.py:1911 tt/TimeTracker.py:2141 msgid "now" msgstr "nyní" -#: tt/TimeTracker.py:1458 +#: tt/TimeTracker.py:1916 #, python-brace-format msgid "# Detailed Report for Task: {name}" msgstr "# Podrobná zpráva o úkolu: {name}" -#: tt/TimeTracker.py:1459 +#: tt/TimeTracker.py:1917 #, python-brace-format msgid "Part of Main Project: {name}" msgstr "Část hlavního projektu: {name}" -#: tt/TimeTracker.py:1462 +#: tt/TimeTracker.py:1920 msgid "Active (currently running)" msgstr "Aktivní (právě běží)" -#: tt/TimeTracker.py:1462 tt/TimeTracker.py:1559 +#: tt/TimeTracker.py:1920 tt/TimeTracker.py:2017 msgid "Inactive" msgstr "Neaktivní" -#: tt/TimeTracker.py:1463 tt/TimeTracker.py:1560 +#: tt/TimeTracker.py:1921 tt/TimeTracker.py:2018 msgid "Status" msgstr "Stav" -#: tt/TimeTracker.py:1465 tt/TimeTracker.py:1562 +#: tt/TimeTracker.py:1923 tt/TimeTracker.py:2020 msgid "First entry" msgstr "První záznam" -#: tt/TimeTracker.py:1467 tt/TimeTracker.py:1564 +#: tt/TimeTracker.py:1925 tt/TimeTracker.py:2022 msgid "Last activity" msgstr "Poslední aktivita" -#: tt/TimeTracker.py:1469 tt/TimeTracker.py:1566 +#: tt/TimeTracker.py:1927 tt/TimeTracker.py:2024 msgid "Total recorded time" msgstr "Celkový zaznamenaný čas" -#: tt/TimeTracker.py:1470 tt/TimeTracker.py:1568 +#: tt/TimeTracker.py:1928 tt/TimeTracker.py:2026 msgid "Total work sessions" msgstr "Celkový počet pracovních relací" -#: tt/TimeTracker.py:1474 tt/TimeTracker.py:1572 +#: tt/TimeTracker.py:1932 tt/TimeTracker.py:2030 msgid "Average session duration" msgstr "Průměrná délka relace" -#: tt/TimeTracker.py:1477 tt/TimeTracker.py:1575 +#: tt/TimeTracker.py:1935 tt/TimeTracker.py:2033 msgid "Weekday Distribution" msgstr "Rozložení v týdnu" -#: tt/TimeTracker.py:1487 +#: tt/TimeTracker.py:1945 msgid "Daily Breakdown" msgstr "Denní rozpis" -#: tt/TimeTracker.py:1556 +#: tt/TimeTracker.py:2014 #, python-brace-format msgid "# Detailed Report for Main Project: {name}" msgstr "# Podrobná zpráva pro hlavní projekt: {name}" -#: tt/TimeTracker.py:1559 +#: tt/TimeTracker.py:2017 #, python-brace-format msgid "Active (working on '{task_name}')" msgstr "Aktivní (pracuje se na '{task_name}')" -#: tt/TimeTracker.py:1567 +#: tt/TimeTracker.py:2025 msgid "Number of tasks" msgstr "Počet úkolů" -#: tt/TimeTracker.py:1586 +#: tt/TimeTracker.py:2044 msgid "Task Breakdown" msgstr "Rozpis úkolů" -#: tt/TimeTracker.py:1595 +#: tt/TimeTracker.py:2053 #, python-brace-format msgid "{num_sessions} sessions" msgstr "{num_sessions} relací" -#: tt/TimeTracker.py:1648 +#: tt/TimeTracker.py:2106 #, python-brace-format msgid "# Time Report: {start_date} to {end_date}\n" msgstr "# Zpráva o čase: od {start_date} do {end_date}\n" -#: tt/TimeTracker.py:1649 +#: tt/TimeTracker.py:2107 #, python-brace-format msgid "" "\n" @@ -1427,17 +1617,17 @@ msgstr "" "\n" "**Celkový čas v období: {total_time}**" -#: tt/TimeTracker.py:1653 +#: tt/TimeTracker.py:2111 #, python-brace-format msgid "No time tracked between {start_date} and {end_date}." msgstr "Mezi {start_date} a {end_date} nebyl zaznamenán žádný čas." -#: tt/TimeTracker.py:1669 +#: tt/TimeTracker.py:2127 #, python-brace-format msgid "# Detailed Daily Report: {date}" msgstr "# Podrobná denní zpráva: {date}" -#: update.py:35 +#: update.py:101 msgid "" "Warning: Update check skipped. 'github_repo' not found in config.json or " "file is invalid." @@ -1445,81 +1635,94 @@ msgstr "" "Varování: Kontrola aktualizací přeskočena. 'github_repo' nebylo nalezeno v " "config.json nebo je soubor neplatný." -#: update.py:55 +#: update.py:121 msgid "Error: Download URL for the new version not found." msgstr "Chyba: Adresa URL pro stažení nové verze nebyla nalezena." -#: update.py:59 +#: update.py:125 +msgid "Warning: Update check timed out (no internet connection?). Skipping." +msgstr "" +"Varování: Kontrola aktualizací vypršela (chybí připojení k internetu?). " +"Přeskakuji." + +#: update.py:127 #, python-brace-format msgid "Error checking for updates: {error}" msgstr "Chyba při kontrole aktualizací: {error}" -#: update.py:61 +#: update.py:129 #, python-brace-format msgid "An unexpected error occurred while checking for updates: {error}" msgstr "Při kontrole aktualizací došlo k neočekávané chybě: {error}" -#: update.py:73 +#: update.py:141 msgid "Downloading update..." msgstr "Stahuji aktualizaci..." -#: update.py:79 +#: update.py:147 msgid "Download complete. The update will be installed on the next start." msgstr "" "Stažení dokončeno. Aktualizace bude nainstalována při příštím spuštění." -#: update.py:82 +#: update.py:150 +msgid "" +"Error: Connecting to the update server timed out (no internet connection?)." +msgstr "" +"Chyba: Vypršel čas při připojování k aktualizačnímu serveru (chybí připojení " +"k internetu?)." + +#: update.py:155 #, python-brace-format msgid "Error downloading the update: {error}" msgstr "Chyba při stahování aktualizace: {error}" -#: update.py:98 +#: update.py:171 msgid "Restarting application to apply the update..." msgstr "Restartování aplikace za účelem použití aktualizace..." -#: update.py:122 +#: update.py:195 msgid "Creating backup of current version before update..." msgstr "Vytváření zálohy aktuální verze před aktualizací..." -#: update.py:132 +#: update.py:205 #, python-brace-format msgid "Backup created successfully as {filename}." msgstr "Záloha byla úspěšně vytvořena jako {filename}." -#: update.py:134 +#: update.py:207 #, python-brace-format msgid "Warning: Could not create backup. Error: {error}" msgstr "Varování: Nelze vytvořit zálohu. Chyba: {error}" -#: update.py:136 +#: update.py:209 msgid "Installing update..." msgstr "Instaluji aktualizaci..." -#: update.py:156 +#: update.py:229 #, python-brace-format msgid "Skipping protected file: {filename}. It will not be overwritten." msgstr "Přeskakuji chráněný soubor: {filename}. Nebude přepsán." -#: update.py:165 +#: update.py:238 msgid "Update installed successfully." msgstr "Aktualizace byla úspěšně nainstalována." -#: update.py:167 +#: update.py:240 #, python-brace-format msgid "Error during update installation: {error}" msgstr "Chyba během instalace aktualizace: {error}" -#: update.py:182 +#: update.py:255 #, python-brace-format msgid "Error: No previous version backup '{filename}' found." msgstr "Chyba: Nebyla nalezena žádná záloha předchozí verze '{filename}'." -#: update.py:185 +#: update.py:258 #, python-brace-format msgid "Restoring previous version from '{filename}'..." msgstr "Obnovování předchozí verze z '{filename}'..." -#: update.py:206 +#: update.py:279 #, python-brace-format msgid "" "Skipping user data file: {filename}. It will not be overwritten during " @@ -1527,33 +1730,33 @@ msgid "" msgstr "" "Přeskočení souboru uživatelských dat:{filename}. Během obnovy nebude přepsán." -#: update.py:213 +#: update.py:286 msgid "Previous version restored successfully." msgstr "Předchozí verze byla úspěšně obnovena." -#: update.py:215 +#: update.py:288 msgid "Restarting application to apply changes..." msgstr "Restartování aplikace za účelem použití změn..." -#: update.py:218 +#: update.py:291 #, python-brace-format msgid "Error during restoration: {error}" msgstr "Chyba při obnovování: {error}" -#: update.py:219 +#: update.py:292 #, python-brace-format msgid "The backup file '{filename}' was not deleted." msgstr "Záložní soubor '{filename}' nebyl odstraněn." -#: update.py:226 +#: update.py:299 msgid "Error: Could not import TimeTracker to get the current version." msgstr "Chyba: Nelze importovat TimeTracker pro získání aktuální verze." -#: update.py:229 +#: update.py:302 msgid "Checking for updates..." msgstr "Kontrola aktualizací..." -#: update.py:236 +#: update.py:309 msgid "No updates available." msgstr "Nejsou k dispozici žádné aktualizace." diff --git a/locale/de/LC_MESSAGES/timetracker.mo b/locale/de/LC_MESSAGES/timetracker.mo index 827824c30c28bc6d6e2923ac698541760f1198a2..3d97c79f75418534fee5fb2166d3846ca430f7dd 100644 GIT binary patch delta 12601 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 delta 7542 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 {weeks} weeks):" msgstr "Inaktive Aufgaben (> {weeks} Wochen):" -#: sl/SL_Menu.py:1846 sl/SL_Menu.py:2207 +#: sl/SL_Menu.py:2148 sl/SL_Menu.py:2509 msgid "Last Activity" msgstr "Letzte Aktivität" -#: sl/SL_Menu.py:1852 +#: sl/SL_Menu.py:2154 #, python-brace-format msgid "No tasks found inactive for more than {weeks} weeks." msgstr "" "Es wurden keine Aufgaben gefunden, die länger als{weeks}Wochen inaktiv waren." -#: sl/SL_Menu.py:1877 sl/SL_Menu.py:1900 +#: sl/SL_Menu.py:2179 sl/SL_Menu.py:2202 msgid "No closed tasks found." msgstr "Keine geschlossenen Aufgaben gefunden." -#: sl/SL_Menu.py:1905 +#: sl/SL_Menu.py:2207 #, python-brace-format msgid "" "Are you sure you want to delete {count} closed tasks? This action cannot be " @@ -825,25 +1014,25 @@ msgstr "" "Sind Sie sicher, dass Sie{count}geschlossene Aufgaben löschen möchten? Diese " "Aktion kann nicht rückgängig gemacht werden." -#: sl/SL_Menu.py:1907 +#: sl/SL_Menu.py:2209 msgid "Show projects to delete" msgstr "Zu löschende Projekte anzeigen" -#: sl/SL_Menu.py:1911 +#: sl/SL_Menu.py:2213 msgid "Delete All" msgstr "Alle löschen" -#: sl/SL_Menu.py:1917 +#: sl/SL_Menu.py:2219 #, python-brace-format msgid "Successfully deleted {count} tasks." msgstr "{count} Aufgaben erfolgreich gelöscht." -#: sl/SL_Menu.py:1943 +#: sl/SL_Menu.py:2245 #, python-brace-format msgid "No open tasks to promote in '{name}'." msgstr "Keine offenen Aufgaben zum Heraufstufen in „{name}“." -#: sl/SL_Menu.py:1954 +#: sl/SL_Menu.py:2256 msgid "" "This will create a new Project with the task's name and move all time " "entries to a 'General' task within it." @@ -851,100 +1040,100 @@ msgstr "" "Dadurch wird ein neues Projekt mit dem Namen der Aufgabe erstellt und alle " "Zeiteinträge in eine darin enthaltene „Allgemeine“ Aufgabe verschoben." -#: sl/SL_Menu.py:1956 +#: sl/SL_Menu.py:2258 msgid "Promote to Project" msgstr "Hochstufen zum Projekt" -#: sl/SL_Menu.py:1980 sl/SL_Menu.py:2044 +#: sl/SL_Menu.py:2282 sl/SL_Menu.py:2346 msgid "closed" msgstr "geschlossen" -#: sl/SL_Menu.py:1983 sl/SL_Menu.py:2030 sl/SL_Menu.py:2170 sl/SL_Menu.py:2711 -#: sl/SL_Menu.py:2770 +#: sl/SL_Menu.py:2285 sl/SL_Menu.py:2332 sl/SL_Menu.py:2472 sl/SL_Menu.py:3017 +#: sl/SL_Menu.py:3076 msgid "No projects found." msgstr "Keine Projekte gefunden." -#: sl/SL_Menu.py:1995 +#: sl/SL_Menu.py:2297 msgid "No open projects to rename." msgstr "Keine offenen Projekte zum Umbenennen." -#: sl/SL_Menu.py:2004 sl/SL_Menu.py:2084 +#: sl/SL_Menu.py:2306 sl/SL_Menu.py:2386 msgid "New Name" msgstr "Neuer Name" -#: sl/SL_Menu.py:2005 sl/SL_Menu.py:2085 +#: sl/SL_Menu.py:2307 sl/SL_Menu.py:2387 msgid "Rename" msgstr "Umbenennen" -#: sl/SL_Menu.py:2009 sl/SL_Menu.py:2089 +#: sl/SL_Menu.py:2311 sl/SL_Menu.py:2391 msgid "Please enter a new name." msgstr "Bitte geben Sie einen neuen Namen ein." -#: sl/SL_Menu.py:2011 sl/SL_Menu.py:2091 +#: sl/SL_Menu.py:2313 sl/SL_Menu.py:2393 msgid "New name is the same as the old name." msgstr "Neuer Name ist derselbe wie der alte Name." -#: sl/SL_Menu.py:2013 +#: sl/SL_Menu.py:2315 #, python-brace-format msgid "Project '{old_name}' successfully renamed to '{new_name}'." msgstr "Projekt '{old_name}' erfolgreich in '{new_name}' umbenannt." -#: sl/SL_Menu.py:2017 +#: sl/SL_Menu.py:2319 #, python-brace-format msgid "Error: Could not rename. The new name '{new_name}' might already exist." msgstr "" "Fehler: Umbenennung fehlgeschlagen. Der neue Name '{new_name}' existiert " "möglicherweise bereits." -#: sl/SL_Menu.py:2041 +#: sl/SL_Menu.py:2343 #, python-brace-format msgid "Tasks for '{name}':" msgstr "Aufgaben für '{name}':" -#: sl/SL_Menu.py:2050 sl/SL_Menu.py:2742 +#: sl/SL_Menu.py:2352 sl/SL_Menu.py:3048 #, python-brace-format msgid "No tasks found for '{name}'." msgstr "Keine Aufgaben für '{name}' gefunden." -#: sl/SL_Menu.py:2074 +#: sl/SL_Menu.py:2376 #, python-brace-format msgid "No open tasks to rename in '{name}'." msgstr "Keine offenen Aufgaben zum Umbenennen in „{name}“." -#: sl/SL_Menu.py:2093 +#: sl/SL_Menu.py:2395 #, python-brace-format msgid "Task '{old_name}' renamed to '{new_name}'." msgstr "Aufgabe '{old_name}' in '{new_name}' umbenannt." -#: sl/SL_Menu.py:2097 +#: sl/SL_Menu.py:2399 msgid "Error: Could not rename. The new name might already exist." msgstr "" "Fehler: Umbenennen nicht möglich. Der neue Name ist möglicherweise bereits " "vorhanden." -#: sl/SL_Menu.py:2110 +#: sl/SL_Menu.py:2412 msgid "No open projects to close." msgstr "Keine offenen Projekte zum Schließen." -#: sl/SL_Menu.py:2123 +#: sl/SL_Menu.py:2425 #, python-brace-format msgid "Project '{name}' has been closed." msgstr "Das Projekt „{name}“ wurde geschlossen." -#: sl/SL_Menu.py:2127 sl/SL_Menu.py:2157 sl/SL_Menu.py:2188 +#: sl/SL_Menu.py:2429 sl/SL_Menu.py:2459 sl/SL_Menu.py:2490 msgid "Error: Project not found." msgstr "Fehler: Projekt nicht gefunden." -#: sl/SL_Menu.py:2140 +#: sl/SL_Menu.py:2442 msgid "No closed projects to reopen." msgstr "Keine geschlossenen Projekte zum erneuten Öffnen." -#: sl/SL_Menu.py:2153 +#: sl/SL_Menu.py:2455 #, python-brace-format msgid "Project '{name}' has been reopened." msgstr "Das Projekt „{name}“ wurde erneut geöffnet." -#: sl/SL_Menu.py:2179 +#: sl/SL_Menu.py:2481 msgid "" "This action cannot be undone. All associated tasks and time entries will be " "deleted." @@ -952,245 +1141,254 @@ msgstr "" "Diese Aktion kann nicht rückgängig gemacht werden. Alle zugehörigen Aufgaben " "und Zeiteinträge werden gelöscht." -#: sl/SL_Menu.py:2184 +#: sl/SL_Menu.py:2486 #, python-brace-format msgid "Project '{name}' has been deleted." msgstr "Projekt „{name}“ wurde gelöscht." -#: sl/SL_Menu.py:2204 +#: sl/SL_Menu.py:2506 #, python-brace-format msgid "Inactive Projects (> {weeks} weeks):" msgstr "Inaktive Projekte (> {weeks} Wochen):" -#: sl/SL_Menu.py:2209 +#: sl/SL_Menu.py:2511 #, python-brace-format msgid "No projects found inactive for more than {weeks} weeks." msgstr "Keine Projekte gefunden, die länger als {weeks} Wochen inaktiv waren." -#: sl/SL_Menu.py:2218 sl/SL_Menu.py:2241 +#: sl/SL_Menu.py:2520 sl/SL_Menu.py:2543 msgid "Demote Project" msgstr "Projekt herabstufen" -#: sl/SL_Menu.py:2230 +#: sl/SL_Menu.py:2532 msgid "Select Project to Demote" msgstr "Wählen Sie das Projekt zum Herabstufen aus." -#: sl/SL_Menu.py:2236 +#: sl/SL_Menu.py:2538 msgid "No other projects available to demote into." msgstr "Es sind keine anderen Projekte zum Herabstufen verfügbar." -#: sl/SL_Menu.py:2239 +#: sl/SL_Menu.py:2541 #, python-brace-format msgid "This will convert '{src}' into a task of '{dst}'." msgstr "Dadurch wird „{src}“ in eine Aufgabe von „{dst}“ umgewandelt." -#: sl/SL_Menu.py:2264 +#: sl/SL_Menu.py:2566 msgid "Projects with only closed or no tasks:" msgstr "Projekte mit nur abgeschlossenen oder keinen Aufgaben:" -#: sl/SL_Menu.py:2268 +#: sl/SL_Menu.py:2570 msgid "No completed projects found." msgstr "Keine abgeschlossenen Projekte gefunden." -#: sl/SL_Menu.py:2277 sl/SL_Menu.py:2411 sl/SL_Menu.py:2707 +#: sl/SL_Menu.py:2579 sl/SL_Menu.py:2713 sl/SL_Menu.py:3013 msgid "Step 1: Select Project" msgstr "Schritt 1: Projekt auswählen" -#: sl/SL_Menu.py:2281 sl/SL_Menu.py:2599 +#: sl/SL_Menu.py:2583 sl/SL_Menu.py:2905 msgid "No open projects found. Please add one first." msgstr "Keine offenen Projekte gefunden. Bitte fügen Sie zuerst eines hinzu." -#: sl/SL_Menu.py:2286 sl/SL_Menu.py:2418 sl/SL_Menu.py:2647 +#: sl/SL_Menu.py:2588 sl/SL_Menu.py:2720 sl/SL_Menu.py:2953 msgid "Project" msgstr "Projekt" -#: sl/SL_Menu.py:2288 sl/SL_Menu.py:2419 sl/SL_Menu.py:2443 sl/SL_Menu.py:2719 +#: sl/SL_Menu.py:2590 sl/SL_Menu.py:2721 sl/SL_Menu.py:2745 sl/SL_Menu.py:3025 msgid "Next" msgstr "Weiter" -#: sl/SL_Menu.py:2303 sl/SL_Menu.py:2733 +#: sl/SL_Menu.py:2605 sl/SL_Menu.py:3039 msgid "No project selected. Please start again." msgstr "Kein Projekt ausgewählt. Bitte beginnen Sie erneut." -#: sl/SL_Menu.py:2308 +#: sl/SL_Menu.py:2610 msgid "To Project:" msgstr "Zum Projekt:" -#: sl/SL_Menu.py:2329 +#: sl/SL_Menu.py:2631 msgid "Name of the new task" msgstr "Name der neuen Aufgabe" -#: sl/SL_Menu.py:2335 +#: sl/SL_Menu.py:2637 msgid "Due date" msgstr "Fälligkeitsdatum" -#: sl/SL_Menu.py:2341 sl/SL_Menu.py:2513 +#: sl/SL_Menu.py:2643 sl/SL_Menu.py:2815 msgid "Recurring" msgstr "Wiederkehrend" -#: sl/SL_Menu.py:2357 sl/SL_Menu.py:2528 +#: sl/SL_Menu.py:2659 sl/SL_Menu.py:2830 msgid "daily" msgstr "täglich" -#: sl/SL_Menu.py:2357 sl/SL_Menu.py:2528 +#: sl/SL_Menu.py:2659 sl/SL_Menu.py:2830 msgid "monthly" msgstr "monatlich" -#: sl/SL_Menu.py:2357 sl/SL_Menu.py:2528 +#: sl/SL_Menu.py:2659 sl/SL_Menu.py:2830 msgid "on all business days" msgstr "an allen Geschäftstagen" -#: sl/SL_Menu.py:2357 sl/SL_Menu.py:2528 +#: sl/SL_Menu.py:2659 sl/SL_Menu.py:2830 msgid "userdefined" msgstr "benutzerdefiniert" -#: sl/SL_Menu.py:2357 sl/SL_Menu.py:2528 +#: sl/SL_Menu.py:2659 sl/SL_Menu.py:2830 msgid "weekly" msgstr "wöchentlich" -#: sl/SL_Menu.py:2361 sl/SL_Menu.py:2537 +#: sl/SL_Menu.py:2663 sl/SL_Menu.py:2839 msgid "Frequency" msgstr "Häufigkeit" -#: sl/SL_Menu.py:2365 sl/SL_Menu.py:2540 +#: sl/SL_Menu.py:2667 sl/SL_Menu.py:2842 msgid "Days" msgstr "Tage" -#: sl/SL_Menu.py:2369 sl/SL_Menu.py:2542 +#: sl/SL_Menu.py:2671 sl/SL_Menu.py:2844 msgid "Edit" msgstr "Bearbeiten" -#: sl/SL_Menu.py:2369 sl/SL_Menu.py:2542 +#: sl/SL_Menu.py:2671 sl/SL_Menu.py:2844 msgid "Preview" msgstr "Vorschau" -#: sl/SL_Menu.py:2374 sl/SL_Menu.py:2547 +#: sl/SL_Menu.py:2676 sl/SL_Menu.py:2849 msgid "No notes provided." msgstr "Keine Notizen bereitgestellt." -#: sl/SL_Menu.py:2379 sl/SL_Menu.py:2552 +#: sl/SL_Menu.py:2681 sl/SL_Menu.py:2854 msgid "A due date is required for recurring tasks." msgstr "Für wiederkehrende Aufgaben ist ein Fälligkeitsdatum erforderlich." -#: sl/SL_Menu.py:2387 +#: sl/SL_Menu.py:2689 msgid "Please enter a name." msgstr "Bitte geben Sie einen Namen ein." -#: sl/SL_Menu.py:2399 +#: sl/SL_Menu.py:2701 #, python-brace-format msgid "Task '{sub_name}' added to '{main_name}'." msgstr "Aufgabe '{sub_name}' zu '{main_name}' hinzugefügt." -#: sl/SL_Menu.py:2431 sl/SL_Menu.py:2738 +#: sl/SL_Menu.py:2733 sl/SL_Menu.py:3044 msgid "Step 2: Select Task from" msgstr "Schritt 2: Aufgabe auswählen aus" -#: sl/SL_Menu.py:2434 +#: sl/SL_Menu.py:2736 msgid "No open tasks found." msgstr "Keine offenen Aufgaben gefunden." -#: sl/SL_Menu.py:2462 +#: sl/SL_Menu.py:2764 msgid "Task not found." msgstr "Aufgabe nicht gefunden." -#: sl/SL_Menu.py:2556 +#: sl/SL_Menu.py:2858 msgid "Save Changes" msgstr "Änderungen speichern" -#: sl/SL_Menu.py:2575 +#: sl/SL_Menu.py:2881 msgid "Task updated successfully." msgstr "Aufgabe erfolgreich aktualisiert." -#: sl/SL_Menu.py:2581 +#: sl/SL_Menu.py:2887 msgid "Error: Could not update task." msgstr "Fehler: Aufgabe konnte nicht aktualisiert werden." -#: sl/SL_Menu.py:2594 +#: sl/SL_Menu.py:2900 msgid "Start Work on Task" msgstr "Arbeit an Aufgabe beginnen" -#: sl/SL_Menu.py:2609 +#: sl/SL_Menu.py:2915 #, python-brace-format msgid "No open tasks to start work on in '{name}'." msgstr "" "Keine offenen Aufgaben, an denen in „{name}“ mit der Arbeit begonnen werden " "kann." -#: sl/SL_Menu.py:2620 +#: sl/SL_Menu.py:2926 msgid "Start Work" msgstr "Arbeit beginnen" -#: sl/SL_Menu.py:2626 +#: sl/SL_Menu.py:2932 #, python-brace-format msgid "Work started on '{task_name}' in project '{main_name}'." msgstr "Arbeit an '{task_name}' im Projekt '{main_name}' begonnen." -#: sl/SL_Menu.py:2630 +#: sl/SL_Menu.py:2936 msgid "Error starting work." msgstr "Fehler beim Starten der Arbeit." -#: sl/SL_Menu.py:2648 +#: sl/SL_Menu.py:2954 msgid "Task" msgstr "Aufgabe" -#: sl/SL_Menu.py:2649 +#: sl/SL_Menu.py:2955 msgid "Started at" msgstr "Gestartet um" -#: sl/SL_Menu.py:2650 tt/TimeTracker.py:1454 +#: sl/SL_Menu.py:2956 tt/TimeTracker.py:1912 msgid "Duration" msgstr "Dauer" -#: sl/SL_Menu.py:2664 sl/SL_Menu.py:2796 +#: sl/SL_Menu.py:2970 sl/SL_Menu.py:3102 msgid "Select Date" msgstr "Datum auswählen" -#: sl/SL_Menu.py:2665 sl/SL_Menu.py:2689 sl/SL_Menu.py:2753 sl/SL_Menu.py:2779 -#: sl/SL_Menu.py:2797 +#: sl/SL_Menu.py:2971 sl/SL_Menu.py:2995 sl/SL_Menu.py:3059 sl/SL_Menu.py:3085 +#: sl/SL_Menu.py:3103 msgid "Generate Report" msgstr "Bericht erstellen" -#: sl/SL_Menu.py:2685 +#: sl/SL_Menu.py:2991 msgid "Start Date" msgstr "Startdatum" -#: sl/SL_Menu.py:2687 +#: sl/SL_Menu.py:2993 msgid "End Date" msgstr "Enddatum" -#: sl/SL_Menu.py:2693 +#: sl/SL_Menu.py:2999 msgid "Error: The start date cannot be after the end date." msgstr "Fehler: Das Startdatum darf nicht nach dem Enddatum liegen." -#: sl/SL_Menu.py:2812 +#: sl/SL_Menu.py:3118 msgid "Report Result" msgstr "Berichtsergebnis" -#: sl/SL_Menu.py:2841 +#: sl/SL_Menu.py:3147 msgid "Export Report" msgstr "Bericht exportieren" -#: tt/TimeTracker.py:91 +#: tt/TimeTracker.py:191 #, python-brace-format msgid "Warning: Could not read {file}. Error: {error}" msgstr "Warnung: Konnte {file} nicht lesen. Fehler: {error}" -#: tt/TimeTracker.py:107 +#: tt/TimeTracker.py:207 msgid "Some required packages are missing. Attempting to install them..." msgstr "" "Einige benötigte Pakete fehlen. Es wird versucht, sie zu installieren..." -#: tt/TimeTracker.py:110 +#: tt/TimeTracker.py:210 #, python-brace-format msgid "Installing {package}..." msgstr "Installiere {package}..." -#: tt/TimeTracker.py:114 +#: tt/TimeTracker.py:217 #, python-brace-format msgid "Failed to install {package}. Continuing without it." msgstr "Installation von {package}fehlgeschlagen. Weiter ohne." -#: tt/TimeTracker.py:118 +#: tt/TimeTracker.py:220 +#, python-brace-format +msgid "" +"Timed out installing {package} (no internet connection?). Continuing without " +"it." +msgstr "" +"Zeitüberschreitung bei der Installation von {package} (keine " +"Internetverbindung?). Weiter ohne." + +#: tt/TimeTracker.py:224 msgid "" "\n" "Dependencies installed successfully." @@ -1198,11 +1396,11 @@ msgstr "" "\n" "Abhängigkeiten erfolgreich installiert." -#: tt/TimeTracker.py:119 +#: tt/TimeTracker.py:225 msgid "Please restart the application for the changes to take effect." msgstr "Bitte starte die Anwendung neu, damit die Änderungen wirksam werden." -#: tt/TimeTracker.py:122 +#: tt/TimeTracker.py:228 #, python-brace-format msgid "" "\n" @@ -1211,22 +1409,22 @@ msgstr "" "\n" "Warnung: Einige Abhängigkeiten konnten nicht installiert werden:{packages}" -#: tt/TimeTracker.py:124 +#: tt/TimeTracker.py:230 #, python-brace-format msgid "An unexpected error occurred during dependency check: {error}" msgstr "" "Ein unerwarteter Fehler bei der Abhängigkeitsprüfung ist aufgetreten: {error}" -#: tt/TimeTracker.py:251 +#: tt/TimeTracker.py:452 msgid "Info: Report content has been copied to the clipboard." msgstr "Info: Der Berichtsinhalt wurde in die Zwischenablage kopiert." -#: tt/TimeTracker.py:253 +#: tt/TimeTracker.py:454 #, python-brace-format msgid "Warning: Could not copy to clipboard. Error: {error}" msgstr "Warnung: Konnte nicht in die Zwischenablage kopieren. Fehler: {error}" -#: tt/TimeTracker.py:255 +#: tt/TimeTracker.py:456 msgid "" "Warning: Could not copy to clipboard. Please install 'pyperclip' (`pip " "install pyperclip`)." @@ -1234,56 +1432,56 @@ msgstr "" "Warnung: Konnte nicht in die Zwischenablage kopieren. Bitte 'pyperclip' " "installieren (`pip install pyperclip`)." -#: tt/TimeTracker.py:275 +#: tt/TimeTracker.py:476 #, python-brace-format msgid "{hours} hours ({dlp} DLP)" msgstr "{hours} Stunden ({dlp} DLP)" -#: tt/TimeTracker.py:877 tt/TimeTracker.py:917 +#: tt/TimeTracker.py:1218 tt/TimeTracker.py:1263 #, python-brace-format msgid "Source main project '{name}' not found." msgstr "Quellhauptprojekt „{name}“ nicht gefunden." -#: tt/TimeTracker.py:879 +#: tt/TimeTracker.py:1220 #, python-brace-format msgid "Destination main project '{name}' not found." msgstr "Ziel-Hauptprojekt „{name}“ nicht gefunden." -#: tt/TimeTracker.py:891 +#: tt/TimeTracker.py:1237 #, python-brace-format msgid "Task '{task_name}' moved successfully." msgstr "Aufgabe „{task_name}“ wurde erfolgreich verschoben." -#: tt/TimeTracker.py:892 tt/TimeTracker.py:927 tt/TimeTracker.py:1416 +#: tt/TimeTracker.py:1238 tt/TimeTracker.py:1273 tt/TimeTracker.py:1874 #, python-brace-format msgid "Task '{task_name}' not found in '{main_name}'." msgstr "Aufgabe „{task_name}“ nicht in „{main_name}“ gefunden." -#: tt/TimeTracker.py:911 +#: tt/TimeTracker.py:1257 #, python-brace-format msgid "A main project named '{name}' already exists." msgstr "Ein Hauptprojekt mit dem Namen „{name}“ existiert bereits." -#: tt/TimeTracker.py:936 +#: tt/TimeTracker.py:1305 msgid "General" msgstr "Allgemein" -#: tt/TimeTracker.py:940 +#: tt/TimeTracker.py:1343 #, python-brace-format msgid "Task '{task_name}' was promoted to a new main project." msgstr "Aufgabe „{task_name}“ wurde in ein neues Hauptprojekt hochgestuft." -#: tt/TimeTracker.py:967 +#: tt/TimeTracker.py:1370 #, python-brace-format msgid "Main project to demote '{name}' not found." msgstr "Hauptprojekt zur Herabstufung von „{name}“ nicht gefunden." -#: tt/TimeTracker.py:969 +#: tt/TimeTracker.py:1372 #, python-brace-format msgid "New parent main project '{name}' not found." msgstr "Neues übergeordnetes Hauptprojekt „{name}“ nicht gefunden." -#: tt/TimeTracker.py:994 +#: tt/TimeTracker.py:1427 #, python-brace-format msgid "" "Main project '{demoted_name}' was demoted to a sub-project under " @@ -1292,42 +1490,42 @@ msgstr "" "Das Hauptprojekt „{demoted_name}“ wurde zu einem Unterprojekt unter " "„{parent_name}“ herabgestuft." -#: tt/TimeTracker.py:1076 +#: tt/TimeTracker.py:1521 msgid "Email import is not enabled." msgstr "E-Mail-Import ist nicht aktiviert." -#: tt/TimeTracker.py:1085 +#: tt/TimeTracker.py:1530 msgid "Email settings are incomplete." msgstr "E-Mail-Einstellungen sind unvollständig." -#: tt/TimeTracker.py:1098 +#: tt/TimeTracker.py:1543 msgid "Error searching emails." msgstr "Fehler beim Durchsuchen der E-Mails." -#: tt/TimeTracker.py:1113 +#: tt/TimeTracker.py:1558 msgid "No Subject" msgstr "Kein Betreff" -#: tt/TimeTracker.py:1173 +#: tt/TimeTracker.py:1631 msgid "Unknown Task" msgstr "Unbekannte Aufgabe" -#: tt/TimeTracker.py:1373 +#: tt/TimeTracker.py:1831 #, python-brace-format msgid "- {name}: {hours} hours" msgstr "- {name}: {hours} Stunden" -#: tt/TimeTracker.py:1381 +#: tt/TimeTracker.py:1839 #, python-brace-format msgid "## {name} ({hours} hours)\n" msgstr "## {name} ({hours} Stunden)\n" -#: tt/TimeTracker.py:1390 +#: tt/TimeTracker.py:1848 #, python-brace-format msgid "# Daily Time Report: {date}\n" msgstr "# Tagesbericht: {date}\n" -#: tt/TimeTracker.py:1391 +#: tt/TimeTracker.py:1849 #, python-brace-format msgid "" "\n" @@ -1336,104 +1534,104 @@ msgstr "" "\n" "**Tägliche Gesamtzeit:{hours}Stunden**" -#: tt/TimeTracker.py:1395 tt/TimeTracker.py:1708 +#: tt/TimeTracker.py:1853 tt/TimeTracker.py:2166 #, python-brace-format msgid "No time tracked for {date}." msgstr "Keine Zeit für {date} erfasst." -#: tt/TimeTracker.py:1412 tt/TimeTracker.py:1507 +#: tt/TimeTracker.py:1870 tt/TimeTracker.py:1965 #, python-brace-format msgid "Main project '{name}' not found." msgstr "Hauptprojekt „{name}“ nicht gefunden." -#: tt/TimeTracker.py:1420 +#: tt/TimeTracker.py:1878 #, python-brace-format msgid "No time entries found for task '{task_name}'." msgstr "Für Aufgabe „{task_name}“ wurden keine Zeiteinträge gefunden." -#: tt/TimeTracker.py:1453 tt/TimeTracker.py:1683 +#: tt/TimeTracker.py:1911 tt/TimeTracker.py:2141 msgid "now" msgstr "jetzt" -#: tt/TimeTracker.py:1458 +#: tt/TimeTracker.py:1916 #, python-brace-format msgid "# Detailed Report for Task: {name}" msgstr "# Detaillierter Bericht für Aufgabe: {name}" -#: tt/TimeTracker.py:1459 +#: tt/TimeTracker.py:1917 #, python-brace-format msgid "Part of Main Project: {name}" msgstr "Teil des Hauptprojekts: {name}" -#: tt/TimeTracker.py:1462 +#: tt/TimeTracker.py:1920 msgid "Active (currently running)" msgstr "Aktiv (läuft gerade)" -#: tt/TimeTracker.py:1462 tt/TimeTracker.py:1559 +#: tt/TimeTracker.py:1920 tt/TimeTracker.py:2017 msgid "Inactive" msgstr "Inaktiv" -#: tt/TimeTracker.py:1463 tt/TimeTracker.py:1560 +#: tt/TimeTracker.py:1921 tt/TimeTracker.py:2018 msgid "Status" msgstr "Status" -#: tt/TimeTracker.py:1465 tt/TimeTracker.py:1562 +#: tt/TimeTracker.py:1923 tt/TimeTracker.py:2020 msgid "First entry" msgstr "Erster Eintrag" -#: tt/TimeTracker.py:1467 tt/TimeTracker.py:1564 +#: tt/TimeTracker.py:1925 tt/TimeTracker.py:2022 msgid "Last activity" msgstr "Letzte Aktivität" -#: tt/TimeTracker.py:1469 tt/TimeTracker.py:1566 +#: tt/TimeTracker.py:1927 tt/TimeTracker.py:2024 msgid "Total recorded time" msgstr "Erfasste Gesamtzeit" -#: tt/TimeTracker.py:1470 tt/TimeTracker.py:1568 +#: tt/TimeTracker.py:1928 tt/TimeTracker.py:2026 msgid "Total work sessions" msgstr "Gesamtzahl der Arbeitssitzungen" -#: tt/TimeTracker.py:1474 tt/TimeTracker.py:1572 +#: tt/TimeTracker.py:1932 tt/TimeTracker.py:2030 msgid "Average session duration" msgstr "Durchschnittliche Sitzungsdauer" -#: tt/TimeTracker.py:1477 tt/TimeTracker.py:1575 +#: tt/TimeTracker.py:1935 tt/TimeTracker.py:2033 msgid "Weekday Distribution" msgstr "Verteilung auf Wochentage" -#: tt/TimeTracker.py:1487 +#: tt/TimeTracker.py:1945 msgid "Daily Breakdown" msgstr "Tagesaufschlüsselung" -#: tt/TimeTracker.py:1556 +#: tt/TimeTracker.py:2014 #, python-brace-format msgid "# Detailed Report for Main Project: {name}" msgstr "# Detaillierter Bericht für Hauptprojekt: {name}" -#: tt/TimeTracker.py:1559 +#: tt/TimeTracker.py:2017 #, python-brace-format msgid "Active (working on '{task_name}')" msgstr "Aktiv (arbeitet an '{task_name}')" -#: tt/TimeTracker.py:1567 +#: tt/TimeTracker.py:2025 msgid "Number of tasks" msgstr "Anzahl der Aufgaben" -#: tt/TimeTracker.py:1586 +#: tt/TimeTracker.py:2044 msgid "Task Breakdown" msgstr "Aufschlüsselung nach Aufgaben" -#: tt/TimeTracker.py:1595 +#: tt/TimeTracker.py:2053 #, python-brace-format msgid "{num_sessions} sessions" msgstr "{num_sessions} Sitzungen" -#: tt/TimeTracker.py:1648 +#: tt/TimeTracker.py:2106 #, python-brace-format msgid "# Time Report: {start_date} to {end_date}\n" msgstr "# Zeitbericht: {start_date} bis {end_date}\n" -#: tt/TimeTracker.py:1649 +#: tt/TimeTracker.py:2107 #, python-brace-format msgid "" "\n" @@ -1442,17 +1640,17 @@ msgstr "" "\n" "**Gesamtzeit im Zeitraum: {total_time}**" -#: tt/TimeTracker.py:1653 +#: tt/TimeTracker.py:2111 #, python-brace-format msgid "No time tracked between {start_date} and {end_date}." msgstr "Keine Zeit zwischen {start_date} und {end_date} erfasst." -#: tt/TimeTracker.py:1669 +#: tt/TimeTracker.py:2127 #, python-brace-format msgid "# Detailed Daily Report: {date}" msgstr "# Detaillierter Tagesbericht: {date}" -#: update.py:35 +#: update.py:101 msgid "" "Warning: Update check skipped. 'github_repo' not found in config.json or " "file is invalid." @@ -1460,83 +1658,96 @@ msgstr "" "Warnung: Update-Prüfung übersprungen. 'github_repo' nicht in config.json " "gefunden oder Datei ist ungültig." -#: update.py:55 +#: update.py:121 msgid "Error: Download URL for the new version not found." msgstr "Fehler: Download-URL für die neue Version nicht gefunden." -#: update.py:59 +#: update.py:125 +msgid "Warning: Update check timed out (no internet connection?). Skipping." +msgstr "" +"Warnung: Zeitüberschreitung bei der Update-Prüfung (keine " +"Internetverbindung?). Wird übersprungen." + +#: update.py:127 #, python-brace-format msgid "Error checking for updates: {error}" msgstr "Fehler bei der Suche nach Updates: {error}" -#: update.py:61 +#: update.py:129 #, python-brace-format msgid "An unexpected error occurred while checking for updates: {error}" msgstr "" "Ein unerwarteter Fehler ist bei der Update-Prüfung aufgetreten: {error}" -#: update.py:73 +#: update.py:141 msgid "Downloading update..." msgstr "Update wird heruntergeladen..." -#: update.py:79 +#: update.py:147 msgid "Download complete. The update will be installed on the next start." msgstr "" "Download abgeschlossen. Das Update wird beim nächsten Start installiert." -#: update.py:82 +#: update.py:150 +msgid "" +"Error: Connecting to the update server timed out (no internet connection?)." +msgstr "" +"Fehler: Zeitüberschreitung beim Verbinden mit dem Update-Server (keine " +"Internetverbindung?)." + +#: update.py:155 #, python-brace-format msgid "Error downloading the update: {error}" msgstr "Fehler beim Herunterladen des Updates: {error}" -#: update.py:98 +#: update.py:171 msgid "Restarting application to apply the update..." msgstr "Anwendung wird neu gestartet, um das Update zu übernehmen..." -#: update.py:122 +#: update.py:195 msgid "Creating backup of current version before update..." msgstr "Erstelle Backup der aktuellen Version vor dem Update..." -#: update.py:132 +#: update.py:205 #, python-brace-format msgid "Backup created successfully as {filename}." msgstr "Backup erfolgreich als {filename} erstellt." -#: update.py:134 +#: update.py:207 #, python-brace-format msgid "Warning: Could not create backup. Error: {error}" msgstr "Warnung: Backup konnte nicht erstellt werden. Fehler: {error}" -#: update.py:136 +#: update.py:209 msgid "Installing update..." msgstr "Update wird installiert..." -#: update.py:156 +#: update.py:229 #, python-brace-format msgid "Skipping protected file: {filename}. It will not be overwritten." msgstr "" "Geschützte Datei wird übersprungen: {filename}. Sie wird nicht überschrieben." -#: update.py:165 +#: update.py:238 msgid "Update installed successfully." msgstr "Update erfolgreich installiert." -#: update.py:167 +#: update.py:240 #, python-brace-format msgid "Error during update installation: {error}" msgstr "Fehler bei der Installation des Updates: {error}" -#: update.py:182 +#: update.py:255 #, python-brace-format msgid "Error: No previous version backup '{filename}' found." msgstr "Fehler: Kein Backup der vorherigen Version '{filename}' gefunden." -#: update.py:185 +#: update.py:258 #, python-brace-format msgid "Restoring previous version from '{filename}'..." msgstr "Stelle vorherige Version aus '{filename}' wieder her..." -#: update.py:206 +#: update.py:279 #, python-brace-format msgid "" "Skipping user data file: {filename}. It will not be overwritten during " @@ -1545,35 +1756,35 @@ msgstr "" "Überspringe Benutzerdatendatei: {filename}. Sie wird bei der " "Wiederherstellung nicht überschrieben." -#: update.py:213 +#: update.py:286 msgid "Previous version restored successfully." msgstr "Vorherige Version erfolgreich wiederhergestellt." -#: update.py:215 +#: update.py:288 msgid "Restarting application to apply changes..." msgstr "Anwendung wird neu gestartet, um die Änderungen zu übernehmen..." -#: update.py:218 +#: update.py:291 #, python-brace-format msgid "Error during restoration: {error}" msgstr "Fehler bei der Wiederherstellung: {error}" -#: update.py:219 +#: update.py:292 #, python-brace-format msgid "The backup file '{filename}' was not deleted." msgstr "Die Backup-Datei '{filename}' wurde nicht gelöscht." -#: update.py:226 +#: update.py:299 msgid "Error: Could not import TimeTracker to get the current version." msgstr "" "Fehler: TimeTracker konnte nicht importiert werden, um die aktuelle Version " "zu erhalten." -#: update.py:229 +#: update.py:302 msgid "Checking for updates..." msgstr "Suche nach Updates..." -#: update.py:236 +#: update.py:309 msgid "No updates available." msgstr "Keine Updates verfügbar." diff --git a/locale/en/LC_MESSAGES/timetracker.mo b/locale/en/LC_MESSAGES/timetracker.mo index 44857a54649b09eb92db1871cedcdfdc3ef33761..1ed482c2a8a60bca2ceb12b20a7a317c8f709f8a 100644 GIT binary patch literal 31921 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 OC3w64`2WWx`2PkxEoCAA delta 7549 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 {weeks} weeks):" msgstr "Inactive Tasks (> {weeks} weeks):" -#: sl/SL_Menu.py:1846 sl/SL_Menu.py:2207 +#: sl/SL_Menu.py:2148 sl/SL_Menu.py:2509 msgid "Last Activity" msgstr "Last Activity" -#: sl/SL_Menu.py:1852 +#: sl/SL_Menu.py:2154 #, python-brace-format msgid "No tasks found inactive for more than {weeks} weeks." msgstr "No tasks found inactive for more than {weeks} weeks." -#: sl/SL_Menu.py:1877 sl/SL_Menu.py:1900 +#: sl/SL_Menu.py:2179 sl/SL_Menu.py:2202 msgid "No closed tasks found." msgstr "No closed tasks found." -#: sl/SL_Menu.py:1905 +#: sl/SL_Menu.py:2207 #, python-brace-format msgid "" "Are you sure you want to delete {count} closed tasks? This action cannot be " @@ -821,25 +1004,25 @@ msgstr "" "Are you sure you want to delete {count} closed tasks? This action cannot be " "undone." -#: sl/SL_Menu.py:1907 +#: sl/SL_Menu.py:2209 msgid "Show projects to delete" msgstr "Show projects to delete" -#: sl/SL_Menu.py:1911 +#: sl/SL_Menu.py:2213 msgid "Delete All" msgstr "Delete All" -#: sl/SL_Menu.py:1917 +#: sl/SL_Menu.py:2219 #, python-brace-format msgid "Successfully deleted {count} tasks." msgstr "Successfully deleted {count} tasks." -#: sl/SL_Menu.py:1943 +#: sl/SL_Menu.py:2245 #, python-brace-format msgid "No open tasks to promote in '{name}'." msgstr "No open tasks to promote in '{name}'." -#: sl/SL_Menu.py:1954 +#: sl/SL_Menu.py:2256 msgid "" "This will create a new Project with the task's name and move all time " "entries to a 'General' task within it." @@ -847,97 +1030,97 @@ msgstr "" "This will create a new Project with the task's name and move all time " "entries to a 'General' task within it." -#: sl/SL_Menu.py:1956 +#: sl/SL_Menu.py:2258 msgid "Promote to Project" msgstr "Promote to Project" -#: sl/SL_Menu.py:1980 sl/SL_Menu.py:2044 +#: sl/SL_Menu.py:2282 sl/SL_Menu.py:2346 msgid "closed" msgstr "closed" -#: sl/SL_Menu.py:1983 sl/SL_Menu.py:2030 sl/SL_Menu.py:2170 sl/SL_Menu.py:2711 -#: sl/SL_Menu.py:2770 +#: sl/SL_Menu.py:2285 sl/SL_Menu.py:2332 sl/SL_Menu.py:2472 sl/SL_Menu.py:3017 +#: sl/SL_Menu.py:3076 msgid "No projects found." msgstr "No projects found." -#: sl/SL_Menu.py:1995 +#: sl/SL_Menu.py:2297 msgid "No open projects to rename." msgstr "No open projects to rename." -#: sl/SL_Menu.py:2004 sl/SL_Menu.py:2084 +#: sl/SL_Menu.py:2306 sl/SL_Menu.py:2386 msgid "New Name" msgstr "New Name" -#: sl/SL_Menu.py:2005 sl/SL_Menu.py:2085 +#: sl/SL_Menu.py:2307 sl/SL_Menu.py:2387 msgid "Rename" msgstr "Rename" -#: sl/SL_Menu.py:2009 sl/SL_Menu.py:2089 +#: sl/SL_Menu.py:2311 sl/SL_Menu.py:2391 msgid "Please enter a new name." msgstr "Please enter a new name." -#: sl/SL_Menu.py:2011 sl/SL_Menu.py:2091 +#: sl/SL_Menu.py:2313 sl/SL_Menu.py:2393 msgid "New name is the same as the old name." msgstr "New name is the same as the old name." -#: sl/SL_Menu.py:2013 +#: sl/SL_Menu.py:2315 #, python-brace-format msgid "Project '{old_name}' successfully renamed to '{new_name}'." msgstr "Project '{old_name}' successfully renamed to '{new_name}'." -#: sl/SL_Menu.py:2017 +#: sl/SL_Menu.py:2319 #, python-brace-format msgid "Error: Could not rename. The new name '{new_name}' might already exist." msgstr "" "Error: Could not rename. The new name '{new_name}' might already exist." -#: sl/SL_Menu.py:2041 +#: sl/SL_Menu.py:2343 #, python-brace-format msgid "Tasks for '{name}':" msgstr "Tasks for '{name}':" -#: sl/SL_Menu.py:2050 sl/SL_Menu.py:2742 +#: sl/SL_Menu.py:2352 sl/SL_Menu.py:3048 #, python-brace-format msgid "No tasks found for '{name}'." msgstr "No tasks found for '{name}'." -#: sl/SL_Menu.py:2074 +#: sl/SL_Menu.py:2376 #, python-brace-format msgid "No open tasks to rename in '{name}'." msgstr "No open tasks to rename in '{name}'." -#: sl/SL_Menu.py:2093 +#: sl/SL_Menu.py:2395 #, python-brace-format msgid "Task '{old_name}' renamed to '{new_name}'." msgstr "Task '{old_name}' renamed to '{new_name}'." -#: sl/SL_Menu.py:2097 +#: sl/SL_Menu.py:2399 msgid "Error: Could not rename. The new name might already exist." msgstr "Error: Could not rename. The new name might already exist." -#: sl/SL_Menu.py:2110 +#: sl/SL_Menu.py:2412 msgid "No open projects to close." msgstr "No open projects to close." -#: sl/SL_Menu.py:2123 +#: sl/SL_Menu.py:2425 #, python-brace-format msgid "Project '{name}' has been closed." msgstr "Project '{name}' has been closed." -#: sl/SL_Menu.py:2127 sl/SL_Menu.py:2157 sl/SL_Menu.py:2188 +#: sl/SL_Menu.py:2429 sl/SL_Menu.py:2459 sl/SL_Menu.py:2490 msgid "Error: Project not found." msgstr "Error: Project not found." -#: sl/SL_Menu.py:2140 +#: sl/SL_Menu.py:2442 msgid "No closed projects to reopen." msgstr "No closed projects to reopen." -#: sl/SL_Menu.py:2153 +#: sl/SL_Menu.py:2455 #, python-brace-format msgid "Project '{name}' has been reopened." msgstr "Project '{name}' has been reopened." -#: sl/SL_Menu.py:2179 +#: sl/SL_Menu.py:2481 msgid "" "This action cannot be undone. All associated tasks and time entries will be " "deleted." @@ -945,242 +1128,251 @@ msgstr "" "This action cannot be undone. All associated tasks and time entries will be " "deleted." -#: sl/SL_Menu.py:2184 +#: sl/SL_Menu.py:2486 #, python-brace-format msgid "Project '{name}' has been deleted." msgstr "Project '{name}' has been deleted." -#: sl/SL_Menu.py:2204 +#: sl/SL_Menu.py:2506 #, python-brace-format msgid "Inactive Projects (> {weeks} weeks):" msgstr "Inactive Projects (> {weeks} weeks):" -#: sl/SL_Menu.py:2209 +#: sl/SL_Menu.py:2511 #, python-brace-format msgid "No projects found inactive for more than {weeks} weeks." msgstr "No projects found inactive for more than {weeks} weeks." -#: sl/SL_Menu.py:2218 sl/SL_Menu.py:2241 +#: sl/SL_Menu.py:2520 sl/SL_Menu.py:2543 msgid "Demote Project" msgstr "Demote Project" -#: sl/SL_Menu.py:2230 +#: sl/SL_Menu.py:2532 msgid "Select Project to Demote" msgstr "Select Project to Demote" -#: sl/SL_Menu.py:2236 +#: sl/SL_Menu.py:2538 msgid "No other projects available to demote into." msgstr "No other projects available to demote into." -#: sl/SL_Menu.py:2239 +#: sl/SL_Menu.py:2541 #, python-brace-format msgid "This will convert '{src}' into a task of '{dst}'." msgstr "This will convert '{src}' into a task of '{dst}'." -#: sl/SL_Menu.py:2264 +#: sl/SL_Menu.py:2566 msgid "Projects with only closed or no tasks:" msgstr "Projects with only closed or no tasks:" -#: sl/SL_Menu.py:2268 +#: sl/SL_Menu.py:2570 msgid "No completed projects found." msgstr "No completed projects found." -#: sl/SL_Menu.py:2277 sl/SL_Menu.py:2411 sl/SL_Menu.py:2707 +#: sl/SL_Menu.py:2579 sl/SL_Menu.py:2713 sl/SL_Menu.py:3013 msgid "Step 1: Select Project" msgstr "Step 1: Select Project" -#: sl/SL_Menu.py:2281 sl/SL_Menu.py:2599 +#: sl/SL_Menu.py:2583 sl/SL_Menu.py:2905 msgid "No open projects found. Please add one first." msgstr "No open projects found. Please add one first." -#: sl/SL_Menu.py:2286 sl/SL_Menu.py:2418 sl/SL_Menu.py:2647 +#: sl/SL_Menu.py:2588 sl/SL_Menu.py:2720 sl/SL_Menu.py:2953 msgid "Project" msgstr "Project" -#: sl/SL_Menu.py:2288 sl/SL_Menu.py:2419 sl/SL_Menu.py:2443 sl/SL_Menu.py:2719 +#: sl/SL_Menu.py:2590 sl/SL_Menu.py:2721 sl/SL_Menu.py:2745 sl/SL_Menu.py:3025 msgid "Next" msgstr "Next" -#: sl/SL_Menu.py:2303 sl/SL_Menu.py:2733 +#: sl/SL_Menu.py:2605 sl/SL_Menu.py:3039 msgid "No project selected. Please start again." msgstr "No project selected. Please start again." -#: sl/SL_Menu.py:2308 +#: sl/SL_Menu.py:2610 msgid "To Project:" msgstr "To Project:" -#: sl/SL_Menu.py:2329 +#: sl/SL_Menu.py:2631 msgid "Name of the new task" msgstr "Name of the new task" -#: sl/SL_Menu.py:2335 +#: sl/SL_Menu.py:2637 msgid "Due date" msgstr "Due date" -#: sl/SL_Menu.py:2341 sl/SL_Menu.py:2513 +#: sl/SL_Menu.py:2643 sl/SL_Menu.py:2815 msgid "Recurring" msgstr "Recurring" -#: sl/SL_Menu.py:2357 sl/SL_Menu.py:2528 +#: sl/SL_Menu.py:2659 sl/SL_Menu.py:2830 msgid "daily" msgstr "daily" -#: sl/SL_Menu.py:2357 sl/SL_Menu.py:2528 +#: sl/SL_Menu.py:2659 sl/SL_Menu.py:2830 msgid "monthly" msgstr "monthly" -#: sl/SL_Menu.py:2357 sl/SL_Menu.py:2528 +#: sl/SL_Menu.py:2659 sl/SL_Menu.py:2830 msgid "on all business days" msgstr "on all business days" -#: sl/SL_Menu.py:2357 sl/SL_Menu.py:2528 +#: sl/SL_Menu.py:2659 sl/SL_Menu.py:2830 msgid "userdefined" msgstr "userdefined" -#: sl/SL_Menu.py:2357 sl/SL_Menu.py:2528 +#: sl/SL_Menu.py:2659 sl/SL_Menu.py:2830 msgid "weekly" msgstr "weekly" -#: sl/SL_Menu.py:2361 sl/SL_Menu.py:2537 +#: sl/SL_Menu.py:2663 sl/SL_Menu.py:2839 msgid "Frequency" msgstr "Frequency" -#: sl/SL_Menu.py:2365 sl/SL_Menu.py:2540 +#: sl/SL_Menu.py:2667 sl/SL_Menu.py:2842 msgid "Days" msgstr "Days" -#: sl/SL_Menu.py:2369 sl/SL_Menu.py:2542 +#: sl/SL_Menu.py:2671 sl/SL_Menu.py:2844 msgid "Edit" msgstr "Edit" -#: sl/SL_Menu.py:2369 sl/SL_Menu.py:2542 +#: sl/SL_Menu.py:2671 sl/SL_Menu.py:2844 msgid "Preview" msgstr "Preview" -#: sl/SL_Menu.py:2374 sl/SL_Menu.py:2547 +#: sl/SL_Menu.py:2676 sl/SL_Menu.py:2849 msgid "No notes provided." msgstr "No notes provided." -#: sl/SL_Menu.py:2379 sl/SL_Menu.py:2552 +#: sl/SL_Menu.py:2681 sl/SL_Menu.py:2854 msgid "A due date is required for recurring tasks." msgstr "A due date is required for recurring tasks." -#: sl/SL_Menu.py:2387 +#: sl/SL_Menu.py:2689 msgid "Please enter a name." msgstr "Please enter a name." -#: sl/SL_Menu.py:2399 +#: sl/SL_Menu.py:2701 #, python-brace-format msgid "Task '{sub_name}' added to '{main_name}'." msgstr "Task '{sub_name}' added to '{main_name}'." -#: sl/SL_Menu.py:2431 sl/SL_Menu.py:2738 +#: sl/SL_Menu.py:2733 sl/SL_Menu.py:3044 msgid "Step 2: Select Task from" msgstr "Step 2: Select Task from" -#: sl/SL_Menu.py:2434 +#: sl/SL_Menu.py:2736 msgid "No open tasks found." msgstr "No open tasks found." -#: sl/SL_Menu.py:2462 +#: sl/SL_Menu.py:2764 msgid "Task not found." msgstr "Task not found." -#: sl/SL_Menu.py:2556 +#: sl/SL_Menu.py:2858 msgid "Save Changes" msgstr "Save Changes" -#: sl/SL_Menu.py:2575 +#: sl/SL_Menu.py:2881 msgid "Task updated successfully." msgstr "Task updated successfully." -#: sl/SL_Menu.py:2581 +#: sl/SL_Menu.py:2887 msgid "Error: Could not update task." msgstr "Error: Could not update task." -#: sl/SL_Menu.py:2594 +#: sl/SL_Menu.py:2900 msgid "Start Work on Task" msgstr "Start Work on Task" -#: sl/SL_Menu.py:2609 +#: sl/SL_Menu.py:2915 #, python-brace-format msgid "No open tasks to start work on in '{name}'." msgstr "No open tasks to start work on in '{name}'." -#: sl/SL_Menu.py:2620 +#: sl/SL_Menu.py:2926 msgid "Start Work" msgstr "Start Work" -#: sl/SL_Menu.py:2626 +#: sl/SL_Menu.py:2932 #, python-brace-format msgid "Work started on '{task_name}' in project '{main_name}'." msgstr "Work started on '{task_name}' in project '{main_name}'." -#: sl/SL_Menu.py:2630 +#: sl/SL_Menu.py:2936 msgid "Error starting work." msgstr "Error starting work." -#: sl/SL_Menu.py:2648 +#: sl/SL_Menu.py:2954 msgid "Task" msgstr "Task" -#: sl/SL_Menu.py:2649 +#: sl/SL_Menu.py:2955 msgid "Started at" msgstr "Started at" -#: sl/SL_Menu.py:2650 tt/TimeTracker.py:1454 +#: sl/SL_Menu.py:2956 tt/TimeTracker.py:1912 msgid "Duration" msgstr "Duration" -#: sl/SL_Menu.py:2664 sl/SL_Menu.py:2796 +#: sl/SL_Menu.py:2970 sl/SL_Menu.py:3102 msgid "Select Date" msgstr "Select Date" -#: sl/SL_Menu.py:2665 sl/SL_Menu.py:2689 sl/SL_Menu.py:2753 sl/SL_Menu.py:2779 -#: sl/SL_Menu.py:2797 +#: sl/SL_Menu.py:2971 sl/SL_Menu.py:2995 sl/SL_Menu.py:3059 sl/SL_Menu.py:3085 +#: sl/SL_Menu.py:3103 msgid "Generate Report" msgstr "Generate Report" -#: sl/SL_Menu.py:2685 +#: sl/SL_Menu.py:2991 msgid "Start Date" msgstr "Start Date" -#: sl/SL_Menu.py:2687 +#: sl/SL_Menu.py:2993 msgid "End Date" msgstr "End Date" -#: sl/SL_Menu.py:2693 +#: sl/SL_Menu.py:2999 msgid "Error: The start date cannot be after the end date." msgstr "Error: The start date cannot be after the end date." -#: sl/SL_Menu.py:2812 +#: sl/SL_Menu.py:3118 msgid "Report Result" msgstr "Report Result" -#: sl/SL_Menu.py:2841 +#: sl/SL_Menu.py:3147 msgid "Export Report" msgstr "Export Report" -#: tt/TimeTracker.py:91 +#: tt/TimeTracker.py:191 #, python-brace-format msgid "Warning: Could not read {file}. Error: {error}" msgstr "Warning: Could not read {file}. Error: {error}" -#: tt/TimeTracker.py:107 +#: tt/TimeTracker.py:207 msgid "Some required packages are missing. Attempting to install them..." msgstr "Some required packages are missing. Attempting to install them..." -#: tt/TimeTracker.py:110 +#: tt/TimeTracker.py:210 #, python-brace-format msgid "Installing {package}..." msgstr "Installing {package}..." -#: tt/TimeTracker.py:114 +#: tt/TimeTracker.py:217 #, python-brace-format msgid "Failed to install {package}. Continuing without it." msgstr "Failed to install {package}. Continuing without it." -#: tt/TimeTracker.py:118 +#: tt/TimeTracker.py:220 +#, python-brace-format +msgid "" +"Timed out installing {package} (no internet connection?). Continuing without " +"it." +msgstr "" +"Timed out installing {package} (no internet connection?). Continuing without " +"it." + +#: tt/TimeTracker.py:224 msgid "" "\n" "Dependencies installed successfully." @@ -1188,11 +1380,11 @@ msgstr "" "\n" "Dependencies installed successfully." -#: tt/TimeTracker.py:119 +#: tt/TimeTracker.py:225 msgid "Please restart the application for the changes to take effect." msgstr "Please restart the application for the changes to take effect." -#: tt/TimeTracker.py:122 +#: tt/TimeTracker.py:228 #, python-brace-format msgid "" "\n" @@ -1201,21 +1393,21 @@ msgstr "" "\n" "Warning: Some dependencies could not be installed: {packages}" -#: tt/TimeTracker.py:124 +#: tt/TimeTracker.py:230 #, python-brace-format msgid "An unexpected error occurred during dependency check: {error}" msgstr "An unexpected error occurred during dependency check: {error}" -#: tt/TimeTracker.py:251 +#: tt/TimeTracker.py:452 msgid "Info: Report content has been copied to the clipboard." msgstr "Info: Report content has been copied to the clipboard." -#: tt/TimeTracker.py:253 +#: tt/TimeTracker.py:454 #, python-brace-format msgid "Warning: Could not copy to clipboard. Error: {error}" msgstr "Warning: Could not copy to clipboard. Error: {error}" -#: tt/TimeTracker.py:255 +#: tt/TimeTracker.py:456 msgid "" "Warning: Could not copy to clipboard. Please install 'pyperclip' (`pip " "install pyperclip`)." @@ -1223,56 +1415,56 @@ msgstr "" "Warning: Could not copy to clipboard. Please install 'pyperclip' (`pip " "install pyperclip`)." -#: tt/TimeTracker.py:275 +#: tt/TimeTracker.py:476 #, python-brace-format msgid "{hours} hours ({dlp} DLP)" msgstr "{hours} hours ({dlp} DLP)" -#: tt/TimeTracker.py:877 tt/TimeTracker.py:917 +#: tt/TimeTracker.py:1218 tt/TimeTracker.py:1263 #, python-brace-format msgid "Source main project '{name}' not found." msgstr "Source main project '{name}' not found." -#: tt/TimeTracker.py:879 +#: tt/TimeTracker.py:1220 #, python-brace-format msgid "Destination main project '{name}' not found." msgstr "Destination main project '{name}' not found." -#: tt/TimeTracker.py:891 +#: tt/TimeTracker.py:1237 #, python-brace-format msgid "Task '{task_name}' moved successfully." msgstr "Task '{task_name}' moved successfully." -#: tt/TimeTracker.py:892 tt/TimeTracker.py:927 tt/TimeTracker.py:1416 +#: tt/TimeTracker.py:1238 tt/TimeTracker.py:1273 tt/TimeTracker.py:1874 #, python-brace-format msgid "Task '{task_name}' not found in '{main_name}'." msgstr "Task '{task_name}' not found in '{main_name}'." -#: tt/TimeTracker.py:911 +#: tt/TimeTracker.py:1257 #, python-brace-format msgid "A main project named '{name}' already exists." msgstr "A main project named '{name}' already exists." -#: tt/TimeTracker.py:936 +#: tt/TimeTracker.py:1305 msgid "General" msgstr "General" -#: tt/TimeTracker.py:940 +#: tt/TimeTracker.py:1343 #, python-brace-format msgid "Task '{task_name}' was promoted to a new main project." msgstr "Task '{task_name}' was promoted to a new main project." -#: tt/TimeTracker.py:967 +#: tt/TimeTracker.py:1370 #, python-brace-format msgid "Main project to demote '{name}' not found." msgstr "Main project to demote '{name}' not found." -#: tt/TimeTracker.py:969 +#: tt/TimeTracker.py:1372 #, python-brace-format msgid "New parent main project '{name}' not found." msgstr "New parent main project '{name}' not found." -#: tt/TimeTracker.py:994 +#: tt/TimeTracker.py:1427 #, python-brace-format msgid "" "Main project '{demoted_name}' was demoted to a sub-project under " @@ -1281,42 +1473,42 @@ msgstr "" "Main project '{demoted_name}' was demoted to a sub-project under " "'{parent_name}'." -#: tt/TimeTracker.py:1076 +#: tt/TimeTracker.py:1521 msgid "Email import is not enabled." msgstr "Email import is not enabled." -#: tt/TimeTracker.py:1085 +#: tt/TimeTracker.py:1530 msgid "Email settings are incomplete." msgstr "Email settings are incomplete." -#: tt/TimeTracker.py:1098 +#: tt/TimeTracker.py:1543 msgid "Error searching emails." msgstr "Error searching emails." -#: tt/TimeTracker.py:1113 +#: tt/TimeTracker.py:1558 msgid "No Subject" msgstr "No Subject" -#: tt/TimeTracker.py:1173 +#: tt/TimeTracker.py:1631 msgid "Unknown Task" msgstr "Unknown Task" -#: tt/TimeTracker.py:1373 +#: tt/TimeTracker.py:1831 #, python-brace-format msgid "- {name}: {hours} hours" msgstr "- {name}: {hours} hours" -#: tt/TimeTracker.py:1381 +#: tt/TimeTracker.py:1839 #, python-brace-format msgid "## {name} ({hours} hours)\n" msgstr "## {name} ({hours} hours)\n" -#: tt/TimeTracker.py:1390 +#: tt/TimeTracker.py:1848 #, python-brace-format msgid "# Daily Time Report: {date}\n" msgstr "# Daily Time Report: {date}\n" -#: tt/TimeTracker.py:1391 +#: tt/TimeTracker.py:1849 #, python-brace-format msgid "" "\n" @@ -1325,104 +1517,104 @@ msgstr "" "\n" "**Total Daily Time: {hours} hours**" -#: tt/TimeTracker.py:1395 tt/TimeTracker.py:1708 +#: tt/TimeTracker.py:1853 tt/TimeTracker.py:2166 #, python-brace-format msgid "No time tracked for {date}." msgstr "No time tracked for {date}." -#: tt/TimeTracker.py:1412 tt/TimeTracker.py:1507 +#: tt/TimeTracker.py:1870 tt/TimeTracker.py:1965 #, python-brace-format msgid "Main project '{name}' not found." msgstr "Main project '{name}' not found." -#: tt/TimeTracker.py:1420 +#: tt/TimeTracker.py:1878 #, python-brace-format msgid "No time entries found for task '{task_name}'." msgstr "No time entries found for task '{task_name}'." -#: tt/TimeTracker.py:1453 tt/TimeTracker.py:1683 +#: tt/TimeTracker.py:1911 tt/TimeTracker.py:2141 msgid "now" msgstr "now" -#: tt/TimeTracker.py:1458 +#: tt/TimeTracker.py:1916 #, python-brace-format msgid "# Detailed Report for Task: {name}" msgstr "# Detailed Report for Task: {name}" -#: tt/TimeTracker.py:1459 +#: tt/TimeTracker.py:1917 #, python-brace-format msgid "Part of Main Project: {name}" msgstr "Part of Main Project: {name}" -#: tt/TimeTracker.py:1462 +#: tt/TimeTracker.py:1920 msgid "Active (currently running)" msgstr "Active (currently running)" -#: tt/TimeTracker.py:1462 tt/TimeTracker.py:1559 +#: tt/TimeTracker.py:1920 tt/TimeTracker.py:2017 msgid "Inactive" msgstr "Inactive" -#: tt/TimeTracker.py:1463 tt/TimeTracker.py:1560 +#: tt/TimeTracker.py:1921 tt/TimeTracker.py:2018 msgid "Status" msgstr "Status" -#: tt/TimeTracker.py:1465 tt/TimeTracker.py:1562 +#: tt/TimeTracker.py:1923 tt/TimeTracker.py:2020 msgid "First entry" msgstr "First entry" -#: tt/TimeTracker.py:1467 tt/TimeTracker.py:1564 +#: tt/TimeTracker.py:1925 tt/TimeTracker.py:2022 msgid "Last activity" msgstr "Last activity" -#: tt/TimeTracker.py:1469 tt/TimeTracker.py:1566 +#: tt/TimeTracker.py:1927 tt/TimeTracker.py:2024 msgid "Total recorded time" msgstr "Total recorded time" -#: tt/TimeTracker.py:1470 tt/TimeTracker.py:1568 +#: tt/TimeTracker.py:1928 tt/TimeTracker.py:2026 msgid "Total work sessions" msgstr "Total work sessions" -#: tt/TimeTracker.py:1474 tt/TimeTracker.py:1572 +#: tt/TimeTracker.py:1932 tt/TimeTracker.py:2030 msgid "Average session duration" msgstr "Average session duration" -#: tt/TimeTracker.py:1477 tt/TimeTracker.py:1575 +#: tt/TimeTracker.py:1935 tt/TimeTracker.py:2033 msgid "Weekday Distribution" msgstr "Weekday Distribution" -#: tt/TimeTracker.py:1487 +#: tt/TimeTracker.py:1945 msgid "Daily Breakdown" msgstr "Daily Breakdown" -#: tt/TimeTracker.py:1556 +#: tt/TimeTracker.py:2014 #, python-brace-format msgid "# Detailed Report for Main Project: {name}" msgstr "# Detailed Report for Main Project: {name}" -#: tt/TimeTracker.py:1559 +#: tt/TimeTracker.py:2017 #, python-brace-format msgid "Active (working on '{task_name}')" msgstr "Active (working on '{task_name}')" -#: tt/TimeTracker.py:1567 +#: tt/TimeTracker.py:2025 msgid "Number of tasks" msgstr "Number of tasks" -#: tt/TimeTracker.py:1586 +#: tt/TimeTracker.py:2044 msgid "Task Breakdown" msgstr "Task Breakdown" -#: tt/TimeTracker.py:1595 +#: tt/TimeTracker.py:2053 #, python-brace-format msgid "{num_sessions} sessions" msgstr "{num_sessions} sessions" -#: tt/TimeTracker.py:1648 +#: tt/TimeTracker.py:2106 #, python-brace-format msgid "# Time Report: {start_date} to {end_date}\n" msgstr "# Time Report: {start_date} to {end_date}\n" -#: tt/TimeTracker.py:1649 +#: tt/TimeTracker.py:2107 #, python-brace-format msgid "" "\n" @@ -1431,17 +1623,17 @@ msgstr "" "\n" "**Total Time in Period: {total_time}**" -#: tt/TimeTracker.py:1653 +#: tt/TimeTracker.py:2111 #, python-brace-format msgid "No time tracked between {start_date} and {end_date}." msgstr "No time tracked between {start_date} and {end_date}." -#: tt/TimeTracker.py:1669 +#: tt/TimeTracker.py:2127 #, python-brace-format msgid "# Detailed Daily Report: {date}" msgstr "# Detailed Daily Report: {date}" -#: update.py:35 +#: update.py:101 msgid "" "Warning: Update check skipped. 'github_repo' not found in config.json or " "file is invalid." @@ -1449,80 +1641,90 @@ msgstr "" "Warning: Update check skipped. 'github_repo' not found in config.json or " "file is invalid." -#: update.py:55 +#: update.py:121 msgid "Error: Download URL for the new version not found." msgstr "Error: Download URL for the new version not found." -#: update.py:59 +#: update.py:125 +msgid "Warning: Update check timed out (no internet connection?). Skipping." +msgstr "Warning: Update check timed out (no internet connection?). Skipping." + +#: update.py:127 #, python-brace-format msgid "Error checking for updates: {error}" msgstr "Error checking for updates: {error}" -#: update.py:61 +#: update.py:129 #, python-brace-format msgid "An unexpected error occurred while checking for updates: {error}" msgstr "An unexpected error occurred while checking for updates: {error}" -#: update.py:73 +#: update.py:141 msgid "Downloading update..." msgstr "Downloading update..." -#: update.py:79 +#: update.py:147 msgid "Download complete. The update will be installed on the next start." msgstr "Download complete. The update will be installed on the next start." -#: update.py:82 +#: update.py:150 +msgid "" +"Error: Connecting to the update server timed out (no internet connection?)." +msgstr "" +"Error: Connecting to the update server timed out (no internet connection?)." + +#: update.py:155 #, python-brace-format msgid "Error downloading the update: {error}" msgstr "Error downloading the update: {error}" -#: update.py:98 +#: update.py:171 msgid "Restarting application to apply the update..." msgstr "Restarting application to apply the update..." -#: update.py:122 +#: update.py:195 msgid "Creating backup of current version before update..." msgstr "Creating backup of current version before update..." -#: update.py:132 +#: update.py:205 #, python-brace-format msgid "Backup created successfully as {filename}." msgstr "Backup created successfully as {filename}." -#: update.py:134 +#: update.py:207 #, python-brace-format msgid "Warning: Could not create backup. Error: {error}" msgstr "Warning: Could not create backup. Error: {error}" -#: update.py:136 +#: update.py:209 msgid "Installing update..." msgstr "Installing update..." -#: update.py:156 +#: update.py:229 #, python-brace-format msgid "Skipping protected file: {filename}. It will not be overwritten." msgstr "Skipping protected file: {filename}. It will not be overwritten." -#: update.py:165 +#: update.py:238 msgid "Update installed successfully." msgstr "Update installed successfully." -#: update.py:167 +#: update.py:240 #, python-brace-format msgid "Error during update installation: {error}" msgstr "Error during update installation: {error}" -#: update.py:182 +#: update.py:255 #, python-brace-format msgid "Error: No previous version backup '{filename}' found." msgstr "Error: No previous version backup '{filename}' found." -#: update.py:185 +#: update.py:258 #, python-brace-format msgid "Restoring previous version from '{filename}'..." msgstr "Restoring previous version from '{filename}'..." -#: update.py:206 +#: update.py:279 #, python-brace-format msgid "" "Skipping user data file: {filename}. It will not be overwritten during " @@ -1531,33 +1733,33 @@ msgstr "" "Skipping user data file: {filename}. It will not be overwritten during " "restore." -#: update.py:213 +#: update.py:286 msgid "Previous version restored successfully." msgstr "Previous version restored successfully." -#: update.py:215 +#: update.py:288 msgid "Restarting application to apply changes..." msgstr "Restarting application to apply changes..." -#: update.py:218 +#: update.py:291 #, python-brace-format msgid "Error during restoration: {error}" msgstr "Error during restoration: {error}" -#: update.py:219 +#: update.py:292 #, python-brace-format msgid "The backup file '{filename}' was not deleted." msgstr "The backup file '{filename}' was not deleted." -#: update.py:226 +#: update.py:299 msgid "Error: Could not import TimeTracker to get the current version." msgstr "Error: Could not import TimeTracker to get the current version." -#: update.py:229 +#: update.py:302 msgid "Checking for updates..." msgstr "Checking for updates..." -#: update.py:236 +#: update.py:309 msgid "No updates available." msgstr "No updates available." diff --git a/locale/es/LC_MESSAGES/timetracker.mo b/locale/es/LC_MESSAGES/timetracker.mo index 05d5f872e2a1d3a7dd2dd7376f670cfdffa87e5a..a64e7a3f91d4824dcd09c0777779b45907d8fc61 100644 GIT binary patch delta 12771 zcmb7|34B%6wTDjv$UF*!nZukAxXkkqAQ0wB2nYgta_>oU$<4i&I|RevRjJBzz#&hq zS`}?;ov=b|t*AJpEhyEu>HMvey)6b@4JV!*IIk+ zjlcUaYsdB3nRh!iz1ia1mStJJ;K|mO^)=t|U#s852LdL`^`Ss5!rA)kgT;c&PQYN2DMgVlmrG_XIU z$(jb+!wVt0S~1uHZiP+Yci=R*6V8CYgq>mY0YSt^GWD~t4}1q|p_5SkI^`P64TPF_BGkf5Azri2hiu;3 z1{FK^!aVqMsQHfPVt)l!TUxc#T&SJSfLgd1E`$kK01rU9;2Ss>_8?Fu!xGpV-U&y; zC*ewX9AYG^kO&_KH$tM`+6`st{TT{lD0~EElAg3GSZt{F0PF-y;5jf2Tf=RzCEV$^ z-vpaezYQu@?t!w%Ua0;rz)kRV*a8-D)a1I%QVKFn3XX@jKw020ltq3Im%+cows2v< za1E4&%Aj2G9jN}dLoK`yDyUzC8ut!t1wZlIKZk2{{#%VO14D2*9kxLmz6`OR^(oZO zdyF(Y?gwSkd?-cFgIcHzD)_cS?JxtS$WFh0Hk9l zT~JZ`eJI}?go@rj`}Ix)gcP0NGYB~d*7Xo;T8~2l+xi2X0Nal>ip+&msjr3S!dsyf zeh6ly$qN+t&-#Er?O}I9RT}3(wXcTicP*4+_rosmFqBW<_v^>uJnBseqz-T~)O;JD zVjv2&@HQwH-8LTkccySJ4a#JX`#b^(RO@4?OQtK^k!y;fCSD7%s#O6M^>;#9;)hTZ zANJedfI4pP!W{S|RIqoLXfCJG6S2QcUQC0|aW(7&AA|DkL8yVp{q}Pv`4)n0XwQQ> zhU1}vbOvk(=RyVBDyW^8_>9A@)GvV=cXNh(Qe90+^TJ_+jfD~9U-T`0>u1w9u)x$+3)ny~%><iX7C?Sv6F?i=wNk$ z8(}dlgU`Z@G|MeCJ6#3ysaL?c@K)F#z6X^DvS*v1n*iriFM~7SUf3J{6|RIG=3pi0 zLd|zKl;sXV1@T8P0=pMs|7R#%S!9Bxh*g(SUkVj`yJ0iwW37Hp3nN5BbCL0JZM$}Wc*cRM6L zGu8nL3JTeKBp3~4fgsex8K{Ntg*wLvVF&mwYzseyP2txN9j)y1OiV0?8h<0yypO>R z@I$EavsWl+@qaM|nJNi&F1J8+d=$2T?Fs2lup1l>^IzBZcCc1&bIQSq`iaZ8&p1*=pC}*`UxE!kA zqfkNjGL(WxeSQjCQg3>`dD69mvh)b3jg43Sm1&A-I2W#f-C!A<0xyM=;ZsmM`2uQz z9-KLuas3dYx=<~s=$d=u9i#n-LH{yH{WXy^s+ff{%Kmcu{8 zxv=;GoV9LDmHF}ve4~N z$2(I;p@722uouj`$e6SU;y!BwRPg-N=L=A-I0|j}HPpC1j)|2iP%c>owV^1KA{p2h zUI*iF59~*?)u+Uy)){apKYR~Ph6kZ6lN~g{)dCKq-Whg-GofN;EmXfKR4k?70C<^S z{~=V=?}G}`=U_fO45#S)e?>u2Ixb{PTL8yXUjWh2x(v!^--rF-At>J-hcabf*YukQ zds5#B2f-`hZ1^x#jC}^>+HR%BatmO8*0(AsjDWkKIvjvw;Rz^93@S74_lux@zY|J< zSKu_3>oNg8D80TH9& z9H^aC!gJtbP$~FHsGYtH+rUTze4S}S0&ID)ap_DrnDwm$g)#6psN;ATc7h$N4F~$10K3t? z0BWHi>!g^_S4TnwLpI_J%| z8O`(IDC!Pe1Z&^|cnr$N`OI1Zmq0DJ7iyST?!LSfU;5NWBZ}3Wq=i-*l*B z7`%)HHDQDXnLGtAgjd1o@FPf4v<728x#k8aSG^9|ywzfd38pKc7XBO#fLUx)!8!

& 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 Y^D3QT>lm*NyFR!Rr`~rp+Y_Px1y8Cxt^fc4 delta 7542 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 UUO3*pci}4c%teje5l>F~FL{SrT>t<8 diff --git a/locale/es/LC_MESSAGES/timetracker.po b/locale/es/LC_MESSAGES/timetracker.po index 08e5a43..6427716 100644 --- a/locale/es/LC_MESSAGES/timetracker.po +++ b/locale/es/LC_MESSAGES/timetracker.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Project-Id-Version: TimeControl\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-08-06 10:27+0200\n" +"POT-Creation-Date: 2026-08-11 17:36+0200\n" "PO-Revision-Date: 2026-01-01 14:40+0200\n" "Last-Translator: Frank Faulstich\n" "Language-Team: Spanish\n" @@ -16,426 +16,511 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: sl/SL_Menu.py:278 sl/SL_Menu.py:1005 sl/SL_Menu.py:2343 sl/SL_Menu.py:2515 +#: sl/SL_Menu.py:294 sl/SL_Menu.py:1134 sl/SL_Menu.py:2645 sl/SL_Menu.py:2817 msgid "Priority" msgstr "Prioridad" -#: sl/SL_Menu.py:462 +#: sl/SL_Menu.py:547 #, python-brace-format msgid "Version {version}" msgstr "Versión {version}" -#: sl/SL_Menu.py:467 update.py:49 +#: sl/SL_Menu.py:552 update.py:115 #, python-brace-format msgid "A new version ({version}) is available." msgstr "Una nueva versión ({version}) está disponible." -#: sl/SL_Menu.py:469 +#: sl/SL_Menu.py:554 msgid "Restart and install the update" msgstr "Reiniciar e instalar la actualización" -#: sl/SL_Menu.py:470 +#: sl/SL_Menu.py:555 msgid "Downloading and installing update..." msgstr "Descargando e instalando la actualización..." -#: sl/SL_Menu.py:501 +#: sl/SL_Menu.py:590 +#, python-brace-format +msgid "" +"{count} time entries were discarded because the task they belonged to had " +"been deleted on another machine." +msgstr "Se descartaron {count} entradas de tiempo porque la tarea a la que pertenecían se había eliminado en otro ordenador." + +#: sl/SL_Menu.py:613 +#, python-brace-format +msgid "Synchronisation is paused: {reason}" +msgstr "La sincronización está detenida: {reason}" + +#: sl/SL_Menu.py:635 msgid "New" msgstr "Nuevo" -#: sl/SL_Menu.py:502 +#: sl/SL_Menu.py:636 msgid "New Project" msgstr "Nuevo proyecto" -#: sl/SL_Menu.py:505 +#: sl/SL_Menu.py:639 msgid "New Task" msgstr "Nueva tarea" -#: sl/SL_Menu.py:510 +#: sl/SL_Menu.py:644 msgid "Project & Task Management" msgstr "Gestión de proyectos y tareas" -#: sl/SL_Menu.py:511 sl/SL_Menu.py:1217 +#: sl/SL_Menu.py:645 sl/SL_Menu.py:1349 msgid "Main Project Management" msgstr "Gestión de Proyectos Principales" -#: sl/SL_Menu.py:512 sl/SL_Menu.py:1233 sl/SL_Menu.py:1628 +#: sl/SL_Menu.py:646 sl/SL_Menu.py:1365 sl/SL_Menu.py:1930 msgid "Add Project" msgstr "Añadir Proyecto" -#: sl/SL_Menu.py:515 sl/SL_Menu.py:1236 sl/SL_Menu.py:1976 +#: sl/SL_Menu.py:649 sl/SL_Menu.py:1368 sl/SL_Menu.py:2278 msgid "List Projects" msgstr "Listar Proyectos" -#: sl/SL_Menu.py:518 sl/SL_Menu.py:1239 sl/SL_Menu.py:1991 +#: sl/SL_Menu.py:652 sl/SL_Menu.py:1371 sl/SL_Menu.py:2293 msgid "Rename Project" msgstr "Renombrar Proyecto" -#: sl/SL_Menu.py:521 sl/SL_Menu.py:1242 sl/SL_Menu.py:2106 sl/SL_Menu.py:2119 +#: sl/SL_Menu.py:655 sl/SL_Menu.py:1374 sl/SL_Menu.py:2408 sl/SL_Menu.py:2421 msgid "Close Project" msgstr "Cerrar Proyecto" -#: sl/SL_Menu.py:524 sl/SL_Menu.py:1245 sl/SL_Menu.py:2136 sl/SL_Menu.py:2149 +#: sl/SL_Menu.py:658 sl/SL_Menu.py:1377 sl/SL_Menu.py:2438 sl/SL_Menu.py:2451 msgid "Re-open Project" msgstr "Reabrir Proyecto" -#: sl/SL_Menu.py:527 sl/SL_Menu.py:1248 sl/SL_Menu.py:2166 sl/SL_Menu.py:2180 +#: sl/SL_Menu.py:661 sl/SL_Menu.py:1380 sl/SL_Menu.py:2468 sl/SL_Menu.py:2482 msgid "Delete Project" msgstr "Eliminar Proyecto" -#: sl/SL_Menu.py:530 sl/SL_Menu.py:1251 sl/SL_Menu.py:2197 +#: sl/SL_Menu.py:664 sl/SL_Menu.py:1383 sl/SL_Menu.py:2499 msgid "List Inactive Projects" msgstr "Listar Proyectos Inactivos" -#: sl/SL_Menu.py:533 sl/SL_Menu.py:1254 +#: sl/SL_Menu.py:667 sl/SL_Menu.py:1386 msgid "Demote Project to Task" msgstr "Degradar Proyecto a Tarea" -#: sl/SL_Menu.py:536 sl/SL_Menu.py:1257 sl/SL_Menu.py:2259 +#: sl/SL_Menu.py:670 sl/SL_Menu.py:1389 sl/SL_Menu.py:2561 msgid "List Completed Projects" msgstr "Listar Proyectos Completados" -#: sl/SL_Menu.py:540 sl/SL_Menu.py:1219 sl/SL_Menu.py:1270 +#: sl/SL_Menu.py:674 sl/SL_Menu.py:1351 sl/SL_Menu.py:1402 msgid "Task Management" msgstr "Gestión de Tareas" -#: sl/SL_Menu.py:541 sl/SL_Menu.py:1272 sl/SL_Menu.py:2277 sl/SL_Menu.py:2308 -#: sl/SL_Menu.py:2383 +#: sl/SL_Menu.py:675 sl/SL_Menu.py:1404 sl/SL_Menu.py:2579 sl/SL_Menu.py:2610 +#: sl/SL_Menu.py:2685 msgid "Add Task" msgstr "Añadir Tarea" -#: sl/SL_Menu.py:544 sl/SL_Menu.py:1275 sl/SL_Menu.py:2026 +#: sl/SL_Menu.py:678 sl/SL_Menu.py:1407 sl/SL_Menu.py:2328 msgid "List Tasks" msgstr "Listar Tareas" -#: sl/SL_Menu.py:547 sl/SL_Menu.py:1278 sl/SL_Menu.py:2059 +#: sl/SL_Menu.py:681 sl/SL_Menu.py:1410 sl/SL_Menu.py:2361 msgid "Rename Task" msgstr "Renombrar Tarea" -#: sl/SL_Menu.py:550 sl/SL_Menu.py:1281 sl/SL_Menu.py:1643 sl/SL_Menu.py:1669 -#: sl/SL_Menu.py:1848 +#: sl/SL_Menu.py:684 sl/SL_Menu.py:1413 sl/SL_Menu.py:1945 sl/SL_Menu.py:1971 +#: sl/SL_Menu.py:2150 msgid "Close Task" msgstr "Cerrar Tarea" -#: sl/SL_Menu.py:553 sl/SL_Menu.py:1284 sl/SL_Menu.py:1688 sl/SL_Menu.py:1714 +#: sl/SL_Menu.py:687 sl/SL_Menu.py:1416 sl/SL_Menu.py:1990 sl/SL_Menu.py:2016 msgid "Re-open Task" msgstr "Reabrir Tarea" -#: sl/SL_Menu.py:556 sl/SL_Menu.py:1287 sl/SL_Menu.py:1733 sl/SL_Menu.py:1760 +#: sl/SL_Menu.py:690 sl/SL_Menu.py:1419 sl/SL_Menu.py:2035 sl/SL_Menu.py:2062 msgid "Delete Task" msgstr "Eliminar Tarea" -#: sl/SL_Menu.py:559 sl/SL_Menu.py:1290 sl/SL_Menu.py:1779 sl/SL_Menu.py:1815 +#: sl/SL_Menu.py:693 sl/SL_Menu.py:1422 sl/SL_Menu.py:2081 sl/SL_Menu.py:2117 msgid "Move Task" msgstr "Mover Tarea" -#: sl/SL_Menu.py:562 sl/SL_Menu.py:1293 sl/SL_Menu.py:1834 +#: sl/SL_Menu.py:696 sl/SL_Menu.py:1425 sl/SL_Menu.py:2136 msgid "List Inactive Tasks" msgstr "Listar Tareas Inactivas" -#: sl/SL_Menu.py:565 sl/SL_Menu.py:1296 sl/SL_Menu.py:1861 +#: sl/SL_Menu.py:699 sl/SL_Menu.py:1428 sl/SL_Menu.py:2163 msgid "List All Closed Tasks" msgstr "Listar Todas las Tareas Cerradas" -#: sl/SL_Menu.py:568 sl/SL_Menu.py:727 sl/SL_Menu.py:813 sl/SL_Menu.py:1027 -#: sl/SL_Menu.py:1299 sl/SL_Menu.py:2411 sl/SL_Menu.py:2431 sl/SL_Menu.py:2466 +#: sl/SL_Menu.py:702 sl/SL_Menu.py:861 sl/SL_Menu.py:945 sl/SL_Menu.py:1155 +#: sl/SL_Menu.py:1431 sl/SL_Menu.py:2713 sl/SL_Menu.py:2733 sl/SL_Menu.py:2768 msgid "Edit Task" msgstr "Editar tarea" -#: sl/SL_Menu.py:571 sl/SL_Menu.py:1302 sl/SL_Menu.py:1886 +#: sl/SL_Menu.py:705 sl/SL_Menu.py:1434 sl/SL_Menu.py:2188 msgid "Delete All Closed Tasks" msgstr "Eliminar Todas las Tareas Cerradas" -#: sl/SL_Menu.py:574 sl/SL_Menu.py:1305 sl/SL_Menu.py:1928 +#: sl/SL_Menu.py:708 sl/SL_Menu.py:1437 sl/SL_Menu.py:2230 msgid "Promote Task to Project" msgstr "Promover Tarea a Proyecto" -#: sl/SL_Menu.py:579 +#: sl/SL_Menu.py:713 msgid "Today View" msgstr "Vista de hoy" -#: sl/SL_Menu.py:584 sl/SL_Menu.py:643 +#: sl/SL_Menu.py:718 sl/SL_Menu.py:777 msgid "Task Planning" msgstr "Planificación de tareas" -#: sl/SL_Menu.py:589 sl/SL_Menu.py:1062 +#: sl/SL_Menu.py:723 sl/SL_Menu.py:1189 msgid "E-Mail Task Assignment" msgstr "Asignación de tareas por correo" -#: sl/SL_Menu.py:594 sl/SL_Menu.py:723 sl/SL_Menu.py:809 sl/SL_Menu.py:1023 +#: sl/SL_Menu.py:728 sl/SL_Menu.py:857 sl/SL_Menu.py:941 sl/SL_Menu.py:1151 msgid "Start work on task" msgstr "Iniciar trabajo en tarea" -#: sl/SL_Menu.py:599 +#: sl/SL_Menu.py:733 msgid "Show current work" msgstr "Mostrar trabajo actual" -#: sl/SL_Menu.py:604 +#: sl/SL_Menu.py:738 msgid "Stop current work" msgstr "Detener trabajo actual" -#: sl/SL_Menu.py:606 +#: sl/SL_Menu.py:740 msgid "Work session stopped successfully." msgstr "Sesión de trabajo detenida con éxito." -#: sl/SL_Menu.py:608 +#: sl/SL_Menu.py:742 msgid "No active work session to stop." msgstr "No hay ninguna sesión de trabajo activa para detener." -#: sl/SL_Menu.py:612 sl/SL_Menu.py:1318 +#: sl/SL_Menu.py:746 sl/SL_Menu.py:1450 msgid "Reporting" msgstr "Informes" -#: sl/SL_Menu.py:613 sl/SL_Menu.py:1321 +#: sl/SL_Menu.py:747 sl/SL_Menu.py:1453 msgid "Daily Report (Today)" msgstr "Informe Diario (Hoy)" -#: sl/SL_Menu.py:618 sl/SL_Menu.py:1327 sl/SL_Menu.py:2661 +#: sl/SL_Menu.py:752 sl/SL_Menu.py:1459 sl/SL_Menu.py:2967 msgid "Daily Report (Specific Day)" msgstr "Informe Diario (Día Específico)" -#: sl/SL_Menu.py:621 sl/SL_Menu.py:1330 sl/SL_Menu.py:2680 +#: sl/SL_Menu.py:755 sl/SL_Menu.py:1462 sl/SL_Menu.py:2986 msgid "Date Range Report" msgstr "Informe por Rango de Fechas" -#: sl/SL_Menu.py:624 sl/SL_Menu.py:1333 sl/SL_Menu.py:2707 sl/SL_Menu.py:2738 +#: sl/SL_Menu.py:758 sl/SL_Menu.py:1465 sl/SL_Menu.py:3013 sl/SL_Menu.py:3044 msgid "Detailed Task Report" msgstr "Informe Detallado de Tarea" -#: sl/SL_Menu.py:627 sl/SL_Menu.py:1336 sl/SL_Menu.py:2766 +#: sl/SL_Menu.py:761 sl/SL_Menu.py:1468 sl/SL_Menu.py:3072 msgid "Detailed Project Report" msgstr "Informe Detallado de Proyecto" -#: sl/SL_Menu.py:630 sl/SL_Menu.py:1339 sl/SL_Menu.py:2793 +#: sl/SL_Menu.py:764 sl/SL_Menu.py:1471 sl/SL_Menu.py:3099 msgid "Detailed Daily Report" msgstr "Informe Diario Detallado" -#: sl/SL_Menu.py:635 sl/SL_Menu.py:1356 +#: sl/SL_Menu.py:769 sl/SL_Menu.py:1518 msgid "Settings" msgstr "Configuración" -#: sl/SL_Menu.py:648 sl/SL_Menu.py:668 sl/SL_Menu.py:734 sl/SL_Menu.py:820 -#: sl/SL_Menu.py:1177 sl/SL_Menu.py:2338 sl/SL_Menu.py:2509 +#: sl/SL_Menu.py:782 sl/SL_Menu.py:802 sl/SL_Menu.py:868 sl/SL_Menu.py:952 +#: sl/SL_Menu.py:1304 sl/SL_Menu.py:2640 sl/SL_Menu.py:2811 msgid "Today" msgstr "hoy" -#: sl/SL_Menu.py:649 sl/SL_Menu.py:669 +#: sl/SL_Menu.py:783 sl/SL_Menu.py:803 msgid "Tomorrow" msgstr "Mañana" -#: sl/SL_Menu.py:650 sl/SL_Menu.py:670 +#: sl/SL_Menu.py:784 sl/SL_Menu.py:804 msgid "Weekly overview" msgstr "Resumen semanal" -#: sl/SL_Menu.py:651 sl/SL_Menu.py:671 +#: sl/SL_Menu.py:785 sl/SL_Menu.py:805 msgid "Overdue tasks" msgstr "Tareas vencidas" -#: sl/SL_Menu.py:652 sl/SL_Menu.py:672 +#: sl/SL_Menu.py:786 sl/SL_Menu.py:806 msgid "Unplanned tasks" msgstr "Tareas no planificadas" -#: sl/SL_Menu.py:653 +#: sl/SL_Menu.py:787 msgid "All" msgstr "Todo" -#: sl/SL_Menu.py:660 +#: sl/SL_Menu.py:794 msgid "Filter" msgstr "Filtro" -#: sl/SL_Menu.py:679 +#: sl/SL_Menu.py:813 msgid "Tasks" msgstr "Tareas" -#: sl/SL_Menu.py:686 tt/TimeTracker.py:1479 tt/TimeTracker.py:1577 +#: sl/SL_Menu.py:820 tt/TimeTracker.py:1937 tt/TimeTracker.py:2035 msgid "Friday" msgstr "Viernes" -#: sl/SL_Menu.py:686 tt/TimeTracker.py:1479 tt/TimeTracker.py:1577 +#: sl/SL_Menu.py:820 tt/TimeTracker.py:1937 tt/TimeTracker.py:2035 msgid "Monday" msgstr "Lunes" -#: sl/SL_Menu.py:686 tt/TimeTracker.py:1479 tt/TimeTracker.py:1577 +#: sl/SL_Menu.py:820 tt/TimeTracker.py:1937 tt/TimeTracker.py:2035 msgid "Saturday" msgstr "Sábado" -#: sl/SL_Menu.py:686 tt/TimeTracker.py:1479 tt/TimeTracker.py:1577 +#: sl/SL_Menu.py:820 tt/TimeTracker.py:1937 tt/TimeTracker.py:2035 msgid "Sunday" msgstr "Domingo" -#: sl/SL_Menu.py:686 tt/TimeTracker.py:1479 tt/TimeTracker.py:1577 +#: sl/SL_Menu.py:820 tt/TimeTracker.py:1937 tt/TimeTracker.py:2035 msgid "Thursday" msgstr "Jueves" -#: sl/SL_Menu.py:686 tt/TimeTracker.py:1479 tt/TimeTracker.py:1577 +#: sl/SL_Menu.py:820 tt/TimeTracker.py:1937 tt/TimeTracker.py:2035 msgid "Tuesday" msgstr "Martes" -#: sl/SL_Menu.py:686 tt/TimeTracker.py:1479 tt/TimeTracker.py:1577 +#: sl/SL_Menu.py:820 tt/TimeTracker.py:1937 tt/TimeTracker.py:2035 msgid "Wednesday" msgstr "Miércoles" -#: sl/SL_Menu.py:747 sl/SL_Menu.py:833 sl/SL_Menu.py:885 sl/SL_Menu.py:1034 -#: sl/SL_Menu.py:2511 +#: sl/SL_Menu.py:880 sl/SL_Menu.py:964 sl/SL_Menu.py:1015 sl/SL_Menu.py:1162 +#: sl/SL_Menu.py:2813 msgid "Done" msgstr "Hecho" -#: sl/SL_Menu.py:797 sl/SL_Menu.py:993 +#: sl/SL_Menu.py:929 sl/SL_Menu.py:1122 msgid "Due" msgstr "Vencimiento" -#: sl/SL_Menu.py:848 +#: sl/SL_Menu.py:978 msgid "No tasks found." msgstr "No Tareas encontradas." -#: sl/SL_Menu.py:850 sl/SL_Menu.py:1206 sl/SL_Menu.py:1224 sl/SL_Menu.py:1263 -#: sl/SL_Menu.py:1311 sl/SL_Menu.py:1345 sl/SL_Menu.py:1615 sl/SL_Menu.py:1648 -#: sl/SL_Menu.py:1659 sl/SL_Menu.py:1693 sl/SL_Menu.py:1704 sl/SL_Menu.py:1738 -#: sl/SL_Menu.py:1749 sl/SL_Menu.py:1784 sl/SL_Menu.py:1795 sl/SL_Menu.py:1803 -#: sl/SL_Menu.py:1854 sl/SL_Menu.py:1879 sl/SL_Menu.py:1901 sl/SL_Menu.py:1933 -#: sl/SL_Menu.py:1944 sl/SL_Menu.py:1984 sl/SL_Menu.py:1996 sl/SL_Menu.py:2031 -#: sl/SL_Menu.py:2052 sl/SL_Menu.py:2064 sl/SL_Menu.py:2075 sl/SL_Menu.py:2111 -#: sl/SL_Menu.py:2141 sl/SL_Menu.py:2171 sl/SL_Menu.py:2211 sl/SL_Menu.py:2223 -#: sl/SL_Menu.py:2270 sl/SL_Menu.py:2282 sl/SL_Menu.py:2416 sl/SL_Menu.py:2435 -#: sl/SL_Menu.py:2448 sl/SL_Menu.py:2463 sl/SL_Menu.py:2600 sl/SL_Menu.py:2610 -#: sl/SL_Menu.py:2654 sl/SL_Menu.py:2673 sl/SL_Menu.py:2700 sl/SL_Menu.py:2712 -#: sl/SL_Menu.py:2724 sl/SL_Menu.py:2743 sl/SL_Menu.py:2759 sl/SL_Menu.py:2771 -#: sl/SL_Menu.py:2786 sl/SL_Menu.py:2805 sl/SL_Menu.py:2846 +#: sl/SL_Menu.py:980 sl/SL_Menu.py:1338 sl/SL_Menu.py:1356 sl/SL_Menu.py:1395 +#: sl/SL_Menu.py:1443 sl/SL_Menu.py:1477 sl/SL_Menu.py:1917 sl/SL_Menu.py:1950 +#: sl/SL_Menu.py:1961 sl/SL_Menu.py:1995 sl/SL_Menu.py:2006 sl/SL_Menu.py:2040 +#: sl/SL_Menu.py:2051 sl/SL_Menu.py:2086 sl/SL_Menu.py:2097 sl/SL_Menu.py:2105 +#: sl/SL_Menu.py:2156 sl/SL_Menu.py:2181 sl/SL_Menu.py:2203 sl/SL_Menu.py:2235 +#: sl/SL_Menu.py:2246 sl/SL_Menu.py:2286 sl/SL_Menu.py:2298 sl/SL_Menu.py:2333 +#: sl/SL_Menu.py:2354 sl/SL_Menu.py:2366 sl/SL_Menu.py:2377 sl/SL_Menu.py:2413 +#: sl/SL_Menu.py:2443 sl/SL_Menu.py:2473 sl/SL_Menu.py:2513 sl/SL_Menu.py:2525 +#: sl/SL_Menu.py:2572 sl/SL_Menu.py:2584 sl/SL_Menu.py:2718 sl/SL_Menu.py:2737 +#: sl/SL_Menu.py:2750 sl/SL_Menu.py:2765 sl/SL_Menu.py:2906 sl/SL_Menu.py:2916 +#: sl/SL_Menu.py:2960 sl/SL_Menu.py:2979 sl/SL_Menu.py:3006 sl/SL_Menu.py:3018 +#: sl/SL_Menu.py:3030 sl/SL_Menu.py:3049 sl/SL_Menu.py:3065 sl/SL_Menu.py:3077 +#: sl/SL_Menu.py:3092 sl/SL_Menu.py:3111 sl/SL_Menu.py:3152 msgid "Back" msgstr "Atrás" -#: sl/SL_Menu.py:861 +#: sl/SL_Menu.py:991 msgid "Today's Tasks" msgstr "Tareas de hoy" -#: sl/SL_Menu.py:880 sl/SL_Menu.py:2639 +#: sl/SL_Menu.py:1010 sl/SL_Menu.py:2945 msgid "Current Active Work" msgstr "Trabajo Activo Actual" -#: sl/SL_Menu.py:882 sl/SL_Menu.py:2652 +#: sl/SL_Menu.py:1012 sl/SL_Menu.py:2958 msgid "No active work session." msgstr "No hay sesión de trabajo activa." -#: sl/SL_Menu.py:899 +#: sl/SL_Menu.py:1028 msgid "Edit current task" msgstr "Editar tarea actual" -#: sl/SL_Menu.py:923 +#: sl/SL_Menu.py:1052 msgid "Show only open tasks" msgstr "Mostrar solo tareas abiertas" -#: sl/SL_Menu.py:936 +#: sl/SL_Menu.py:1065 msgid "Sort by priority" msgstr "Ordenar por prioridad" -#: sl/SL_Menu.py:1008 sl/SL_Menu.py:2343 sl/SL_Menu.py:2515 +#: sl/SL_Menu.py:1137 sl/SL_Menu.py:2645 sl/SL_Menu.py:2817 msgid "0 (lowest) to 9 (highest)" msgstr "0 (la más baja) a 9 (la más alta)" -#: sl/SL_Menu.py:1049 +#: sl/SL_Menu.py:1176 msgid "No open tasks for today." msgstr "No hay tareas abiertas para hoy." -#: sl/SL_Menu.py:1051 +#: sl/SL_Menu.py:1178 msgid "No tasks for today." msgstr "No hay tareas para hoy." -#: sl/SL_Menu.py:1055 +#: sl/SL_Menu.py:1182 msgid "Exit" msgstr "Salir" -#: sl/SL_Menu.py:1068 +#: sl/SL_Menu.py:1195 msgid "Fetching emails..." msgstr "Recuperando correos electrónicos..." -#: sl/SL_Menu.py:1071 +#: sl/SL_Menu.py:1198 #, python-brace-format msgid "Error fetching emails: {error}" msgstr "Error al recuperar correos: {error}" -#: sl/SL_Menu.py:1074 +#: sl/SL_Menu.py:1201 #, python-brace-format msgid "{count} new tasks created from emails." msgstr "{count} nuevas tareas creadas desde correos." -#: sl/SL_Menu.py:1076 +#: sl/SL_Menu.py:1203 msgid "No new emails found." msgstr "No se encontraron correos nuevos." -#: sl/SL_Menu.py:1101 +#: sl/SL_Menu.py:1228 #, python-brace-format msgid "{remaining} of {total} emails still to process" msgstr "{remaining} de {total} correos electrónicos por procesar" -#: sl/SL_Menu.py:1105 +#: sl/SL_Menu.py:1232 msgid "No unassigned email tasks available." msgstr "No hay tareas de correo sin asignar disponibles." -#: sl/SL_Menu.py:1117 +#: sl/SL_Menu.py:1244 msgid "Assign Project" msgstr "Asignar proyecto" -#: sl/SL_Menu.py:1128 +#: sl/SL_Menu.py:1255 msgid "Are you sure you want to delete this task?" msgstr "¿Está seguro de que desea eliminar esta tarea?" -#: sl/SL_Menu.py:1131 +#: sl/SL_Menu.py:1258 msgid "Yes, delete" msgstr "Sí, eliminar" -#: sl/SL_Menu.py:1136 +#: sl/SL_Menu.py:1263 msgid "No, cancel" msgstr "No, cancelar" -#: sl/SL_Menu.py:1143 +#: sl/SL_Menu.py:1270 msgid "Delete" msgstr "Eliminar" -#: sl/SL_Menu.py:1147 +#: sl/SL_Menu.py:1274 msgid "Edit Details" msgstr "Editar detalles" -#: sl/SL_Menu.py:1155 sl/SL_Menu.py:2495 +#: sl/SL_Menu.py:1282 sl/SL_Menu.py:2797 msgid "Task Name" msgstr "Nombre de la tarea" -#: sl/SL_Menu.py:1164 sl/SL_Menu.py:2499 +#: sl/SL_Menu.py:1291 sl/SL_Menu.py:2801 msgid "Due Date" msgstr "Fecha de vencimiento" -#: sl/SL_Menu.py:1172 sl/SL_Menu.py:2503 +#: sl/SL_Menu.py:1299 sl/SL_Menu.py:2805 msgid "Clear" msgstr "Limpiar" -#: sl/SL_Menu.py:1179 sl/SL_Menu.py:2371 sl/SL_Menu.py:2544 +#: sl/SL_Menu.py:1306 sl/SL_Menu.py:2673 sl/SL_Menu.py:2846 msgid "Notes (Markdown)" msgstr "Notas (Markdown)" -#: sl/SL_Menu.py:1198 +#: sl/SL_Menu.py:1330 msgid "Task details updated successfully." msgstr "Detalles de la tarea actualizados con éxito." -#: sl/SL_Menu.py:1204 +#: sl/SL_Menu.py:1336 msgid "Error updating task details." msgstr "Error al actualizar los detalles de la tarea." -#: sl/SL_Menu.py:1215 sl/SL_Menu.py:1231 +#: sl/SL_Menu.py:1347 sl/SL_Menu.py:1363 msgid "Project Management" msgstr "Gestión de Proyectos" -#: sl/SL_Menu.py:1359 +#: sl/SL_Menu.py:1490 +msgid "No server address is set. Enter one above and save it first." +msgstr "" +"No hay ninguna dirección de servidor configurada. Introdúzcala arriba y " +"guárdela." + +#: sl/SL_Menu.py:1491 +msgid "" +"The address must start with https:// - a token sent over plain HTTP could be " +"read by anyone on the way." +msgstr "" +"La dirección debe empezar por https://: un token enviado por HTTP sin cifrar " +"podría ser leído por cualquiera en el camino." + +#: sl/SL_Menu.py:1493 +msgid "Please enter both a username and a password." +msgstr "Introduzca el usuario y la contraseña." + +#: sl/SL_Menu.py:1494 +msgid "Wrong username or password." +msgstr "Usuario o contraseña incorrectos." + +#: sl/SL_Menu.py:1495 +msgid "Too many sign-in attempts on the server. Try again in a minute." +msgstr "" +"Demasiados intentos de inicio de sesión en el servidor. Inténtelo de nuevo " +"en un minuto." + +#: sl/SL_Menu.py:1496 +msgid "The server's certificate could not be verified." +msgstr "No se pudo verificar el certificado del servidor." + +#: sl/SL_Menu.py:1497 +msgid "The server did not answer in time." +msgstr "El servidor no respondió a tiempo." + +#: sl/SL_Menu.py:1498 +msgid "The server could not be reached. Check the address and your connection." +msgstr "" +"No se pudo contactar con el servidor. Compruebe la dirección y su conexión." + +#: sl/SL_Menu.py:1499 +msgid "" +"The address answered, but not like a TimeControl sync server. Check that it " +"points at the right directory." +msgstr "" +"La dirección respondió, pero no como un servidor de sincronización de " +"TimeControl. Compruebe que apunta al directorio correcto." + +#: sl/SL_Menu.py:1501 +msgid "The server is reachable but has not been set up yet." +msgstr "El servidor responde, pero aún no está configurado." + +#: sl/SL_Menu.py:1503 +msgid "This device is not signed in to the server." +msgstr "Este dispositivo no ha iniciado sesión en el servidor." + +#: sl/SL_Menu.py:1504 sl/SL_Menu.py:1887 +msgid "This device is no longer signed in. Please sign in again." +msgstr "" +"Este dispositivo ya no tiene la sesión iniciada. Vuelva a iniciar sesión." + +#: sl/SL_Menu.py:1505 +msgid "The synchronisation files on this computer could not be written." +msgstr "" +"No se pudieron escribir los archivos de sincronización de este ordenador." + +#: sl/SL_Menu.py:1507 +#, python-brace-format +msgid "Sign-in failed ({code})." +msgstr "Error al iniciar sesión ({code})." + +#: sl/SL_Menu.py:1521 msgid "Change Language" msgstr "Cambiar idioma" -#: sl/SL_Menu.py:1377 +#: sl/SL_Menu.py:1539 msgid "Select Language" msgstr "Seleccionar idioma" -#: sl/SL_Menu.py:1378 sl/SL_Menu.py:1418 sl/SL_Menu.py:1459 sl/SL_Menu.py:1472 -#: sl/SL_Menu.py:1492 sl/SL_Menu.py:1526 sl/SL_Menu.py:1549 sl/SL_Menu.py:1604 +#: sl/SL_Menu.py:1540 sl/SL_Menu.py:1580 sl/SL_Menu.py:1621 sl/SL_Menu.py:1634 +#: sl/SL_Menu.py:1654 sl/SL_Menu.py:1688 sl/SL_Menu.py:1711 sl/SL_Menu.py:1766 +#: sl/SL_Menu.py:1804 msgid "Save" msgstr "Guardar" -#: sl/SL_Menu.py:1384 +#: sl/SL_Menu.py:1546 msgid "" "Language changed. Please restart the application for the changes to take " "effect." @@ -443,23 +528,23 @@ msgstr "" "Idioma cambiado. Por favor, reinicie la aplicación para que los cambios " "surtan efecto." -#: sl/SL_Menu.py:1387 +#: sl/SL_Menu.py:1549 msgid "Restore Previous Version" msgstr "Restaurar versión anterior" -#: sl/SL_Menu.py:1390 +#: sl/SL_Menu.py:1552 msgid "The 'update' module is not available. This feature is disabled." msgstr "" "El módulo 'update' no está disponible. Esta función está deshabilitada." -#: sl/SL_Menu.py:1392 +#: sl/SL_Menu.py:1554 #, python-brace-format msgid "No previous version backup '{filename}' found." msgstr "" "No se encontró ninguna copia de seguridad de la versión anterior " "'{filename}'." -#: sl/SL_Menu.py:1394 +#: sl/SL_Menu.py:1556 msgid "" "This will restore the application to the previously backed-up version. The " "application will then restart. You may need to manually refresh your browser " @@ -469,35 +554,35 @@ msgstr "" "aplicación se reiniciará. Es posible que deba actualizar manualmente su " "navegador si no se vuelve a conectar automáticamente." -#: sl/SL_Menu.py:1395 +#: sl/SL_Menu.py:1557 msgid "Restore and Restart" msgstr "Restaurar y reiniciar" -#: sl/SL_Menu.py:1396 +#: sl/SL_Menu.py:1558 msgid "Restoring and restarting..." msgstr "Restaurando y reiniciando..." -#: sl/SL_Menu.py:1399 +#: sl/SL_Menu.py:1561 msgid "Restore complete. Please restart the application." msgstr "Restauración completa. Reinicie la aplicación." -#: sl/SL_Menu.py:1401 +#: sl/SL_Menu.py:1563 msgid "Change Data Storage Location" msgstr "Cambiar ubicación de almacenamiento de datos" -#: sl/SL_Menu.py:1403 +#: sl/SL_Menu.py:1565 msgid "Current data file" msgstr "Archivo de datos actual" -#: sl/SL_Menu.py:1406 +#: sl/SL_Menu.py:1568 msgid "New Path for data file" msgstr "Nueva ruta para el archivo de datos" -#: sl/SL_Menu.py:1412 +#: sl/SL_Menu.py:1574 msgid "Move existing data to the new location" msgstr "Mover datos existentes a la nueva ubicación" -#: sl/SL_Menu.py:1415 +#: sl/SL_Menu.py:1577 msgid "" "If unchecked, the old data file will remain, and a new empty one might be " "created at the new location on restart." @@ -505,11 +590,11 @@ msgstr "" "Si no se marca, el archivo de datos antiguo permanecerá y se podría crear " "uno nuevo vacío en la nueva ubicación al reiniciar." -#: sl/SL_Menu.py:1422 +#: sl/SL_Menu.py:1584 msgid "Please enter a new path." msgstr "Por favor ingrese una nueva ruta." -#: sl/SL_Menu.py:1430 +#: sl/SL_Menu.py:1592 msgid "" "Error: For security, the data file must be located within the application " "directory." @@ -517,12 +602,12 @@ msgstr "" "Error: Por seguridad, el archivo de datos debe estar ubicado dentro del " "directorio de la aplicación." -#: sl/SL_Menu.py:1434 +#: sl/SL_Menu.py:1596 #, python-brace-format msgid "Error: The directory '{dir}' does not exist." msgstr "Error: El directorio '{dir}' no existe." -#: sl/SL_Menu.py:1439 +#: sl/SL_Menu.py:1601 msgid "" "Storage location updated. Please restart the application for the changes to " "take effect." @@ -530,89 +615,89 @@ msgstr "" "Ubicación de almacenamiento actualizada. Por favor, reinicie la aplicación " "para que los cambios surtan efecto." -#: sl/SL_Menu.py:1444 +#: sl/SL_Menu.py:1606 msgid "Data moved successfully." msgstr "Datos movidos con éxito." -#: sl/SL_Menu.py:1446 +#: sl/SL_Menu.py:1608 #, python-brace-format msgid "Error moving data: {error}" msgstr "Error al mover datos: {error}" -#: sl/SL_Menu.py:1453 +#: sl/SL_Menu.py:1615 msgid "Report Format" msgstr "Formato de informe" -#: sl/SL_Menu.py:1458 +#: sl/SL_Menu.py:1620 msgid "Select Format" msgstr "Seleccionar formato" -#: sl/SL_Menu.py:1463 +#: sl/SL_Menu.py:1625 msgid "Report format updated." msgstr "Formato de informe actualizado." -#: sl/SL_Menu.py:1466 +#: sl/SL_Menu.py:1628 msgid "Streamlit Port Settings" msgstr "Configuración del puerto Streamlit" -#: sl/SL_Menu.py:1468 +#: sl/SL_Menu.py:1630 msgid "Current Streamlit Port" msgstr "Puerto actual de Streamlit" -#: sl/SL_Menu.py:1471 +#: sl/SL_Menu.py:1633 msgid "New Port" msgstr "Nuevo puerto" -#: sl/SL_Menu.py:1476 +#: sl/SL_Menu.py:1638 #, python-brace-format msgid "Port updated to {port}. Please restart Streamlit." msgstr "Puerto actualizado a {port}. Por favor, reinicie Streamlit." -#: sl/SL_Menu.py:1479 +#: sl/SL_Menu.py:1641 msgid "Email Settings" msgstr "Configuración de correo" -#: sl/SL_Menu.py:1485 +#: sl/SL_Menu.py:1647 msgid "Enable email import" msgstr "Activar importación de correo" -#: sl/SL_Menu.py:1486 +#: sl/SL_Menu.py:1648 msgid "IMAP Server" msgstr "Servidor IMAP" -#: sl/SL_Menu.py:1487 +#: sl/SL_Menu.py:1649 msgid "Port" msgstr "Puerto" -#: sl/SL_Menu.py:1488 +#: sl/SL_Menu.py:1650 sl/SL_Menu.py:1896 msgid "Username" msgstr "Usuario" -#: sl/SL_Menu.py:1489 +#: sl/SL_Menu.py:1651 sl/SL_Menu.py:1897 msgid "Password" msgstr "Contraseña" -#: sl/SL_Menu.py:1490 +#: sl/SL_Menu.py:1652 msgid "Use SSL" msgstr "Usar SSL" -#: sl/SL_Menu.py:1503 +#: sl/SL_Menu.py:1665 msgid "Email settings saved." msgstr "Configuración de correo guardada." -#: sl/SL_Menu.py:1506 +#: sl/SL_Menu.py:1668 msgid "Change CSS Style" msgstr "Cambiar estilo CSS" -#: sl/SL_Menu.py:1508 +#: sl/SL_Menu.py:1670 msgid "Current CSS file" msgstr "Archivo CSS actual" -#: sl/SL_Menu.py:1525 +#: sl/SL_Menu.py:1687 msgid "Select CSS File" msgstr "Seleccionar archivo CSS" -#: sl/SL_Menu.py:1531 +#: sl/SL_Menu.py:1693 msgid "" "CSS style updated. Please restart the application for the changes to take " "effect." @@ -620,23 +705,23 @@ msgstr "" "Estilo CSS actualizado. Por favor, reinicie la aplicación para que los " "cambios surtan efecto." -#: sl/SL_Menu.py:1534 +#: sl/SL_Menu.py:1696 msgid "Change View Mode" msgstr "Cambiar modo de vista" -#: sl/SL_Menu.py:1538 +#: sl/SL_Menu.py:1700 msgid "App Window (Webview)" msgstr "Ventana de aplicación (vista web)" -#: sl/SL_Menu.py:1538 +#: sl/SL_Menu.py:1700 msgid "System Browser" msgstr "Navegador del sistema" -#: sl/SL_Menu.py:1548 +#: sl/SL_Menu.py:1710 msgid "Select View Mode" msgstr "Seleccionar modo de visualización" -#: sl/SL_Menu.py:1555 +#: sl/SL_Menu.py:1717 msgid "" "View mode updated. Please restart the application for the changes to take " "effect." @@ -644,38 +729,38 @@ msgstr "" "Modo de visualización actualizado. Reinicie la aplicación para que los " "cambios surtan efecto." -#: sl/SL_Menu.py:1558 +#: sl/SL_Menu.py:1720 msgid "MCP Server Settings" msgstr "Configuración del servidor MCP" -#: sl/SL_Menu.py:1560 +#: sl/SL_Menu.py:1722 msgid "HTTP (Streamable HTTP)" msgstr "HTTP (Streamable HTTP)" -#: sl/SL_Menu.py:1561 +#: sl/SL_Menu.py:1723 msgid "stdio (recommended for Claude Desktop)" msgstr "stdio (recomendado para Claude Desktop)" -#: sl/SL_Menu.py:1575 +#: sl/SL_Menu.py:1737 msgid "Transport" msgstr "Transporte" -#: sl/SL_Menu.py:1586 +#: sl/SL_Menu.py:1748 msgid "Enable MCP server" msgstr "Activar servidor MCP" -#: sl/SL_Menu.py:1589 +#: sl/SL_Menu.py:1751 msgid "" "Not used with stdio - the MCP client starts and stops the server itself." msgstr "" "No se usa con stdio: el cliente MCP inicia y detiene el servidor por sí " "mismo." -#: sl/SL_Menu.py:1592 +#: sl/SL_Menu.py:1754 msgid "Port (HTTP only)" msgstr "Puerto (solo HTTP)" -#: sl/SL_Menu.py:1599 +#: sl/SL_Menu.py:1761 msgid "" "With stdio, the app does not start the MCP server itself - the MCP client " "(e.g. Claude Desktop) launches it directly, and the port is ignored." @@ -683,7 +768,7 @@ msgstr "" "Con stdio, la aplicación no inicia el servidor MCP por sí misma: el cliente " "MCP (p. ej. Claude Desktop) lo inicia directamente y el puerto se ignora." -#: sl/SL_Menu.py:1610 +#: sl/SL_Menu.py:1772 msgid "" "MCP server settings saved. Please restart the application for the changes to " "take effect." @@ -691,132 +776,238 @@ msgstr "" "Configuración del servidor MCP guardada. Por favor, reinicie la aplicación " "para que los cambios surtan efecto." -#: sl/SL_Menu.py:1624 +#: sl/SL_Menu.py:1775 +msgid "Sync Server Settings" +msgstr "Configuración del servidor de sincronización" + +#: sl/SL_Menu.py:1777 +msgid "" +"The sync client is unavailable because the 'requests' package is missing." +msgstr "" +"La sincronización no está disponible porque falta el paquete «requests»." + +#: sl/SL_Menu.py:1788 +msgid "Server address" +msgstr "Dirección del servidor" + +#: sl/SL_Menu.py:1793 +msgid "Enable synchronisation" +msgstr "Activar sincronización" + +#: sl/SL_Menu.py:1795 +msgid "Without this, TimeControl works entirely locally, exactly as before." +msgstr "" +"Sin esto, TimeControl funciona de forma totalmente local, igual que antes." + +#: sl/SL_Menu.py:1798 +msgid "Sync every (minutes)" +msgstr "Sincronizar cada (minutos)" + +#: sl/SL_Menu.py:1802 +msgid "Synchronisation also runs whenever you switch to a different view." +msgstr "La sincronización también se ejecuta cada vez que cambia de vista." + +#: sl/SL_Menu.py:1816 +msgid "Sync server settings saved." +msgstr "Configuración del servidor de sincronización guardada." + +#: sl/SL_Menu.py:1842 +#, python-brace-format +msgid "Last synchronised at {time}." +msgstr "Última sincronización el {time}." + +#: sl/SL_Menu.py:1845 +msgid "Not synchronised yet." +msgstr "Todavía no se ha sincronizado." + +#: sl/SL_Menu.py:1847 +#, python-brace-format +msgid "{count} changes are waiting to be sent." +msgstr "{count} cambios están esperando a enviarse." + +#: sl/SL_Menu.py:1853 +#, python-brace-format +msgid "Signed in as {user}." +msgstr "Sesión iniciada como {user}." + +#: sl/SL_Menu.py:1855 +#, python-brace-format +msgid "Access expires on {date}." +msgstr "El acceso caduca el {date}." + +#: sl/SL_Menu.py:1859 +msgid "Check connection" +msgstr "Comprobar la conexión" + +#: sl/SL_Menu.py:1861 sl/SL_Menu.py:1879 sl/SL_Menu.py:1899 +msgid "Contacting the server..." +msgstr "Contactando con el servidor..." + +#: sl/SL_Menu.py:1869 +msgid "The server answered." +msgstr "El servidor respondió." + +#: sl/SL_Menu.py:1878 +msgid "Sign out" +msgstr "Cerrar sesión" + +#: sl/SL_Menu.py:1881 +msgid "Signed out on this device." +msgstr "Sesión cerrada en este dispositivo." + +#: sl/SL_Menu.py:1889 +#, python-brace-format +msgid "The server could not be reached ({reason})." +msgstr "No se pudo contactar con el servidor ({reason})." + +#: sl/SL_Menu.py:1893 +msgid "" +"Signing in stores an access token for this device only. It is kept outside " +"the project directory and is never written to config.json." +msgstr "" +"Al iniciar sesión se guarda un token de acceso solo para este dispositivo. " +"Se almacena fuera del directorio del proyecto y nunca se escribe en " +"config.json." + +#: sl/SL_Menu.py:1898 +msgid "Sign in" +msgstr "Iniciar sesión" + +#: sl/SL_Menu.py:1906 +msgid "Signed in successfully." +msgstr "Sesión iniciada correctamente." + +#: sl/SL_Menu.py:1912 +#, python-brace-format +msgid "This device: {name} ({uid})" +msgstr "Este dispositivo: {name} ({uid})" + +#: sl/SL_Menu.py:1926 msgid "Add New Project" msgstr "Agregar nuevo proyecto" -#: sl/SL_Menu.py:1627 +#: sl/SL_Menu.py:1929 msgid "Name of the project" msgstr "Nombre del proyecto" -#: sl/SL_Menu.py:1631 +#: sl/SL_Menu.py:1933 #, python-brace-format msgid "Project '{name}' added." msgstr "Proyecto '{name}' agregado." -#: sl/SL_Menu.py:1635 sl/SL_Menu.py:1681 sl/SL_Menu.py:1726 sl/SL_Menu.py:1772 -#: sl/SL_Menu.py:1827 sl/SL_Menu.py:1921 sl/SL_Menu.py:1969 sl/SL_Menu.py:2019 -#: sl/SL_Menu.py:2099 sl/SL_Menu.py:2129 sl/SL_Menu.py:2159 sl/SL_Menu.py:2190 -#: sl/SL_Menu.py:2252 sl/SL_Menu.py:2292 sl/SL_Menu.py:2404 sl/SL_Menu.py:2423 -#: sl/SL_Menu.py:2584 sl/SL_Menu.py:2632 +#: sl/SL_Menu.py:1937 sl/SL_Menu.py:1983 sl/SL_Menu.py:2028 sl/SL_Menu.py:2074 +#: sl/SL_Menu.py:2129 sl/SL_Menu.py:2223 sl/SL_Menu.py:2271 sl/SL_Menu.py:2321 +#: sl/SL_Menu.py:2401 sl/SL_Menu.py:2431 sl/SL_Menu.py:2461 sl/SL_Menu.py:2492 +#: sl/SL_Menu.py:2554 sl/SL_Menu.py:2594 sl/SL_Menu.py:2706 sl/SL_Menu.py:2725 +#: sl/SL_Menu.py:2890 sl/SL_Menu.py:2938 msgid "Cancel" msgstr "Cancelar" -#: sl/SL_Menu.py:1647 sl/SL_Menu.py:1692 sl/SL_Menu.py:1737 sl/SL_Menu.py:1783 -#: sl/SL_Menu.py:1932 sl/SL_Menu.py:2063 sl/SL_Menu.py:2222 sl/SL_Menu.py:2415 +#: sl/SL_Menu.py:1949 sl/SL_Menu.py:1994 sl/SL_Menu.py:2039 sl/SL_Menu.py:2085 +#: sl/SL_Menu.py:2234 sl/SL_Menu.py:2365 sl/SL_Menu.py:2524 sl/SL_Menu.py:2717 msgid "No open projects found." msgstr "No se encontraron proyectos abiertos." -#: sl/SL_Menu.py:1653 sl/SL_Menu.py:1698 sl/SL_Menu.py:1743 sl/SL_Menu.py:1938 -#: sl/SL_Menu.py:2001 sl/SL_Menu.py:2036 sl/SL_Menu.py:2069 sl/SL_Menu.py:2118 -#: sl/SL_Menu.py:2148 sl/SL_Menu.py:2178 sl/SL_Menu.py:2605 sl/SL_Menu.py:2717 -#: sl/SL_Menu.py:2778 +#: sl/SL_Menu.py:1955 sl/SL_Menu.py:2000 sl/SL_Menu.py:2045 sl/SL_Menu.py:2240 +#: sl/SL_Menu.py:2303 sl/SL_Menu.py:2338 sl/SL_Menu.py:2371 sl/SL_Menu.py:2420 +#: sl/SL_Menu.py:2450 sl/SL_Menu.py:2480 sl/SL_Menu.py:2911 sl/SL_Menu.py:3023 +#: sl/SL_Menu.py:3084 msgid "Select Project" msgstr "Seleccionar Proyecto" -#: sl/SL_Menu.py:1658 +#: sl/SL_Menu.py:1960 #, python-brace-format msgid "No open tasks to close in '{name}'." msgstr "No hay tareas abiertas para cerrar en '{name}'." -#: sl/SL_Menu.py:1665 sl/SL_Menu.py:1710 sl/SL_Menu.py:1755 sl/SL_Menu.py:1809 -#: sl/SL_Menu.py:1950 sl/SL_Menu.py:2079 sl/SL_Menu.py:2438 sl/SL_Menu.py:2616 -#: sl/SL_Menu.py:2748 +#: sl/SL_Menu.py:1967 sl/SL_Menu.py:2012 sl/SL_Menu.py:2057 sl/SL_Menu.py:2111 +#: sl/SL_Menu.py:2252 sl/SL_Menu.py:2381 sl/SL_Menu.py:2740 sl/SL_Menu.py:2922 +#: sl/SL_Menu.py:3054 msgid "Select Task" msgstr "Seleccionar Tarea" -#: sl/SL_Menu.py:1675 +#: sl/SL_Menu.py:1977 #, python-brace-format msgid "Task '{sub_name}' in '{main_name}' has been closed." msgstr "La tarea '{sub_name}' en '{main_name}' se ha cerrado." -#: sl/SL_Menu.py:1679 sl/SL_Menu.py:1724 sl/SL_Menu.py:1770 +#: sl/SL_Menu.py:1981 sl/SL_Menu.py:2026 sl/SL_Menu.py:2072 msgid "Error: Main project or task not found." msgstr "Error: Proyecto principal o tarea no encontrada." -#: sl/SL_Menu.py:1703 +#: sl/SL_Menu.py:2005 #, python-brace-format msgid "No closed tasks to reopen in '{name}'." msgstr "No hay tareas cerradas para reabrir en '{name}'." -#: sl/SL_Menu.py:1720 +#: sl/SL_Menu.py:2022 #, python-brace-format msgid "Task '{sub_name}' in '{main_name}' has been reopened." msgstr "La tarea '{sub_name}' en '{main_name}' se ha reabierto." -#: sl/SL_Menu.py:1748 +#: sl/SL_Menu.py:2050 #, python-brace-format msgid "No open tasks to delete in '{name}'." msgstr "No hay tareas abiertas para eliminar en '{name}'." -#: sl/SL_Menu.py:1759 +#: sl/SL_Menu.py:2061 msgid "This action cannot be undone." msgstr "Esta acción no se puede deshacer." -#: sl/SL_Menu.py:1766 +#: sl/SL_Menu.py:2068 #, python-brace-format msgid "Task '{sub_name}' deleted from '{main_name}'." msgstr "Tarea '{sub_name}' eliminada de '{main_name}'." -#: sl/SL_Menu.py:1789 +#: sl/SL_Menu.py:2091 msgid "Select Source Project" msgstr "Seleccionar proyecto fuente" -#: sl/SL_Menu.py:1794 +#: sl/SL_Menu.py:2096 #, python-brace-format msgid "No tasks found in '{name}'." msgstr "No se encontraron tareas en '{name}'." -#: sl/SL_Menu.py:1802 +#: sl/SL_Menu.py:2104 msgid "No other projects available to move to." msgstr "No hay otros proyectos disponibles para mudarse." -#: sl/SL_Menu.py:1813 sl/SL_Menu.py:2238 +#: sl/SL_Menu.py:2115 sl/SL_Menu.py:2540 msgid "Select Target Project" msgstr "Seleccionar proyecto de destino" -#: sl/SL_Menu.py:1821 +#: sl/SL_Menu.py:2123 #, python-brace-format msgid "Task '{sub}' moved from '{src}' to '{dst}'." msgstr "La tarea '{sub}' se movió de '{src}' a '{dst}'." -#: sl/SL_Menu.py:1825 +#: sl/SL_Menu.py:2127 msgid "Error: Could not move task." msgstr "Error: No se pudo mover la tarea." -#: sl/SL_Menu.py:1836 sl/SL_Menu.py:2199 +#: sl/SL_Menu.py:2138 sl/SL_Menu.py:2501 msgid "Weeks of inactivity" msgstr "Semanas de inactividad" -#: sl/SL_Menu.py:1841 +#: sl/SL_Menu.py:2143 #, python-brace-format msgid "Inactive Tasks (> {weeks} weeks):" msgstr "Tareas inactivas (> {weeks} semanas):" -#: sl/SL_Menu.py:1846 sl/SL_Menu.py:2207 +#: sl/SL_Menu.py:2148 sl/SL_Menu.py:2509 msgid "Last Activity" msgstr "Última actividad" -#: sl/SL_Menu.py:1852 +#: sl/SL_Menu.py:2154 #, python-brace-format msgid "No tasks found inactive for more than {weeks} weeks." msgstr "No se encontraron tareas inactivas durante más de {weeks} semanas." -#: sl/SL_Menu.py:1877 sl/SL_Menu.py:1900 +#: sl/SL_Menu.py:2179 sl/SL_Menu.py:2202 msgid "No closed tasks found." msgstr "No se encontraron tareas cerradas." -#: sl/SL_Menu.py:1905 +#: sl/SL_Menu.py:2207 #, python-brace-format msgid "" "Are you sure you want to delete {count} closed tasks? This action cannot be " @@ -825,25 +1016,25 @@ msgstr "" "¿Está seguro de que desea eliminar{count}tareas cerradas? Esta acción no se " "puede deshacer." -#: sl/SL_Menu.py:1907 +#: sl/SL_Menu.py:2209 msgid "Show projects to delete" msgstr "Mostrar proyectos a eliminar" -#: sl/SL_Menu.py:1911 +#: sl/SL_Menu.py:2213 msgid "Delete All" msgstr "Eliminar todo" -#: sl/SL_Menu.py:1917 +#: sl/SL_Menu.py:2219 #, python-brace-format msgid "Successfully deleted {count} tasks." msgstr "Tareas {count} eliminadas correctamente." -#: sl/SL_Menu.py:1943 +#: sl/SL_Menu.py:2245 #, python-brace-format msgid "No open tasks to promote in '{name}'." msgstr "No hay tareas abiertas para promocionar en '{name}'." -#: sl/SL_Menu.py:1954 +#: sl/SL_Menu.py:2256 msgid "" "This will create a new Project with the task's name and move all time " "entries to a 'General' task within it." @@ -851,99 +1042,99 @@ msgstr "" "Esto creará un nuevo Proyecto con el nombre de la tarea y moverá todas las " "entradas de tiempo a una tarea 'General' dentro de él." -#: sl/SL_Menu.py:1956 +#: sl/SL_Menu.py:2258 msgid "Promote to Project" msgstr "Promocionar a Proyecto" -#: sl/SL_Menu.py:1980 sl/SL_Menu.py:2044 +#: sl/SL_Menu.py:2282 sl/SL_Menu.py:2346 msgid "closed" msgstr "cerrado" -#: sl/SL_Menu.py:1983 sl/SL_Menu.py:2030 sl/SL_Menu.py:2170 sl/SL_Menu.py:2711 -#: sl/SL_Menu.py:2770 +#: sl/SL_Menu.py:2285 sl/SL_Menu.py:2332 sl/SL_Menu.py:2472 sl/SL_Menu.py:3017 +#: sl/SL_Menu.py:3076 msgid "No projects found." msgstr "No se encontraron proyectos." -#: sl/SL_Menu.py:1995 +#: sl/SL_Menu.py:2297 msgid "No open projects to rename." msgstr "No hay proyectos abiertos para cambiar el nombre." -#: sl/SL_Menu.py:2004 sl/SL_Menu.py:2084 +#: sl/SL_Menu.py:2306 sl/SL_Menu.py:2386 msgid "New Name" msgstr "Nuevo Nombre" -#: sl/SL_Menu.py:2005 sl/SL_Menu.py:2085 +#: sl/SL_Menu.py:2307 sl/SL_Menu.py:2387 msgid "Rename" msgstr "Renombrar" -#: sl/SL_Menu.py:2009 sl/SL_Menu.py:2089 +#: sl/SL_Menu.py:2311 sl/SL_Menu.py:2391 msgid "Please enter a new name." msgstr "Por favor, introduzca un nuevo nombre." -#: sl/SL_Menu.py:2011 sl/SL_Menu.py:2091 +#: sl/SL_Menu.py:2313 sl/SL_Menu.py:2393 msgid "New name is the same as the old name." msgstr "El nuevo nombre es el mismo que el antiguo." -#: sl/SL_Menu.py:2013 +#: sl/SL_Menu.py:2315 #, python-brace-format msgid "Project '{old_name}' successfully renamed to '{new_name}'." msgstr "El proyecto '{old_name}' se renombra exitosamente a '{new_name}'." -#: sl/SL_Menu.py:2017 +#: sl/SL_Menu.py:2319 #, python-brace-format msgid "Error: Could not rename. The new name '{new_name}' might already exist." msgstr "" "Error: No se pudo renombrar. El nuevo nombre '{new_name}' ya podría existir." -#: sl/SL_Menu.py:2041 +#: sl/SL_Menu.py:2343 #, python-brace-format msgid "Tasks for '{name}':" msgstr "Tareas para '{name}':" -#: sl/SL_Menu.py:2050 sl/SL_Menu.py:2742 +#: sl/SL_Menu.py:2352 sl/SL_Menu.py:3048 #, python-brace-format msgid "No tasks found for '{name}'." msgstr "No se encontraron tareas para '{name}'." -#: sl/SL_Menu.py:2074 +#: sl/SL_Menu.py:2376 #, python-brace-format msgid "No open tasks to rename in '{name}'." msgstr "No hay tareas abiertas para cambiar el nombre en '{name}'." -#: sl/SL_Menu.py:2093 +#: sl/SL_Menu.py:2395 #, python-brace-format msgid "Task '{old_name}' renamed to '{new_name}'." msgstr "Tarea '{old_name}' renombrada a '{new_name}'." -#: sl/SL_Menu.py:2097 +#: sl/SL_Menu.py:2399 msgid "Error: Could not rename. The new name might already exist." msgstr "" "Error: No se pudo cambiar el nombre. Es posible que el nuevo nombre ya " "exista." -#: sl/SL_Menu.py:2110 +#: sl/SL_Menu.py:2412 msgid "No open projects to close." msgstr "No hay proyectos abiertos para cerrar." -#: sl/SL_Menu.py:2123 +#: sl/SL_Menu.py:2425 #, python-brace-format msgid "Project '{name}' has been closed." msgstr "El proyecto '{name}' ha sido cerrado." -#: sl/SL_Menu.py:2127 sl/SL_Menu.py:2157 sl/SL_Menu.py:2188 +#: sl/SL_Menu.py:2429 sl/SL_Menu.py:2459 sl/SL_Menu.py:2490 msgid "Error: Project not found." msgstr "Error: Proyecto no encontrado." -#: sl/SL_Menu.py:2140 +#: sl/SL_Menu.py:2442 msgid "No closed projects to reopen." msgstr "No hay proyectos cerrados para reabrir." -#: sl/SL_Menu.py:2153 +#: sl/SL_Menu.py:2455 #, python-brace-format msgid "Project '{name}' has been reopened." msgstr "El proyecto '{name}' ha sido reabierto." -#: sl/SL_Menu.py:2179 +#: sl/SL_Menu.py:2481 msgid "" "This action cannot be undone. All associated tasks and time entries will be " "deleted." @@ -951,242 +1142,251 @@ msgstr "" "Esta acción no se puede deshacer. Se eliminarán todas las tareas asociadas y " "entradas de tiempo." -#: sl/SL_Menu.py:2184 +#: sl/SL_Menu.py:2486 #, python-brace-format msgid "Project '{name}' has been deleted." msgstr "El proyecto '{name}' ha sido eliminado." -#: sl/SL_Menu.py:2204 +#: sl/SL_Menu.py:2506 #, python-brace-format msgid "Inactive Projects (> {weeks} weeks):" msgstr "Proyectos inactivos (> {weeks} semanas):" -#: sl/SL_Menu.py:2209 +#: sl/SL_Menu.py:2511 #, python-brace-format msgid "No projects found inactive for more than {weeks} weeks." msgstr "No se encontraron proyectos inactivos durante más de {weeks} semanas." -#: sl/SL_Menu.py:2218 sl/SL_Menu.py:2241 +#: sl/SL_Menu.py:2520 sl/SL_Menu.py:2543 msgid "Demote Project" msgstr "Degradar proyecto" -#: sl/SL_Menu.py:2230 +#: sl/SL_Menu.py:2532 msgid "Select Project to Demote" msgstr "Seleccionar proyecto para degradar" -#: sl/SL_Menu.py:2236 +#: sl/SL_Menu.py:2538 msgid "No other projects available to demote into." msgstr "No hay otros proyectos disponibles para degradar." -#: sl/SL_Menu.py:2239 +#: sl/SL_Menu.py:2541 #, python-brace-format msgid "This will convert '{src}' into a task of '{dst}'." msgstr "Esto convertirá '{src}' en una tarea de '{dst}'." -#: sl/SL_Menu.py:2264 +#: sl/SL_Menu.py:2566 msgid "Projects with only closed or no tasks:" msgstr "Proyectos con solo tareas cerradas o sin tareas:" -#: sl/SL_Menu.py:2268 +#: sl/SL_Menu.py:2570 msgid "No completed projects found." msgstr "No se encontraron proyectos completados." -#: sl/SL_Menu.py:2277 sl/SL_Menu.py:2411 sl/SL_Menu.py:2707 +#: sl/SL_Menu.py:2579 sl/SL_Menu.py:2713 sl/SL_Menu.py:3013 msgid "Step 1: Select Project" msgstr "Paso 1: Seleccionar proyecto" -#: sl/SL_Menu.py:2281 sl/SL_Menu.py:2599 +#: sl/SL_Menu.py:2583 sl/SL_Menu.py:2905 msgid "No open projects found. Please add one first." msgstr "No se encontraron proyectos abiertos. Por favor agregue uno primero." -#: sl/SL_Menu.py:2286 sl/SL_Menu.py:2418 sl/SL_Menu.py:2647 +#: sl/SL_Menu.py:2588 sl/SL_Menu.py:2720 sl/SL_Menu.py:2953 msgid "Project" msgstr "Proyecto" -#: sl/SL_Menu.py:2288 sl/SL_Menu.py:2419 sl/SL_Menu.py:2443 sl/SL_Menu.py:2719 +#: sl/SL_Menu.py:2590 sl/SL_Menu.py:2721 sl/SL_Menu.py:2745 sl/SL_Menu.py:3025 msgid "Next" msgstr "Siguiente" -#: sl/SL_Menu.py:2303 sl/SL_Menu.py:2733 +#: sl/SL_Menu.py:2605 sl/SL_Menu.py:3039 msgid "No project selected. Please start again." msgstr "Ningún proyecto seleccionado. Por favor, empieza de nuevo." -#: sl/SL_Menu.py:2308 +#: sl/SL_Menu.py:2610 msgid "To Project:" msgstr "Al proyecto:" -#: sl/SL_Menu.py:2329 +#: sl/SL_Menu.py:2631 msgid "Name of the new task" msgstr "Nombre de la nueva tarea" -#: sl/SL_Menu.py:2335 +#: sl/SL_Menu.py:2637 msgid "Due date" msgstr "Fecha de vencimiento" -#: sl/SL_Menu.py:2341 sl/SL_Menu.py:2513 +#: sl/SL_Menu.py:2643 sl/SL_Menu.py:2815 msgid "Recurring" msgstr "Recurrente" -#: sl/SL_Menu.py:2357 sl/SL_Menu.py:2528 +#: sl/SL_Menu.py:2659 sl/SL_Menu.py:2830 msgid "daily" msgstr "diario" -#: sl/SL_Menu.py:2357 sl/SL_Menu.py:2528 +#: sl/SL_Menu.py:2659 sl/SL_Menu.py:2830 msgid "monthly" msgstr "mensual" -#: sl/SL_Menu.py:2357 sl/SL_Menu.py:2528 +#: sl/SL_Menu.py:2659 sl/SL_Menu.py:2830 msgid "on all business days" msgstr "todos los días hábiles" -#: sl/SL_Menu.py:2357 sl/SL_Menu.py:2528 +#: sl/SL_Menu.py:2659 sl/SL_Menu.py:2830 msgid "userdefined" msgstr "definido por el usuario" -#: sl/SL_Menu.py:2357 sl/SL_Menu.py:2528 +#: sl/SL_Menu.py:2659 sl/SL_Menu.py:2830 msgid "weekly" msgstr "semanal" -#: sl/SL_Menu.py:2361 sl/SL_Menu.py:2537 +#: sl/SL_Menu.py:2663 sl/SL_Menu.py:2839 msgid "Frequency" msgstr "Frecuencia" -#: sl/SL_Menu.py:2365 sl/SL_Menu.py:2540 +#: sl/SL_Menu.py:2667 sl/SL_Menu.py:2842 msgid "Days" msgstr "Días" -#: sl/SL_Menu.py:2369 sl/SL_Menu.py:2542 +#: sl/SL_Menu.py:2671 sl/SL_Menu.py:2844 msgid "Edit" msgstr "Editar" -#: sl/SL_Menu.py:2369 sl/SL_Menu.py:2542 +#: sl/SL_Menu.py:2671 sl/SL_Menu.py:2844 msgid "Preview" msgstr "Vista previa" -#: sl/SL_Menu.py:2374 sl/SL_Menu.py:2547 +#: sl/SL_Menu.py:2676 sl/SL_Menu.py:2849 msgid "No notes provided." msgstr "No se proporcionaron notas." -#: sl/SL_Menu.py:2379 sl/SL_Menu.py:2552 +#: sl/SL_Menu.py:2681 sl/SL_Menu.py:2854 msgid "A due date is required for recurring tasks." msgstr "Se requiere una fecha de vencimiento para las tareas recurrentes." -#: sl/SL_Menu.py:2387 +#: sl/SL_Menu.py:2689 msgid "Please enter a name." msgstr "Por favor ingrese un nombre." -#: sl/SL_Menu.py:2399 +#: sl/SL_Menu.py:2701 #, python-brace-format msgid "Task '{sub_name}' added to '{main_name}'." msgstr "Tarea '{sub_name}' agregada a '{main_name}'." -#: sl/SL_Menu.py:2431 sl/SL_Menu.py:2738 +#: sl/SL_Menu.py:2733 sl/SL_Menu.py:3044 msgid "Step 2: Select Task from" msgstr "Paso 2: Seleccione Tarea de" -#: sl/SL_Menu.py:2434 +#: sl/SL_Menu.py:2736 msgid "No open tasks found." msgstr "No se encontraron tareas abiertas." -#: sl/SL_Menu.py:2462 +#: sl/SL_Menu.py:2764 msgid "Task not found." msgstr "Tarea no encontrada." -#: sl/SL_Menu.py:2556 +#: sl/SL_Menu.py:2858 msgid "Save Changes" msgstr "Guardar cambios" -#: sl/SL_Menu.py:2575 +#: sl/SL_Menu.py:2881 msgid "Task updated successfully." msgstr "Tarea actualizada exitosamente." -#: sl/SL_Menu.py:2581 +#: sl/SL_Menu.py:2887 msgid "Error: Could not update task." msgstr "Error: No se pudo actualizar la tarea." -#: sl/SL_Menu.py:2594 +#: sl/SL_Menu.py:2900 msgid "Start Work on Task" msgstr "Comenzar a trabajar en la tarea" -#: sl/SL_Menu.py:2609 +#: sl/SL_Menu.py:2915 #, python-brace-format msgid "No open tasks to start work on in '{name}'." msgstr "No hay tareas abiertas para comenzar a trabajar en '{name}'." -#: sl/SL_Menu.py:2620 +#: sl/SL_Menu.py:2926 msgid "Start Work" msgstr "Iniciar trabajo" -#: sl/SL_Menu.py:2626 +#: sl/SL_Menu.py:2932 #, python-brace-format msgid "Work started on '{task_name}' in project '{main_name}'." msgstr "Trabajo iniciado en '{task_name}' en el proyecto '{main_name}'." -#: sl/SL_Menu.py:2630 +#: sl/SL_Menu.py:2936 msgid "Error starting work." msgstr "Error al iniciar el trabajo." -#: sl/SL_Menu.py:2648 +#: sl/SL_Menu.py:2954 msgid "Task" msgstr "Tarea" -#: sl/SL_Menu.py:2649 +#: sl/SL_Menu.py:2955 msgid "Started at" msgstr "Comenzó en" -#: sl/SL_Menu.py:2650 tt/TimeTracker.py:1454 +#: sl/SL_Menu.py:2956 tt/TimeTracker.py:1912 msgid "Duration" msgstr "Duración" -#: sl/SL_Menu.py:2664 sl/SL_Menu.py:2796 +#: sl/SL_Menu.py:2970 sl/SL_Menu.py:3102 msgid "Select Date" msgstr "Seleccionar fecha" -#: sl/SL_Menu.py:2665 sl/SL_Menu.py:2689 sl/SL_Menu.py:2753 sl/SL_Menu.py:2779 -#: sl/SL_Menu.py:2797 +#: sl/SL_Menu.py:2971 sl/SL_Menu.py:2995 sl/SL_Menu.py:3059 sl/SL_Menu.py:3085 +#: sl/SL_Menu.py:3103 msgid "Generate Report" msgstr "Generar Informe" -#: sl/SL_Menu.py:2685 +#: sl/SL_Menu.py:2991 msgid "Start Date" msgstr "Fecha de inicio" -#: sl/SL_Menu.py:2687 +#: sl/SL_Menu.py:2993 msgid "End Date" msgstr "Fecha de finalización" -#: sl/SL_Menu.py:2693 +#: sl/SL_Menu.py:2999 msgid "Error: The start date cannot be after the end date." msgstr "Error: La fecha de inicio no puede ser posterior a la fecha de fin." -#: sl/SL_Menu.py:2812 +#: sl/SL_Menu.py:3118 msgid "Report Result" msgstr "Resultado del Informe" -#: sl/SL_Menu.py:2841 +#: sl/SL_Menu.py:3147 msgid "Export Report" msgstr "Exportar informe" -#: tt/TimeTracker.py:91 +#: tt/TimeTracker.py:191 #, python-brace-format msgid "Warning: Could not read {file}. Error: {error}" msgstr "Advertencia: No se pudo leer {file}. Error: {error}" -#: tt/TimeTracker.py:107 +#: tt/TimeTracker.py:207 msgid "Some required packages are missing. Attempting to install them..." msgstr "Faltan algunos paquetes requeridos. Intentando instalarlos..." -#: tt/TimeTracker.py:110 +#: tt/TimeTracker.py:210 #, python-brace-format msgid "Installing {package}..." msgstr "Instalando {package}..." -#: tt/TimeTracker.py:114 +#: tt/TimeTracker.py:217 #, python-brace-format msgid "Failed to install {package}. Continuing without it." msgstr "No se pudo instalar {package}. Continuar sin él." -#: tt/TimeTracker.py:118 +#: tt/TimeTracker.py:220 +#, python-brace-format +msgid "" +"Timed out installing {package} (no internet connection?). Continuing without " +"it." +msgstr "" +"Se agotó el tiempo al instalar {package} (¿sin conexión a Internet?). Se " +"continúa sin él." + +#: tt/TimeTracker.py:224 msgid "" "\n" "Dependencies installed successfully." @@ -1194,11 +1394,11 @@ msgstr "" "\n" "Dependencias instaladas con éxito." -#: tt/TimeTracker.py:119 +#: tt/TimeTracker.py:225 msgid "Please restart the application for the changes to take effect." msgstr "Por favor, reinicie la aplicación para que los cambios surtan efecto." -#: tt/TimeTracker.py:122 +#: tt/TimeTracker.py:228 #, python-brace-format msgid "" "\n" @@ -1207,22 +1407,22 @@ msgstr "" "\n" "Advertencia: No se pudieron instalar algunas dependencias:{packages}" -#: tt/TimeTracker.py:124 +#: tt/TimeTracker.py:230 #, python-brace-format msgid "An unexpected error occurred during dependency check: {error}" msgstr "" "Ocurrió un error inesperado durante la comprobación de dependencias: {error}" -#: tt/TimeTracker.py:251 +#: tt/TimeTracker.py:452 msgid "Info: Report content has been copied to the clipboard." msgstr "Info: El contenido del informe ha sido copiado al portapapeles." -#: tt/TimeTracker.py:253 +#: tt/TimeTracker.py:454 #, python-brace-format msgid "Warning: Could not copy to clipboard. Error: {error}" msgstr "Advertencia: No se pudo copiar al portapapeles. Error: {error}" -#: tt/TimeTracker.py:255 +#: tt/TimeTracker.py:456 msgid "" "Warning: Could not copy to clipboard. Please install 'pyperclip' (`pip " "install pyperclip`)." @@ -1230,56 +1430,56 @@ msgstr "" "Advertencia: No se pudo copiar al portapapeles. Por favor, instale " "'pyperclip' (`pip install pyperclip`)." -#: tt/TimeTracker.py:275 +#: tt/TimeTracker.py:476 #, python-brace-format msgid "{hours} hours ({dlp} DLP)" msgstr "{hours} horas ({dlp} DLP)" -#: tt/TimeTracker.py:877 tt/TimeTracker.py:917 +#: tt/TimeTracker.py:1218 tt/TimeTracker.py:1263 #, python-brace-format msgid "Source main project '{name}' not found." msgstr "Proyecto principal de origen '{name}' no encontrado." -#: tt/TimeTracker.py:879 +#: tt/TimeTracker.py:1220 #, python-brace-format msgid "Destination main project '{name}' not found." msgstr "Proyecto principal de destino '{name}' no encontrado." -#: tt/TimeTracker.py:891 +#: tt/TimeTracker.py:1237 #, python-brace-format msgid "Task '{task_name}' moved successfully." msgstr "Tarea '{task_name}' movida con éxito." -#: tt/TimeTracker.py:892 tt/TimeTracker.py:927 tt/TimeTracker.py:1416 +#: tt/TimeTracker.py:1238 tt/TimeTracker.py:1273 tt/TimeTracker.py:1874 #, python-brace-format msgid "Task '{task_name}' not found in '{main_name}'." msgstr "Tarea '{task_name}' no encontrada en '{main_name}'." -#: tt/TimeTracker.py:911 +#: tt/TimeTracker.py:1257 #, python-brace-format msgid "A main project named '{name}' already exists." msgstr "Ya existe un proyecto principal llamado '{name}'." -#: tt/TimeTracker.py:936 +#: tt/TimeTracker.py:1305 msgid "General" msgstr "General" -#: tt/TimeTracker.py:940 +#: tt/TimeTracker.py:1343 #, python-brace-format msgid "Task '{task_name}' was promoted to a new main project." msgstr "La tarea '{task_name}' fue promovida a un nuevo proyecto principal." -#: tt/TimeTracker.py:967 +#: tt/TimeTracker.py:1370 #, python-brace-format msgid "Main project to demote '{name}' not found." msgstr "No se encontró el proyecto principal a degradar '{name}'." -#: tt/TimeTracker.py:969 +#: tt/TimeTracker.py:1372 #, python-brace-format msgid "New parent main project '{name}' not found." msgstr "No se encontró el nuevo proyecto principal padre '{name}'." -#: tt/TimeTracker.py:994 +#: tt/TimeTracker.py:1427 #, python-brace-format msgid "" "Main project '{demoted_name}' was demoted to a sub-project under " @@ -1288,42 +1488,42 @@ msgstr "" "El proyecto principal '{demoted_name}' fue degradado a un subproyecto bajo " "'{parent_name}'." -#: tt/TimeTracker.py:1076 +#: tt/TimeTracker.py:1521 msgid "Email import is not enabled." msgstr "La importación de correo electrónico no está habilitada." -#: tt/TimeTracker.py:1085 +#: tt/TimeTracker.py:1530 msgid "Email settings are incomplete." msgstr "La configuración de correo electrónico está incompleta." -#: tt/TimeTracker.py:1098 +#: tt/TimeTracker.py:1543 msgid "Error searching emails." msgstr "Error al buscar correos electrónicos." -#: tt/TimeTracker.py:1113 +#: tt/TimeTracker.py:1558 msgid "No Subject" msgstr "Sin asunto" -#: tt/TimeTracker.py:1173 +#: tt/TimeTracker.py:1631 msgid "Unknown Task" msgstr "Tarea desconocida" -#: tt/TimeTracker.py:1373 +#: tt/TimeTracker.py:1831 #, python-brace-format msgid "- {name}: {hours} hours" msgstr "- {name}: {hours} horas" -#: tt/TimeTracker.py:1381 +#: tt/TimeTracker.py:1839 #, python-brace-format msgid "## {name} ({hours} hours)\n" msgstr "## {name} ({hours} horas)\n" -#: tt/TimeTracker.py:1390 +#: tt/TimeTracker.py:1848 #, python-brace-format msgid "# Daily Time Report: {date}\n" msgstr "# Informe de Tiempo Diario: {date}\n" -#: tt/TimeTracker.py:1391 +#: tt/TimeTracker.py:1849 #, python-brace-format msgid "" "\n" @@ -1332,104 +1532,104 @@ msgstr "" "\n" "**Tiempo diario total:{hours}horas**" -#: tt/TimeTracker.py:1395 tt/TimeTracker.py:1708 +#: tt/TimeTracker.py:1853 tt/TimeTracker.py:2166 #, python-brace-format msgid "No time tracked for {date}." msgstr "No hay tiempo registrado para {date}." -#: tt/TimeTracker.py:1412 tt/TimeTracker.py:1507 +#: tt/TimeTracker.py:1870 tt/TimeTracker.py:1965 #, python-brace-format msgid "Main project '{name}' not found." msgstr "Proyecto principal '{name}' no encontrado." -#: tt/TimeTracker.py:1420 +#: tt/TimeTracker.py:1878 #, python-brace-format msgid "No time entries found for task '{task_name}'." msgstr "No se encontraron entradas de tiempo para la tarea '{task_name}'." -#: tt/TimeTracker.py:1453 tt/TimeTracker.py:1683 +#: tt/TimeTracker.py:1911 tt/TimeTracker.py:2141 msgid "now" msgstr "ahora" -#: tt/TimeTracker.py:1458 +#: tt/TimeTracker.py:1916 #, python-brace-format msgid "# Detailed Report for Task: {name}" msgstr "# Informe detallado de la tarea: {name}" -#: tt/TimeTracker.py:1459 +#: tt/TimeTracker.py:1917 #, python-brace-format msgid "Part of Main Project: {name}" msgstr "Parte del Proyecto Principal: {name}" -#: tt/TimeTracker.py:1462 +#: tt/TimeTracker.py:1920 msgid "Active (currently running)" msgstr "Activo (actualmente en ejecución)" -#: tt/TimeTracker.py:1462 tt/TimeTracker.py:1559 +#: tt/TimeTracker.py:1920 tt/TimeTracker.py:2017 msgid "Inactive" msgstr "Inactivo" -#: tt/TimeTracker.py:1463 tt/TimeTracker.py:1560 +#: tt/TimeTracker.py:1921 tt/TimeTracker.py:2018 msgid "Status" msgstr "Estado" -#: tt/TimeTracker.py:1465 tt/TimeTracker.py:1562 +#: tt/TimeTracker.py:1923 tt/TimeTracker.py:2020 msgid "First entry" msgstr "Primera entrada" -#: tt/TimeTracker.py:1467 tt/TimeTracker.py:1564 +#: tt/TimeTracker.py:1925 tt/TimeTracker.py:2022 msgid "Last activity" msgstr "Última actividad" -#: tt/TimeTracker.py:1469 tt/TimeTracker.py:1566 +#: tt/TimeTracker.py:1927 tt/TimeTracker.py:2024 msgid "Total recorded time" msgstr "Tiempo total registrado" -#: tt/TimeTracker.py:1470 tt/TimeTracker.py:1568 +#: tt/TimeTracker.py:1928 tt/TimeTracker.py:2026 msgid "Total work sessions" msgstr "Total de sesiones de trabajo" -#: tt/TimeTracker.py:1474 tt/TimeTracker.py:1572 +#: tt/TimeTracker.py:1932 tt/TimeTracker.py:2030 msgid "Average session duration" msgstr "Duración media de la sesión" -#: tt/TimeTracker.py:1477 tt/TimeTracker.py:1575 +#: tt/TimeTracker.py:1935 tt/TimeTracker.py:2033 msgid "Weekday Distribution" msgstr "Distribución por día de la semana" -#: tt/TimeTracker.py:1487 +#: tt/TimeTracker.py:1945 msgid "Daily Breakdown" msgstr "Desglose diario" -#: tt/TimeTracker.py:1556 +#: tt/TimeTracker.py:2014 #, python-brace-format msgid "# Detailed Report for Main Project: {name}" msgstr "# Informe Detallado del Proyecto Principal: {name}" -#: tt/TimeTracker.py:1559 +#: tt/TimeTracker.py:2017 #, python-brace-format msgid "Active (working on '{task_name}')" msgstr "Activo (trabajando en '{task_name}')" -#: tt/TimeTracker.py:1567 +#: tt/TimeTracker.py:2025 msgid "Number of tasks" msgstr "Número de tareas" -#: tt/TimeTracker.py:1586 +#: tt/TimeTracker.py:2044 msgid "Task Breakdown" msgstr "Desglose de tareas" -#: tt/TimeTracker.py:1595 +#: tt/TimeTracker.py:2053 #, python-brace-format msgid "{num_sessions} sessions" msgstr "{num_sessions} sesiones" -#: tt/TimeTracker.py:1648 +#: tt/TimeTracker.py:2106 #, python-brace-format msgid "# Time Report: {start_date} to {end_date}\n" msgstr "# Informe de Tiempo: {start_date} a {end_date}\n" -#: tt/TimeTracker.py:1649 +#: tt/TimeTracker.py:2107 #, python-brace-format msgid "" "\n" @@ -1438,17 +1638,17 @@ msgstr "" "\n" "**Tiempo Total en el Periodo: {total_time}**" -#: tt/TimeTracker.py:1653 +#: tt/TimeTracker.py:2111 #, python-brace-format msgid "No time tracked between {start_date} and {end_date}." msgstr "No hay tiempo registrado entre {start_date} y {end_date}." -#: tt/TimeTracker.py:1669 +#: tt/TimeTracker.py:2127 #, python-brace-format msgid "# Detailed Daily Report: {date}" msgstr "# Informe Diario Detallado: {date}" -#: update.py:35 +#: update.py:101 msgid "" "Warning: Update check skipped. 'github_repo' not found in config.json or " "file is invalid." @@ -1456,83 +1656,96 @@ msgstr "" "Advertencia: Comprobación de actualizaciones omitida. 'github_repo' no " "encontrado en config.json o el archivo es inválido." -#: update.py:55 +#: update.py:121 msgid "Error: Download URL for the new version not found." msgstr "Error: No se encontró la URL de descarga para la nueva versión." -#: update.py:59 +#: update.py:125 +msgid "Warning: Update check timed out (no internet connection?). Skipping." +msgstr "" +"Advertencia: se agotó el tiempo de la comprobación de actualizaciones (¿sin " +"conexión a internet?). Se omite." + +#: update.py:127 #, python-brace-format msgid "Error checking for updates: {error}" msgstr "Error al buscar actualizaciones: {error}" -#: update.py:61 +#: update.py:129 #, python-brace-format msgid "An unexpected error occurred while checking for updates: {error}" msgstr "Ocurrió un error inesperado al buscar actualizaciones: {error}" -#: update.py:73 +#: update.py:141 msgid "Downloading update..." msgstr "Descargando actualización..." -#: update.py:79 +#: update.py:147 msgid "Download complete. The update will be installed on the next start." msgstr "Descarga completa. La actualización se instalará en el próximo inicio." -#: update.py:82 +#: update.py:150 +msgid "" +"Error: Connecting to the update server timed out (no internet connection?)." +msgstr "" +"Error: se agotó el tiempo al conectar con el servidor de actualizaciones " +"(¿sin conexión a internet?)." + +#: update.py:155 #, python-brace-format msgid "Error downloading the update: {error}" msgstr "Error al descargar la actualización: {error}" -#: update.py:98 +#: update.py:171 msgid "Restarting application to apply the update..." msgstr "Reiniciando la aplicación para aplicar la actualización..." -#: update.py:122 +#: update.py:195 msgid "Creating backup of current version before update..." msgstr "" "Creando copia de seguridad de la versión actual antes de la actualización..." -#: update.py:132 +#: update.py:205 #, python-brace-format msgid "Backup created successfully as {filename}." msgstr "Copia de seguridad creada con éxito como {filename}." -#: update.py:134 +#: update.py:207 #, python-brace-format msgid "Warning: Could not create backup. Error: {error}" msgstr "Advertencia: No se pudo crear la copia de seguridad. Error: {error}" -#: update.py:136 +#: update.py:209 msgid "Installing update..." msgstr "Instalando actualización..." -#: update.py:156 +#: update.py:229 #, python-brace-format msgid "Skipping protected file: {filename}. It will not be overwritten." msgstr "Omitiendo archivo protegido: {filename}. No será sobrescrito." -#: update.py:165 +#: update.py:238 msgid "Update installed successfully." msgstr "Actualización instalada con éxito." -#: update.py:167 +#: update.py:240 #, python-brace-format msgid "Error during update installation: {error}" msgstr "Error durante la instalación de la actualización: {error}" -#: update.py:182 +#: update.py:255 #, python-brace-format msgid "Error: No previous version backup '{filename}' found." msgstr "" "Error: No se encontró la copia de seguridad de la versión anterior " "'{filename}'." -#: update.py:185 +#: update.py:258 #, python-brace-format msgid "Restoring previous version from '{filename}'..." msgstr "Restaurando la versión anterior desde '{filename}'..." -#: update.py:206 +#: update.py:279 #, python-brace-format msgid "" "Skipping user data file: {filename}. It will not be overwritten during " @@ -1541,33 +1754,33 @@ msgstr "" "Omitiendo archivo de datos de usuario: {filename}. No será sobrescrito " "durante la restauración." -#: update.py:213 +#: update.py:286 msgid "Previous version restored successfully." msgstr "Versión anterior restaurada con éxito." -#: update.py:215 +#: update.py:288 msgid "Restarting application to apply changes..." msgstr "Reiniciando la aplicación para aplicar los cambios..." -#: update.py:218 +#: update.py:291 #, python-brace-format msgid "Error during restoration: {error}" msgstr "Error durante la restauración: {error}" -#: update.py:219 +#: update.py:292 #, python-brace-format msgid "The backup file '{filename}' was not deleted." msgstr "El archivo de copia de seguridad '{filename}' no fue eliminado." -#: update.py:226 +#: update.py:299 msgid "Error: Could not import TimeTracker to get the current version." msgstr "Error: No se pudo importar TimeTracker para obtener la versión actual." -#: update.py:229 +#: update.py:302 msgid "Checking for updates..." msgstr "Buscando actualizaciones..." -#: update.py:236 +#: update.py:309 msgid "No updates available." msgstr "No hay actualizaciones disponibles." diff --git a/locale/fr/LC_MESSAGES/timetracker.mo b/locale/fr/LC_MESSAGES/timetracker.mo index 119aa7ef928894bf39639aab523fd8d8bb9290ab..d4b415943a4142b82773c166571afadc9aa8180b 100644 GIT binary patch delta 12650 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 E4<+*$hX4Qo delta 7555 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 diff --git a/locale/fr/LC_MESSAGES/timetracker.po b/locale/fr/LC_MESSAGES/timetracker.po index 0bf8396..ae4e7d3 100644 --- a/locale/fr/LC_MESSAGES/timetracker.po +++ b/locale/fr/LC_MESSAGES/timetracker.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Project-Id-Version: TimeControl\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-08-06 10:27+0200\n" +"POT-Creation-Date: 2026-08-11 17:36+0200\n" "PO-Revision-Date: 2026-01-01 15:00+0200\n" "Last-Translator: Frank Faulstich\n" "Language-Team: French\n" @@ -16,426 +16,508 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" -#: sl/SL_Menu.py:278 sl/SL_Menu.py:1005 sl/SL_Menu.py:2343 sl/SL_Menu.py:2515 +#: sl/SL_Menu.py:294 sl/SL_Menu.py:1134 sl/SL_Menu.py:2645 sl/SL_Menu.py:2817 msgid "Priority" msgstr "Priorité" -#: sl/SL_Menu.py:462 +#: sl/SL_Menu.py:547 #, python-brace-format msgid "Version {version}" msgstr "Version {version}" -#: sl/SL_Menu.py:467 update.py:49 +#: sl/SL_Menu.py:552 update.py:115 #, python-brace-format msgid "A new version ({version}) is available." msgstr "Une nouvelle version ({version}) est disponible." -#: sl/SL_Menu.py:469 +#: sl/SL_Menu.py:554 msgid "Restart and install the update" msgstr "Redémarrer et installer la mise à jour" -#: sl/SL_Menu.py:470 +#: sl/SL_Menu.py:555 msgid "Downloading and installing update..." msgstr "Téléchargement et installation de la mise à jour..." -#: sl/SL_Menu.py:501 +#: sl/SL_Menu.py:590 +#, python-brace-format +msgid "" +"{count} time entries were discarded because the task they belonged to had " +"been deleted on another machine." +msgstr "{count} entrées de temps ont été supprimées car la tâche à laquelle elles appartenaient avait été supprimée sur un autre ordinateur." + +#: sl/SL_Menu.py:613 +#, python-brace-format +msgid "Synchronisation is paused: {reason}" +msgstr "La synchronisation est suspendue : {reason}" + +#: sl/SL_Menu.py:635 msgid "New" msgstr "Nouveau" -#: sl/SL_Menu.py:502 +#: sl/SL_Menu.py:636 msgid "New Project" msgstr "Nouveau projet" -#: sl/SL_Menu.py:505 +#: sl/SL_Menu.py:639 msgid "New Task" msgstr "Nouvelle tâche" -#: sl/SL_Menu.py:510 +#: sl/SL_Menu.py:644 msgid "Project & Task Management" msgstr "Gestion des projets et des tâches" -#: sl/SL_Menu.py:511 sl/SL_Menu.py:1217 +#: sl/SL_Menu.py:645 sl/SL_Menu.py:1349 msgid "Main Project Management" msgstr "Gestion du projet principal" -#: sl/SL_Menu.py:512 sl/SL_Menu.py:1233 sl/SL_Menu.py:1628 +#: sl/SL_Menu.py:646 sl/SL_Menu.py:1365 sl/SL_Menu.py:1930 msgid "Add Project" msgstr "Ajouter un projet" -#: sl/SL_Menu.py:515 sl/SL_Menu.py:1236 sl/SL_Menu.py:1976 +#: sl/SL_Menu.py:649 sl/SL_Menu.py:1368 sl/SL_Menu.py:2278 msgid "List Projects" msgstr "Liste des projets" -#: sl/SL_Menu.py:518 sl/SL_Menu.py:1239 sl/SL_Menu.py:1991 +#: sl/SL_Menu.py:652 sl/SL_Menu.py:1371 sl/SL_Menu.py:2293 msgid "Rename Project" msgstr "Renommer le projet" -#: sl/SL_Menu.py:521 sl/SL_Menu.py:1242 sl/SL_Menu.py:2106 sl/SL_Menu.py:2119 +#: sl/SL_Menu.py:655 sl/SL_Menu.py:1374 sl/SL_Menu.py:2408 sl/SL_Menu.py:2421 msgid "Close Project" msgstr "Fermer le projet" -#: sl/SL_Menu.py:524 sl/SL_Menu.py:1245 sl/SL_Menu.py:2136 sl/SL_Menu.py:2149 +#: sl/SL_Menu.py:658 sl/SL_Menu.py:1377 sl/SL_Menu.py:2438 sl/SL_Menu.py:2451 msgid "Re-open Project" msgstr "Rouvrir le projet" -#: sl/SL_Menu.py:527 sl/SL_Menu.py:1248 sl/SL_Menu.py:2166 sl/SL_Menu.py:2180 +#: sl/SL_Menu.py:661 sl/SL_Menu.py:1380 sl/SL_Menu.py:2468 sl/SL_Menu.py:2482 msgid "Delete Project" msgstr "Supprimer le projet" -#: sl/SL_Menu.py:530 sl/SL_Menu.py:1251 sl/SL_Menu.py:2197 +#: sl/SL_Menu.py:664 sl/SL_Menu.py:1383 sl/SL_Menu.py:2499 msgid "List Inactive Projects" msgstr "Liste des projets inactifs" -#: sl/SL_Menu.py:533 sl/SL_Menu.py:1254 +#: sl/SL_Menu.py:667 sl/SL_Menu.py:1386 msgid "Demote Project to Task" msgstr "Rétrograder le projet en tâche" -#: sl/SL_Menu.py:536 sl/SL_Menu.py:1257 sl/SL_Menu.py:2259 +#: sl/SL_Menu.py:670 sl/SL_Menu.py:1389 sl/SL_Menu.py:2561 msgid "List Completed Projects" msgstr "Liste des projets terminés" -#: sl/SL_Menu.py:540 sl/SL_Menu.py:1219 sl/SL_Menu.py:1270 +#: sl/SL_Menu.py:674 sl/SL_Menu.py:1351 sl/SL_Menu.py:1402 msgid "Task Management" msgstr "Gestion des tâches" -#: sl/SL_Menu.py:541 sl/SL_Menu.py:1272 sl/SL_Menu.py:2277 sl/SL_Menu.py:2308 -#: sl/SL_Menu.py:2383 +#: sl/SL_Menu.py:675 sl/SL_Menu.py:1404 sl/SL_Menu.py:2579 sl/SL_Menu.py:2610 +#: sl/SL_Menu.py:2685 msgid "Add Task" msgstr "Ajouter une tâche" -#: sl/SL_Menu.py:544 sl/SL_Menu.py:1275 sl/SL_Menu.py:2026 +#: sl/SL_Menu.py:678 sl/SL_Menu.py:1407 sl/SL_Menu.py:2328 msgid "List Tasks" msgstr "Liste des tâches" -#: sl/SL_Menu.py:547 sl/SL_Menu.py:1278 sl/SL_Menu.py:2059 +#: sl/SL_Menu.py:681 sl/SL_Menu.py:1410 sl/SL_Menu.py:2361 msgid "Rename Task" msgstr "Renommer la tâche" -#: sl/SL_Menu.py:550 sl/SL_Menu.py:1281 sl/SL_Menu.py:1643 sl/SL_Menu.py:1669 -#: sl/SL_Menu.py:1848 +#: sl/SL_Menu.py:684 sl/SL_Menu.py:1413 sl/SL_Menu.py:1945 sl/SL_Menu.py:1971 +#: sl/SL_Menu.py:2150 msgid "Close Task" msgstr "Fermer la tâche" -#: sl/SL_Menu.py:553 sl/SL_Menu.py:1284 sl/SL_Menu.py:1688 sl/SL_Menu.py:1714 +#: sl/SL_Menu.py:687 sl/SL_Menu.py:1416 sl/SL_Menu.py:1990 sl/SL_Menu.py:2016 msgid "Re-open Task" msgstr "Rouvrir la tâche" -#: sl/SL_Menu.py:556 sl/SL_Menu.py:1287 sl/SL_Menu.py:1733 sl/SL_Menu.py:1760 +#: sl/SL_Menu.py:690 sl/SL_Menu.py:1419 sl/SL_Menu.py:2035 sl/SL_Menu.py:2062 msgid "Delete Task" msgstr "Supprimer la tâche" -#: sl/SL_Menu.py:559 sl/SL_Menu.py:1290 sl/SL_Menu.py:1779 sl/SL_Menu.py:1815 +#: sl/SL_Menu.py:693 sl/SL_Menu.py:1422 sl/SL_Menu.py:2081 sl/SL_Menu.py:2117 msgid "Move Task" msgstr "Déplacer la tâche" -#: sl/SL_Menu.py:562 sl/SL_Menu.py:1293 sl/SL_Menu.py:1834 +#: sl/SL_Menu.py:696 sl/SL_Menu.py:1425 sl/SL_Menu.py:2136 msgid "List Inactive Tasks" msgstr "Liste des tâches inactives" -#: sl/SL_Menu.py:565 sl/SL_Menu.py:1296 sl/SL_Menu.py:1861 +#: sl/SL_Menu.py:699 sl/SL_Menu.py:1428 sl/SL_Menu.py:2163 msgid "List All Closed Tasks" msgstr "Liste de toutes les tâches fermées" -#: sl/SL_Menu.py:568 sl/SL_Menu.py:727 sl/SL_Menu.py:813 sl/SL_Menu.py:1027 -#: sl/SL_Menu.py:1299 sl/SL_Menu.py:2411 sl/SL_Menu.py:2431 sl/SL_Menu.py:2466 +#: sl/SL_Menu.py:702 sl/SL_Menu.py:861 sl/SL_Menu.py:945 sl/SL_Menu.py:1155 +#: sl/SL_Menu.py:1431 sl/SL_Menu.py:2713 sl/SL_Menu.py:2733 sl/SL_Menu.py:2768 msgid "Edit Task" msgstr "Modifier la tâche" -#: sl/SL_Menu.py:571 sl/SL_Menu.py:1302 sl/SL_Menu.py:1886 +#: sl/SL_Menu.py:705 sl/SL_Menu.py:1434 sl/SL_Menu.py:2188 msgid "Delete All Closed Tasks" msgstr "Supprimer toutes les tâches fermées" -#: sl/SL_Menu.py:574 sl/SL_Menu.py:1305 sl/SL_Menu.py:1928 +#: sl/SL_Menu.py:708 sl/SL_Menu.py:1437 sl/SL_Menu.py:2230 msgid "Promote Task to Project" msgstr "Promouvoir la tâche en projet" -#: sl/SL_Menu.py:579 +#: sl/SL_Menu.py:713 msgid "Today View" msgstr "Vue du jour" -#: sl/SL_Menu.py:584 sl/SL_Menu.py:643 +#: sl/SL_Menu.py:718 sl/SL_Menu.py:777 msgid "Task Planning" msgstr "Planification des tâches" -#: sl/SL_Menu.py:589 sl/SL_Menu.py:1062 +#: sl/SL_Menu.py:723 sl/SL_Menu.py:1189 msgid "E-Mail Task Assignment" msgstr "Attribution des tâches par e-mail" -#: sl/SL_Menu.py:594 sl/SL_Menu.py:723 sl/SL_Menu.py:809 sl/SL_Menu.py:1023 +#: sl/SL_Menu.py:728 sl/SL_Menu.py:857 sl/SL_Menu.py:941 sl/SL_Menu.py:1151 msgid "Start work on task" msgstr "Démarrer le travail sur la tâche" -#: sl/SL_Menu.py:599 +#: sl/SL_Menu.py:733 msgid "Show current work" msgstr "Afficher le travail en cours" -#: sl/SL_Menu.py:604 +#: sl/SL_Menu.py:738 msgid "Stop current work" msgstr "Arrêter le travail en cours" -#: sl/SL_Menu.py:606 +#: sl/SL_Menu.py:740 msgid "Work session stopped successfully." msgstr "Session de travail arrêtée avec succès." -#: sl/SL_Menu.py:608 +#: sl/SL_Menu.py:742 msgid "No active work session to stop." msgstr "Aucune session de travail active à arrêter." -#: sl/SL_Menu.py:612 sl/SL_Menu.py:1318 +#: sl/SL_Menu.py:746 sl/SL_Menu.py:1450 msgid "Reporting" msgstr "Rapports" -#: sl/SL_Menu.py:613 sl/SL_Menu.py:1321 +#: sl/SL_Menu.py:747 sl/SL_Menu.py:1453 msgid "Daily Report (Today)" msgstr "Rapport quotidien (aujourd'hui)" -#: sl/SL_Menu.py:618 sl/SL_Menu.py:1327 sl/SL_Menu.py:2661 +#: sl/SL_Menu.py:752 sl/SL_Menu.py:1459 sl/SL_Menu.py:2967 msgid "Daily Report (Specific Day)" msgstr "Rapport journalier (Jour spécifique)" -#: sl/SL_Menu.py:621 sl/SL_Menu.py:1330 sl/SL_Menu.py:2680 +#: sl/SL_Menu.py:755 sl/SL_Menu.py:1462 sl/SL_Menu.py:2986 msgid "Date Range Report" msgstr "Rapport par plage de dates" -#: sl/SL_Menu.py:624 sl/SL_Menu.py:1333 sl/SL_Menu.py:2707 sl/SL_Menu.py:2738 +#: sl/SL_Menu.py:758 sl/SL_Menu.py:1465 sl/SL_Menu.py:3013 sl/SL_Menu.py:3044 msgid "Detailed Task Report" msgstr "Rapport détaillé de tâche" -#: sl/SL_Menu.py:627 sl/SL_Menu.py:1336 sl/SL_Menu.py:2766 +#: sl/SL_Menu.py:761 sl/SL_Menu.py:1468 sl/SL_Menu.py:3072 msgid "Detailed Project Report" msgstr "Rapport détaillé de projet" -#: sl/SL_Menu.py:630 sl/SL_Menu.py:1339 sl/SL_Menu.py:2793 +#: sl/SL_Menu.py:764 sl/SL_Menu.py:1471 sl/SL_Menu.py:3099 msgid "Detailed Daily Report" msgstr "Rapport journalier détaillé" -#: sl/SL_Menu.py:635 sl/SL_Menu.py:1356 +#: sl/SL_Menu.py:769 sl/SL_Menu.py:1518 msgid "Settings" msgstr "Paramètres" -#: sl/SL_Menu.py:648 sl/SL_Menu.py:668 sl/SL_Menu.py:734 sl/SL_Menu.py:820 -#: sl/SL_Menu.py:1177 sl/SL_Menu.py:2338 sl/SL_Menu.py:2509 +#: sl/SL_Menu.py:782 sl/SL_Menu.py:802 sl/SL_Menu.py:868 sl/SL_Menu.py:952 +#: sl/SL_Menu.py:1304 sl/SL_Menu.py:2640 sl/SL_Menu.py:2811 msgid "Today" msgstr "Aujourd'hui" -#: sl/SL_Menu.py:649 sl/SL_Menu.py:669 +#: sl/SL_Menu.py:783 sl/SL_Menu.py:803 msgid "Tomorrow" msgstr "Demain" -#: sl/SL_Menu.py:650 sl/SL_Menu.py:670 +#: sl/SL_Menu.py:784 sl/SL_Menu.py:804 msgid "Weekly overview" msgstr "Aperçu hebdomadaire" -#: sl/SL_Menu.py:651 sl/SL_Menu.py:671 +#: sl/SL_Menu.py:785 sl/SL_Menu.py:805 msgid "Overdue tasks" msgstr "Tâches en retard" -#: sl/SL_Menu.py:652 sl/SL_Menu.py:672 +#: sl/SL_Menu.py:786 sl/SL_Menu.py:806 msgid "Unplanned tasks" msgstr "Tâches non planifiées" -#: sl/SL_Menu.py:653 +#: sl/SL_Menu.py:787 msgid "All" msgstr "Tous" -#: sl/SL_Menu.py:660 +#: sl/SL_Menu.py:794 msgid "Filter" msgstr "Filtrer" -#: sl/SL_Menu.py:679 +#: sl/SL_Menu.py:813 msgid "Tasks" msgstr "Tâches" -#: sl/SL_Menu.py:686 tt/TimeTracker.py:1479 tt/TimeTracker.py:1577 +#: sl/SL_Menu.py:820 tt/TimeTracker.py:1937 tt/TimeTracker.py:2035 msgid "Friday" msgstr "Vendredi" -#: sl/SL_Menu.py:686 tt/TimeTracker.py:1479 tt/TimeTracker.py:1577 +#: sl/SL_Menu.py:820 tt/TimeTracker.py:1937 tt/TimeTracker.py:2035 msgid "Monday" msgstr "Lundi" -#: sl/SL_Menu.py:686 tt/TimeTracker.py:1479 tt/TimeTracker.py:1577 +#: sl/SL_Menu.py:820 tt/TimeTracker.py:1937 tt/TimeTracker.py:2035 msgid "Saturday" msgstr "Samedi" -#: sl/SL_Menu.py:686 tt/TimeTracker.py:1479 tt/TimeTracker.py:1577 +#: sl/SL_Menu.py:820 tt/TimeTracker.py:1937 tt/TimeTracker.py:2035 msgid "Sunday" msgstr "Dimanche" -#: sl/SL_Menu.py:686 tt/TimeTracker.py:1479 tt/TimeTracker.py:1577 +#: sl/SL_Menu.py:820 tt/TimeTracker.py:1937 tt/TimeTracker.py:2035 msgid "Thursday" msgstr "Jeudi" -#: sl/SL_Menu.py:686 tt/TimeTracker.py:1479 tt/TimeTracker.py:1577 +#: sl/SL_Menu.py:820 tt/TimeTracker.py:1937 tt/TimeTracker.py:2035 msgid "Tuesday" msgstr "Mardi" -#: sl/SL_Menu.py:686 tt/TimeTracker.py:1479 tt/TimeTracker.py:1577 +#: sl/SL_Menu.py:820 tt/TimeTracker.py:1937 tt/TimeTracker.py:2035 msgid "Wednesday" msgstr "Mercredi" -#: sl/SL_Menu.py:747 sl/SL_Menu.py:833 sl/SL_Menu.py:885 sl/SL_Menu.py:1034 -#: sl/SL_Menu.py:2511 +#: sl/SL_Menu.py:880 sl/SL_Menu.py:964 sl/SL_Menu.py:1015 sl/SL_Menu.py:1162 +#: sl/SL_Menu.py:2813 msgid "Done" msgstr "Terminé" -#: sl/SL_Menu.py:797 sl/SL_Menu.py:993 +#: sl/SL_Menu.py:929 sl/SL_Menu.py:1122 msgid "Due" msgstr "Due" -#: sl/SL_Menu.py:848 +#: sl/SL_Menu.py:978 msgid "No tasks found." msgstr "Aucune tâche trouvée." -#: sl/SL_Menu.py:850 sl/SL_Menu.py:1206 sl/SL_Menu.py:1224 sl/SL_Menu.py:1263 -#: sl/SL_Menu.py:1311 sl/SL_Menu.py:1345 sl/SL_Menu.py:1615 sl/SL_Menu.py:1648 -#: sl/SL_Menu.py:1659 sl/SL_Menu.py:1693 sl/SL_Menu.py:1704 sl/SL_Menu.py:1738 -#: sl/SL_Menu.py:1749 sl/SL_Menu.py:1784 sl/SL_Menu.py:1795 sl/SL_Menu.py:1803 -#: sl/SL_Menu.py:1854 sl/SL_Menu.py:1879 sl/SL_Menu.py:1901 sl/SL_Menu.py:1933 -#: sl/SL_Menu.py:1944 sl/SL_Menu.py:1984 sl/SL_Menu.py:1996 sl/SL_Menu.py:2031 -#: sl/SL_Menu.py:2052 sl/SL_Menu.py:2064 sl/SL_Menu.py:2075 sl/SL_Menu.py:2111 -#: sl/SL_Menu.py:2141 sl/SL_Menu.py:2171 sl/SL_Menu.py:2211 sl/SL_Menu.py:2223 -#: sl/SL_Menu.py:2270 sl/SL_Menu.py:2282 sl/SL_Menu.py:2416 sl/SL_Menu.py:2435 -#: sl/SL_Menu.py:2448 sl/SL_Menu.py:2463 sl/SL_Menu.py:2600 sl/SL_Menu.py:2610 -#: sl/SL_Menu.py:2654 sl/SL_Menu.py:2673 sl/SL_Menu.py:2700 sl/SL_Menu.py:2712 -#: sl/SL_Menu.py:2724 sl/SL_Menu.py:2743 sl/SL_Menu.py:2759 sl/SL_Menu.py:2771 -#: sl/SL_Menu.py:2786 sl/SL_Menu.py:2805 sl/SL_Menu.py:2846 +#: sl/SL_Menu.py:980 sl/SL_Menu.py:1338 sl/SL_Menu.py:1356 sl/SL_Menu.py:1395 +#: sl/SL_Menu.py:1443 sl/SL_Menu.py:1477 sl/SL_Menu.py:1917 sl/SL_Menu.py:1950 +#: sl/SL_Menu.py:1961 sl/SL_Menu.py:1995 sl/SL_Menu.py:2006 sl/SL_Menu.py:2040 +#: sl/SL_Menu.py:2051 sl/SL_Menu.py:2086 sl/SL_Menu.py:2097 sl/SL_Menu.py:2105 +#: sl/SL_Menu.py:2156 sl/SL_Menu.py:2181 sl/SL_Menu.py:2203 sl/SL_Menu.py:2235 +#: sl/SL_Menu.py:2246 sl/SL_Menu.py:2286 sl/SL_Menu.py:2298 sl/SL_Menu.py:2333 +#: sl/SL_Menu.py:2354 sl/SL_Menu.py:2366 sl/SL_Menu.py:2377 sl/SL_Menu.py:2413 +#: sl/SL_Menu.py:2443 sl/SL_Menu.py:2473 sl/SL_Menu.py:2513 sl/SL_Menu.py:2525 +#: sl/SL_Menu.py:2572 sl/SL_Menu.py:2584 sl/SL_Menu.py:2718 sl/SL_Menu.py:2737 +#: sl/SL_Menu.py:2750 sl/SL_Menu.py:2765 sl/SL_Menu.py:2906 sl/SL_Menu.py:2916 +#: sl/SL_Menu.py:2960 sl/SL_Menu.py:2979 sl/SL_Menu.py:3006 sl/SL_Menu.py:3018 +#: sl/SL_Menu.py:3030 sl/SL_Menu.py:3049 sl/SL_Menu.py:3065 sl/SL_Menu.py:3077 +#: sl/SL_Menu.py:3092 sl/SL_Menu.py:3111 sl/SL_Menu.py:3152 msgid "Back" msgstr "Retour" -#: sl/SL_Menu.py:861 +#: sl/SL_Menu.py:991 msgid "Today's Tasks" msgstr "Tâches du jour" -#: sl/SL_Menu.py:880 sl/SL_Menu.py:2639 +#: sl/SL_Menu.py:1010 sl/SL_Menu.py:2945 msgid "Current Active Work" msgstr "Travail actif en cours" -#: sl/SL_Menu.py:882 sl/SL_Menu.py:2652 +#: sl/SL_Menu.py:1012 sl/SL_Menu.py:2958 msgid "No active work session." msgstr "Aucune session de travail active." -#: sl/SL_Menu.py:899 +#: sl/SL_Menu.py:1028 msgid "Edit current task" msgstr "Modifier la tâche actuelle" -#: sl/SL_Menu.py:923 +#: sl/SL_Menu.py:1052 msgid "Show only open tasks" msgstr "Afficher uniquement les tâches ouvertes" -#: sl/SL_Menu.py:936 +#: sl/SL_Menu.py:1065 msgid "Sort by priority" msgstr "Trier par priorité" -#: sl/SL_Menu.py:1008 sl/SL_Menu.py:2343 sl/SL_Menu.py:2515 +#: sl/SL_Menu.py:1137 sl/SL_Menu.py:2645 sl/SL_Menu.py:2817 msgid "0 (lowest) to 9 (highest)" msgstr "0 (la plus basse) à 9 (la plus élevée)" -#: sl/SL_Menu.py:1049 +#: sl/SL_Menu.py:1176 msgid "No open tasks for today." msgstr "Aucune tâche ouverte pour aujourd'hui." -#: sl/SL_Menu.py:1051 +#: sl/SL_Menu.py:1178 msgid "No tasks for today." msgstr "Aucune tâche pour aujourd'hui." -#: sl/SL_Menu.py:1055 +#: sl/SL_Menu.py:1182 msgid "Exit" msgstr "Quitter" -#: sl/SL_Menu.py:1068 +#: sl/SL_Menu.py:1195 msgid "Fetching emails..." msgstr "Récupération des e-mails..." -#: sl/SL_Menu.py:1071 +#: sl/SL_Menu.py:1198 #, python-brace-format msgid "Error fetching emails: {error}" msgstr "Erreur lors de la récupération des e-mails : {error}" -#: sl/SL_Menu.py:1074 +#: sl/SL_Menu.py:1201 #, python-brace-format msgid "{count} new tasks created from emails." msgstr "{count} nouvelles tâches créées à partir des e-mails." -#: sl/SL_Menu.py:1076 +#: sl/SL_Menu.py:1203 msgid "No new emails found." msgstr "Aucun nouvel e-mail trouvé." -#: sl/SL_Menu.py:1101 +#: sl/SL_Menu.py:1228 #, python-brace-format msgid "{remaining} of {total} emails still to process" msgstr "{remaining} e-mails sur {total} restants à traiter" -#: sl/SL_Menu.py:1105 +#: sl/SL_Menu.py:1232 msgid "No unassigned email tasks available." msgstr "Aucune tâche par e-mail non attribuée disponible." -#: sl/SL_Menu.py:1117 +#: sl/SL_Menu.py:1244 msgid "Assign Project" msgstr "Attribuer un projet" -#: sl/SL_Menu.py:1128 +#: sl/SL_Menu.py:1255 msgid "Are you sure you want to delete this task?" msgstr "Êtes-vous sûr de vouloir supprimer cette tâche ?" -#: sl/SL_Menu.py:1131 +#: sl/SL_Menu.py:1258 msgid "Yes, delete" msgstr "Oui, supprimer" -#: sl/SL_Menu.py:1136 +#: sl/SL_Menu.py:1263 msgid "No, cancel" msgstr "Non, annuler" -#: sl/SL_Menu.py:1143 +#: sl/SL_Menu.py:1270 msgid "Delete" msgstr "Supprimer" -#: sl/SL_Menu.py:1147 +#: sl/SL_Menu.py:1274 msgid "Edit Details" msgstr "Modifier les détails" -#: sl/SL_Menu.py:1155 sl/SL_Menu.py:2495 +#: sl/SL_Menu.py:1282 sl/SL_Menu.py:2797 msgid "Task Name" msgstr "Nom de la tâche" -#: sl/SL_Menu.py:1164 sl/SL_Menu.py:2499 +#: sl/SL_Menu.py:1291 sl/SL_Menu.py:2801 msgid "Due Date" msgstr "Date d'échéance" -#: sl/SL_Menu.py:1172 sl/SL_Menu.py:2503 +#: sl/SL_Menu.py:1299 sl/SL_Menu.py:2805 msgid "Clear" msgstr "Effacer" -#: sl/SL_Menu.py:1179 sl/SL_Menu.py:2371 sl/SL_Menu.py:2544 +#: sl/SL_Menu.py:1306 sl/SL_Menu.py:2673 sl/SL_Menu.py:2846 msgid "Notes (Markdown)" msgstr "Aucun temps suivi entre le {start_date} et le {end_date}." -#: sl/SL_Menu.py:1198 +#: sl/SL_Menu.py:1330 msgid "Task details updated successfully." msgstr "Détails de la tâche mis à jour avec succès." -#: sl/SL_Menu.py:1204 +#: sl/SL_Menu.py:1336 msgid "Error updating task details." msgstr "Erreur lors de la mise à jour des détails de la tâche." -#: sl/SL_Menu.py:1215 sl/SL_Menu.py:1231 +#: sl/SL_Menu.py:1347 sl/SL_Menu.py:1363 msgid "Project Management" msgstr "Gestion de projet" -#: sl/SL_Menu.py:1359 +#: sl/SL_Menu.py:1490 +msgid "No server address is set. Enter one above and save it first." +msgstr "" +"Aucune adresse de serveur n'est renseignée. Saisissez-en une ci-dessus et " +"enregistrez." + +#: sl/SL_Menu.py:1491 +msgid "" +"The address must start with https:// - a token sent over plain HTTP could be " +"read by anyone on the way." +msgstr "" +"L'adresse doit commencer par https:// – un jeton envoyé en HTTP non chiffré " +"pourrait être lu par n'importe qui en chemin." + +#: sl/SL_Menu.py:1493 +msgid "Please enter both a username and a password." +msgstr "Veuillez saisir un nom d'utilisateur et un mot de passe." + +#: sl/SL_Menu.py:1494 +msgid "Wrong username or password." +msgstr "Nom d'utilisateur ou mot de passe incorrect." + +#: sl/SL_Menu.py:1495 +msgid "Too many sign-in attempts on the server. Try again in a minute." +msgstr "" +"Trop de tentatives de connexion sur le serveur. Réessayez dans une minute." + +#: sl/SL_Menu.py:1496 +msgid "The server's certificate could not be verified." +msgstr "Le certificat du serveur n'a pas pu être vérifié." + +#: sl/SL_Menu.py:1497 +msgid "The server did not answer in time." +msgstr "Le serveur n'a pas répondu à temps." + +#: sl/SL_Menu.py:1498 +msgid "The server could not be reached. Check the address and your connection." +msgstr "Le serveur est injoignable. Vérifiez l'adresse et votre connexion." + +#: sl/SL_Menu.py:1499 +msgid "" +"The address answered, but not like a TimeControl sync server. Check that it " +"points at the right directory." +msgstr "" +"L'adresse a répondu, mais pas comme un serveur de synchronisation " +"TimeControl. Vérifiez qu'elle pointe vers le bon répertoire." + +#: sl/SL_Menu.py:1501 +msgid "The server is reachable but has not been set up yet." +msgstr "Le serveur répond, mais n'est pas encore configuré." + +#: sl/SL_Menu.py:1503 +msgid "This device is not signed in to the server." +msgstr "Cet appareil n'est pas connecté au serveur." + +#: sl/SL_Menu.py:1504 sl/SL_Menu.py:1887 +msgid "This device is no longer signed in. Please sign in again." +msgstr "Cet appareil n'est plus connecté. Veuillez vous reconnecter." + +#: sl/SL_Menu.py:1505 +msgid "The synchronisation files on this computer could not be written." +msgstr "" +"Les fichiers de synchronisation de cet ordinateur n'ont pas pu être écrits." + +#: sl/SL_Menu.py:1507 +#, python-brace-format +msgid "Sign-in failed ({code})." +msgstr "Échec de la connexion ({code})." + +#: sl/SL_Menu.py:1521 msgid "Change Language" msgstr "1. Changer de langue" -#: sl/SL_Menu.py:1377 +#: sl/SL_Menu.py:1539 msgid "Select Language" msgstr "Sélectionner la langue" -#: sl/SL_Menu.py:1378 sl/SL_Menu.py:1418 sl/SL_Menu.py:1459 sl/SL_Menu.py:1472 -#: sl/SL_Menu.py:1492 sl/SL_Menu.py:1526 sl/SL_Menu.py:1549 sl/SL_Menu.py:1604 +#: sl/SL_Menu.py:1540 sl/SL_Menu.py:1580 sl/SL_Menu.py:1621 sl/SL_Menu.py:1634 +#: sl/SL_Menu.py:1654 sl/SL_Menu.py:1688 sl/SL_Menu.py:1711 sl/SL_Menu.py:1766 +#: sl/SL_Menu.py:1804 msgid "Save" msgstr "Enregistrer" -#: sl/SL_Menu.py:1384 +#: sl/SL_Menu.py:1546 msgid "" "Language changed. Please restart the application for the changes to take " "effect." @@ -443,22 +525,22 @@ msgstr "" "Langue changée. Veuillez redémarrer l'application pour que les changements " "prennent effet." -#: sl/SL_Menu.py:1387 +#: sl/SL_Menu.py:1549 msgid "Restore Previous Version" msgstr "Restaurer la version précédente" -#: sl/SL_Menu.py:1390 +#: sl/SL_Menu.py:1552 msgid "The 'update' module is not available. This feature is disabled." msgstr "" "Le module 'mise à jour' n'est pas disponible. Cette fonctionnalité est " "désactivée." -#: sl/SL_Menu.py:1392 +#: sl/SL_Menu.py:1554 #, python-brace-format msgid "No previous version backup '{filename}' found." msgstr "Aucune sauvegarde de la version précédente '{filename}' trouvée." -#: sl/SL_Menu.py:1394 +#: sl/SL_Menu.py:1556 msgid "" "This will restore the application to the previously backed-up version. The " "application will then restart. You may need to manually refresh your browser " @@ -468,35 +550,35 @@ msgstr "" "L'application redémarrera ensuite. Vous devrez peut-être actualiser " "manuellement votre navigateur s'il ne se reconnecte pas automatiquement." -#: sl/SL_Menu.py:1395 +#: sl/SL_Menu.py:1557 msgid "Restore and Restart" msgstr "Restaurer et redémarrer" -#: sl/SL_Menu.py:1396 +#: sl/SL_Menu.py:1558 msgid "Restoring and restarting..." msgstr "Restauration et redémarrage..." -#: sl/SL_Menu.py:1399 +#: sl/SL_Menu.py:1561 msgid "Restore complete. Please restart the application." msgstr "Restauration terminée. Veuillez redémarrer l'application." -#: sl/SL_Menu.py:1401 +#: sl/SL_Menu.py:1563 msgid "Change Data Storage Location" msgstr "Changer l'emplacement de stockage des données" -#: sl/SL_Menu.py:1403 +#: sl/SL_Menu.py:1565 msgid "Current data file" msgstr "Fichier de données actuel" -#: sl/SL_Menu.py:1406 +#: sl/SL_Menu.py:1568 msgid "New Path for data file" msgstr "Nouveau chemin pour le fichier de données" -#: sl/SL_Menu.py:1412 +#: sl/SL_Menu.py:1574 msgid "Move existing data to the new location" msgstr "Déplacer les données existantes vers le nouvel emplacement" -#: sl/SL_Menu.py:1415 +#: sl/SL_Menu.py:1577 msgid "" "If unchecked, the old data file will remain, and a new empty one might be " "created at the new location on restart." @@ -504,11 +586,11 @@ msgstr "" "Si décoché, l'ancien fichier de données restera, et un nouveau fichier vide " "pourrait être créé au nouvel emplacement au redémarrage." -#: sl/SL_Menu.py:1422 +#: sl/SL_Menu.py:1584 msgid "Please enter a new path." msgstr "Veuillez entrer un nouveau chemin." -#: sl/SL_Menu.py:1430 +#: sl/SL_Menu.py:1592 msgid "" "Error: For security, the data file must be located within the application " "directory." @@ -516,12 +598,12 @@ msgstr "" "Erreur : Pour des raisons de sécurité, le fichier de données doit être situé " "dans le répertoire de l'application." -#: sl/SL_Menu.py:1434 +#: sl/SL_Menu.py:1596 #, python-brace-format msgid "Error: The directory '{dir}' does not exist." msgstr "Erreur : Le répertoire '{dir}' n'existe pas." -#: sl/SL_Menu.py:1439 +#: sl/SL_Menu.py:1601 msgid "" "Storage location updated. Please restart the application for the changes to " "take effect." @@ -529,89 +611,89 @@ msgstr "" "Emplacement de stockage mis à jour. Veuillez redémarrer l'application pour " "que les changements prennent effet." -#: sl/SL_Menu.py:1444 +#: sl/SL_Menu.py:1606 msgid "Data moved successfully." msgstr "Données déplacées avec succès." -#: sl/SL_Menu.py:1446 +#: sl/SL_Menu.py:1608 #, python-brace-format msgid "Error moving data: {error}" msgstr "Erreur lors du déplacement des données : {error}" -#: sl/SL_Menu.py:1453 +#: sl/SL_Menu.py:1615 msgid "Report Format" msgstr "Format du rapport" -#: sl/SL_Menu.py:1458 +#: sl/SL_Menu.py:1620 msgid "Select Format" msgstr "Sélectionnez le format" -#: sl/SL_Menu.py:1463 +#: sl/SL_Menu.py:1625 msgid "Report format updated." msgstr "Format de rapport mis à jour." -#: sl/SL_Menu.py:1466 +#: sl/SL_Menu.py:1628 msgid "Streamlit Port Settings" msgstr "Paramètres du port Streamlit" -#: sl/SL_Menu.py:1468 +#: sl/SL_Menu.py:1630 msgid "Current Streamlit Port" msgstr "Port Streamlit actuel" -#: sl/SL_Menu.py:1471 +#: sl/SL_Menu.py:1633 msgid "New Port" msgstr "Nouveau port" -#: sl/SL_Menu.py:1476 +#: sl/SL_Menu.py:1638 #, python-brace-format msgid "Port updated to {port}. Please restart Streamlit." msgstr "Port mis à jour vers {port}. Veuillez redémarrer Streamlit." -#: sl/SL_Menu.py:1479 +#: sl/SL_Menu.py:1641 msgid "Email Settings" msgstr "Paramètres de messagerie" -#: sl/SL_Menu.py:1485 +#: sl/SL_Menu.py:1647 msgid "Enable email import" msgstr "Activer l'importation d'e-mails" -#: sl/SL_Menu.py:1486 +#: sl/SL_Menu.py:1648 msgid "IMAP Server" msgstr "Serveur IMAP" -#: sl/SL_Menu.py:1487 +#: sl/SL_Menu.py:1649 msgid "Port" msgstr "Port" -#: sl/SL_Menu.py:1488 +#: sl/SL_Menu.py:1650 sl/SL_Menu.py:1896 msgid "Username" msgstr "Nom d'utilisateur" -#: sl/SL_Menu.py:1489 +#: sl/SL_Menu.py:1651 sl/SL_Menu.py:1897 msgid "Password" msgstr "Mot de passe" -#: sl/SL_Menu.py:1490 +#: sl/SL_Menu.py:1652 msgid "Use SSL" msgstr "Utiliser SSL" -#: sl/SL_Menu.py:1503 +#: sl/SL_Menu.py:1665 msgid "Email settings saved." msgstr "Paramètres de messagerie enregistrés." -#: sl/SL_Menu.py:1506 +#: sl/SL_Menu.py:1668 msgid "Change CSS Style" msgstr "Changer le style CSS" -#: sl/SL_Menu.py:1508 +#: sl/SL_Menu.py:1670 msgid "Current CSS file" msgstr "Fichier CSS actuel" -#: sl/SL_Menu.py:1525 +#: sl/SL_Menu.py:1687 msgid "Select CSS File" msgstr "Sélectionner le fichier CSS" -#: sl/SL_Menu.py:1531 +#: sl/SL_Menu.py:1693 msgid "" "CSS style updated. Please restart the application for the changes to take " "effect." @@ -619,23 +701,23 @@ msgstr "" "Style CSS mis à jour. Veuillez redémarrer l'application pour que les " "changements prennent effet." -#: sl/SL_Menu.py:1534 +#: sl/SL_Menu.py:1696 msgid "Change View Mode" msgstr "Changer le mode d'affichage" -#: sl/SL_Menu.py:1538 +#: sl/SL_Menu.py:1700 msgid "App Window (Webview)" msgstr "Fenêtre d'application (Webview)" -#: sl/SL_Menu.py:1538 +#: sl/SL_Menu.py:1700 msgid "System Browser" msgstr "Navigateur système" -#: sl/SL_Menu.py:1548 +#: sl/SL_Menu.py:1710 msgid "Select View Mode" msgstr "Sélectionner le mode d'affichage" -#: sl/SL_Menu.py:1555 +#: sl/SL_Menu.py:1717 msgid "" "View mode updated. Please restart the application for the changes to take " "effect." @@ -643,37 +725,37 @@ msgstr "" "Mode d'affichage mis à jour. Veuillez redémarrer l'application pour que les " "changements prennent effet." -#: sl/SL_Menu.py:1558 +#: sl/SL_Menu.py:1720 msgid "MCP Server Settings" msgstr "Paramètres du serveur MCP" -#: sl/SL_Menu.py:1560 +#: sl/SL_Menu.py:1722 msgid "HTTP (Streamable HTTP)" msgstr "HTTP (Streamable HTTP)" -#: sl/SL_Menu.py:1561 +#: sl/SL_Menu.py:1723 msgid "stdio (recommended for Claude Desktop)" msgstr "stdio (recommandé pour Claude Desktop)" -#: sl/SL_Menu.py:1575 +#: sl/SL_Menu.py:1737 msgid "Transport" msgstr "Transport" -#: sl/SL_Menu.py:1586 +#: sl/SL_Menu.py:1748 msgid "Enable MCP server" msgstr "Activer le serveur MCP" -#: sl/SL_Menu.py:1589 +#: sl/SL_Menu.py:1751 msgid "" "Not used with stdio - the MCP client starts and stops the server itself." msgstr "" "Non utilisé avec stdio - le client MCP démarre et arrête lui-même le serveur." -#: sl/SL_Menu.py:1592 +#: sl/SL_Menu.py:1754 msgid "Port (HTTP only)" msgstr "Port (HTTP uniquement)" -#: sl/SL_Menu.py:1599 +#: sl/SL_Menu.py:1761 msgid "" "With stdio, the app does not start the MCP server itself - the MCP client " "(e.g. Claude Desktop) launches it directly, and the port is ignored." @@ -682,7 +764,7 @@ msgstr "" "client MCP (par ex. Claude Desktop) le lance directement, et le port est " "ignoré." -#: sl/SL_Menu.py:1610 +#: sl/SL_Menu.py:1772 msgid "" "MCP server settings saved. Please restart the application for the changes to " "take effect." @@ -690,133 +772,240 @@ msgstr "" "Paramètres du serveur MCP enregistrés. Veuillez redémarrer l'application " "pour que les changements prennent effet." -#: sl/SL_Menu.py:1624 +#: sl/SL_Menu.py:1775 +msgid "Sync Server Settings" +msgstr "Paramètres du serveur de synchronisation" + +#: sl/SL_Menu.py:1777 +msgid "" +"The sync client is unavailable because the 'requests' package is missing." +msgstr "" +"La synchronisation est indisponible car le paquet « requests » est absent." + +#: sl/SL_Menu.py:1788 +msgid "Server address" +msgstr "Adresse du serveur" + +#: sl/SL_Menu.py:1793 +msgid "Enable synchronisation" +msgstr "Activer la synchronisation" + +#: sl/SL_Menu.py:1795 +msgid "Without this, TimeControl works entirely locally, exactly as before." +msgstr "" +"Sans cela, TimeControl fonctionne entièrement en local, exactement comme " +"avant." + +#: sl/SL_Menu.py:1798 +msgid "Sync every (minutes)" +msgstr "Synchroniser toutes les (minutes)" + +#: sl/SL_Menu.py:1802 +msgid "Synchronisation also runs whenever you switch to a different view." +msgstr "La synchronisation s'exécute également à chaque changement de vue." + +#: sl/SL_Menu.py:1816 +msgid "Sync server settings saved." +msgstr "Paramètres du serveur de synchronisation enregistrés." + +#: sl/SL_Menu.py:1842 +#, python-brace-format +msgid "Last synchronised at {time}." +msgstr "Dernière synchronisation le {time}." + +#: sl/SL_Menu.py:1845 +msgid "Not synchronised yet." +msgstr "Pas encore synchronisé." + +#: sl/SL_Menu.py:1847 +#, python-brace-format +msgid "{count} changes are waiting to be sent." +msgstr "{count} modifications sont en attente d'envoi." + +#: sl/SL_Menu.py:1853 +#, python-brace-format +msgid "Signed in as {user}." +msgstr "Connecté en tant que {user}." + +#: sl/SL_Menu.py:1855 +#, python-brace-format +msgid "Access expires on {date}." +msgstr "L'accès expire le {date}." + +#: sl/SL_Menu.py:1859 +msgid "Check connection" +msgstr "Vérifier la connexion" + +#: sl/SL_Menu.py:1861 sl/SL_Menu.py:1879 sl/SL_Menu.py:1899 +msgid "Contacting the server..." +msgstr "Connexion au serveur..." + +#: sl/SL_Menu.py:1869 +msgid "The server answered." +msgstr "Le serveur a répondu." + +#: sl/SL_Menu.py:1878 +msgid "Sign out" +msgstr "Se déconnecter" + +#: sl/SL_Menu.py:1881 +msgid "Signed out on this device." +msgstr "Déconnecté sur cet appareil." + +#: sl/SL_Menu.py:1889 +#, python-brace-format +msgid "The server could not be reached ({reason})." +msgstr "Le serveur est injoignable ({reason})." + +#: sl/SL_Menu.py:1893 +msgid "" +"Signing in stores an access token for this device only. It is kept outside " +"the project directory and is never written to config.json." +msgstr "" +"La connexion enregistre un jeton d'accès pour cet appareil uniquement. Il " +"est conservé en dehors du répertoire du projet et n'est jamais écrit dans " +"config.json." + +#: sl/SL_Menu.py:1898 +msgid "Sign in" +msgstr "Se connecter" + +#: sl/SL_Menu.py:1906 +msgid "Signed in successfully." +msgstr "Connexion réussie." + +#: sl/SL_Menu.py:1912 +#, python-brace-format +msgid "This device: {name} ({uid})" +msgstr "Cet appareil : {name} ({uid})" + +#: sl/SL_Menu.py:1926 msgid "Add New Project" msgstr "Ajouter un nouveau projet" -#: sl/SL_Menu.py:1627 +#: sl/SL_Menu.py:1929 msgid "Name of the project" msgstr "Nom du projet" -#: sl/SL_Menu.py:1631 +#: sl/SL_Menu.py:1933 #, python-brace-format msgid "Project '{name}' added." msgstr "Projet '{name}' ajouté." -#: sl/SL_Menu.py:1635 sl/SL_Menu.py:1681 sl/SL_Menu.py:1726 sl/SL_Menu.py:1772 -#: sl/SL_Menu.py:1827 sl/SL_Menu.py:1921 sl/SL_Menu.py:1969 sl/SL_Menu.py:2019 -#: sl/SL_Menu.py:2099 sl/SL_Menu.py:2129 sl/SL_Menu.py:2159 sl/SL_Menu.py:2190 -#: sl/SL_Menu.py:2252 sl/SL_Menu.py:2292 sl/SL_Menu.py:2404 sl/SL_Menu.py:2423 -#: sl/SL_Menu.py:2584 sl/SL_Menu.py:2632 +#: sl/SL_Menu.py:1937 sl/SL_Menu.py:1983 sl/SL_Menu.py:2028 sl/SL_Menu.py:2074 +#: sl/SL_Menu.py:2129 sl/SL_Menu.py:2223 sl/SL_Menu.py:2271 sl/SL_Menu.py:2321 +#: sl/SL_Menu.py:2401 sl/SL_Menu.py:2431 sl/SL_Menu.py:2461 sl/SL_Menu.py:2492 +#: sl/SL_Menu.py:2554 sl/SL_Menu.py:2594 sl/SL_Menu.py:2706 sl/SL_Menu.py:2725 +#: sl/SL_Menu.py:2890 sl/SL_Menu.py:2938 msgid "Cancel" msgstr "Annuler" -#: sl/SL_Menu.py:1647 sl/SL_Menu.py:1692 sl/SL_Menu.py:1737 sl/SL_Menu.py:1783 -#: sl/SL_Menu.py:1932 sl/SL_Menu.py:2063 sl/SL_Menu.py:2222 sl/SL_Menu.py:2415 +#: sl/SL_Menu.py:1949 sl/SL_Menu.py:1994 sl/SL_Menu.py:2039 sl/SL_Menu.py:2085 +#: sl/SL_Menu.py:2234 sl/SL_Menu.py:2365 sl/SL_Menu.py:2524 sl/SL_Menu.py:2717 msgid "No open projects found." msgstr "Aucun projet ouvert trouvé." -#: sl/SL_Menu.py:1653 sl/SL_Menu.py:1698 sl/SL_Menu.py:1743 sl/SL_Menu.py:1938 -#: sl/SL_Menu.py:2001 sl/SL_Menu.py:2036 sl/SL_Menu.py:2069 sl/SL_Menu.py:2118 -#: sl/SL_Menu.py:2148 sl/SL_Menu.py:2178 sl/SL_Menu.py:2605 sl/SL_Menu.py:2717 -#: sl/SL_Menu.py:2778 +#: sl/SL_Menu.py:1955 sl/SL_Menu.py:2000 sl/SL_Menu.py:2045 sl/SL_Menu.py:2240 +#: sl/SL_Menu.py:2303 sl/SL_Menu.py:2338 sl/SL_Menu.py:2371 sl/SL_Menu.py:2420 +#: sl/SL_Menu.py:2450 sl/SL_Menu.py:2480 sl/SL_Menu.py:2911 sl/SL_Menu.py:3023 +#: sl/SL_Menu.py:3084 msgid "Select Project" msgstr "Sélectionner le projet" -#: sl/SL_Menu.py:1658 +#: sl/SL_Menu.py:1960 #, python-brace-format msgid "No open tasks to close in '{name}'." msgstr "Aucune tâche ouverte à fermer dans '{name}'." -#: sl/SL_Menu.py:1665 sl/SL_Menu.py:1710 sl/SL_Menu.py:1755 sl/SL_Menu.py:1809 -#: sl/SL_Menu.py:1950 sl/SL_Menu.py:2079 sl/SL_Menu.py:2438 sl/SL_Menu.py:2616 -#: sl/SL_Menu.py:2748 +#: sl/SL_Menu.py:1967 sl/SL_Menu.py:2012 sl/SL_Menu.py:2057 sl/SL_Menu.py:2111 +#: sl/SL_Menu.py:2252 sl/SL_Menu.py:2381 sl/SL_Menu.py:2740 sl/SL_Menu.py:2922 +#: sl/SL_Menu.py:3054 msgid "Select Task" msgstr "Sélectionner une tâche" -#: sl/SL_Menu.py:1675 +#: sl/SL_Menu.py:1977 #, python-brace-format msgid "Task '{sub_name}' in '{main_name}' has been closed." msgstr "La tâche '{sub_name}' dans '{main_name}' a été fermée." -#: sl/SL_Menu.py:1679 sl/SL_Menu.py:1724 sl/SL_Menu.py:1770 +#: sl/SL_Menu.py:1981 sl/SL_Menu.py:2026 sl/SL_Menu.py:2072 msgid "Error: Main project or task not found." msgstr "Erreur : projet ou tâche principal introuvable." -#: sl/SL_Menu.py:1703 +#: sl/SL_Menu.py:2005 #, python-brace-format msgid "No closed tasks to reopen in '{name}'." msgstr "Aucune tâche fermée à rouvrir dans '{name}'." -#: sl/SL_Menu.py:1720 +#: sl/SL_Menu.py:2022 #, python-brace-format msgid "Task '{sub_name}' in '{main_name}' has been reopened." msgstr "La tâche '{sub_name}' dans '{main_name}' a été rouverte." -#: sl/SL_Menu.py:1748 +#: sl/SL_Menu.py:2050 #, python-brace-format msgid "No open tasks to delete in '{name}'." msgstr "Aucune tâche ouverte à supprimer dans '{name}'." -#: sl/SL_Menu.py:1759 +#: sl/SL_Menu.py:2061 msgid "This action cannot be undone." msgstr "Cette action ne peut pas être annulée." -#: sl/SL_Menu.py:1766 +#: sl/SL_Menu.py:2068 #, python-brace-format msgid "Task '{sub_name}' deleted from '{main_name}'." msgstr "Tâche '{sub_name}' supprimée de '{main_name}'." -#: sl/SL_Menu.py:1789 +#: sl/SL_Menu.py:2091 msgid "Select Source Project" msgstr "Sélectionnez le projet source" -#: sl/SL_Menu.py:1794 +#: sl/SL_Menu.py:2096 #, python-brace-format msgid "No tasks found in '{name}'." msgstr "Aucune tâche trouvée dans '{name}'." -#: sl/SL_Menu.py:1802 +#: sl/SL_Menu.py:2104 msgid "No other projects available to move to." msgstr "Aucun autre projet disponible vers lequel migrer." -#: sl/SL_Menu.py:1813 sl/SL_Menu.py:2238 +#: sl/SL_Menu.py:2115 sl/SL_Menu.py:2540 msgid "Select Target Project" msgstr "Sélectionnez le projet cible" -#: sl/SL_Menu.py:1821 +#: sl/SL_Menu.py:2123 #, python-brace-format msgid "Task '{sub}' moved from '{src}' to '{dst}'." msgstr "La tâche «{sub}» a été déplacée de «{src}» à «{dst}»." -#: sl/SL_Menu.py:1825 +#: sl/SL_Menu.py:2127 msgid "Error: Could not move task." msgstr "Erreur : impossible de déplacer la tâche." -#: sl/SL_Menu.py:1836 sl/SL_Menu.py:2199 +#: sl/SL_Menu.py:2138 sl/SL_Menu.py:2501 msgid "Weeks of inactivity" msgstr "Semaines d'inactivité" -#: sl/SL_Menu.py:1841 +#: sl/SL_Menu.py:2143 #, python-brace-format msgid "Inactive Tasks (> {weeks} weeks):" msgstr "Tâches inactives (> {weeks} semaines) :" -#: sl/SL_Menu.py:1846 sl/SL_Menu.py:2207 +#: sl/SL_Menu.py:2148 sl/SL_Menu.py:2509 msgid "Last Activity" msgstr "Dernière activité" -#: sl/SL_Menu.py:1852 +#: sl/SL_Menu.py:2154 #, python-brace-format msgid "No tasks found inactive for more than {weeks} weeks." msgstr "" "Aucune tâche n'a été trouvée inactive pendant plus de {weeks} semaines." -#: sl/SL_Menu.py:1877 sl/SL_Menu.py:1900 +#: sl/SL_Menu.py:2179 sl/SL_Menu.py:2202 msgid "No closed tasks found." msgstr "Aucune tâche fermée trouvée." -#: sl/SL_Menu.py:1905 +#: sl/SL_Menu.py:2207 #, python-brace-format msgid "" "Are you sure you want to delete {count} closed tasks? This action cannot be " @@ -825,25 +1014,25 @@ msgstr "" "Êtes-vous sûr de vouloir supprimer{count}tâches fermées ? Cette action ne " "peut pas être annulée." -#: sl/SL_Menu.py:1907 +#: sl/SL_Menu.py:2209 msgid "Show projects to delete" msgstr "Afficher les projets à supprimer" -#: sl/SL_Menu.py:1911 +#: sl/SL_Menu.py:2213 msgid "Delete All" msgstr "Supprimer tout" -#: sl/SL_Menu.py:1917 +#: sl/SL_Menu.py:2219 #, python-brace-format msgid "Successfully deleted {count} tasks." msgstr "Tâches {count} supprimées avec succès." -#: sl/SL_Menu.py:1943 +#: sl/SL_Menu.py:2245 #, python-brace-format msgid "No open tasks to promote in '{name}'." msgstr "Aucune tâche ouverte à promouvoir dans '{name}'." -#: sl/SL_Menu.py:1954 +#: sl/SL_Menu.py:2256 msgid "" "This will create a new Project with the task's name and move all time " "entries to a 'General' task within it." @@ -851,98 +1040,98 @@ msgstr "" "Cela créera un nouveau projet avec le nom de la tâche et déplacera toutes " "les entrées de temps vers une tâche « Générale » à l'intérieur de celui-ci." -#: sl/SL_Menu.py:1956 +#: sl/SL_Menu.py:2258 msgid "Promote to Project" msgstr "Promouvoir vers le projet" -#: sl/SL_Menu.py:1980 sl/SL_Menu.py:2044 +#: sl/SL_Menu.py:2282 sl/SL_Menu.py:2346 msgid "closed" msgstr "fermé" -#: sl/SL_Menu.py:1983 sl/SL_Menu.py:2030 sl/SL_Menu.py:2170 sl/SL_Menu.py:2711 -#: sl/SL_Menu.py:2770 +#: sl/SL_Menu.py:2285 sl/SL_Menu.py:2332 sl/SL_Menu.py:2472 sl/SL_Menu.py:3017 +#: sl/SL_Menu.py:3076 msgid "No projects found." msgstr "Aucun projet trouvé." -#: sl/SL_Menu.py:1995 +#: sl/SL_Menu.py:2297 msgid "No open projects to rename." msgstr "Aucun projet ouvert à renommer." -#: sl/SL_Menu.py:2004 sl/SL_Menu.py:2084 +#: sl/SL_Menu.py:2306 sl/SL_Menu.py:2386 msgid "New Name" msgstr "Nouveau nom" -#: sl/SL_Menu.py:2005 sl/SL_Menu.py:2085 +#: sl/SL_Menu.py:2307 sl/SL_Menu.py:2387 msgid "Rename" msgstr "Renommer" -#: sl/SL_Menu.py:2009 sl/SL_Menu.py:2089 +#: sl/SL_Menu.py:2311 sl/SL_Menu.py:2391 msgid "Please enter a new name." msgstr "Veuillez saisir un nouveau nom." -#: sl/SL_Menu.py:2011 sl/SL_Menu.py:2091 +#: sl/SL_Menu.py:2313 sl/SL_Menu.py:2393 msgid "New name is the same as the old name." msgstr "Le nouveau nom est le même que l'ancien nom." -#: sl/SL_Menu.py:2013 +#: sl/SL_Menu.py:2315 #, python-brace-format msgid "Project '{old_name}' successfully renamed to '{new_name}'." msgstr "Le projet '{old_name}' a été renommé en '{new_name}' avec succès." -#: sl/SL_Menu.py:2017 +#: sl/SL_Menu.py:2319 #, python-brace-format msgid "Error: Could not rename. The new name '{new_name}' might already exist." msgstr "" "Erreur : Impossible de renommer. Le nouveau nom '{new_name}' existe peut-" "être déjà." -#: sl/SL_Menu.py:2041 +#: sl/SL_Menu.py:2343 #, python-brace-format msgid "Tasks for '{name}':" msgstr "Tâches pour '{name}' :" -#: sl/SL_Menu.py:2050 sl/SL_Menu.py:2742 +#: sl/SL_Menu.py:2352 sl/SL_Menu.py:3048 #, python-brace-format msgid "No tasks found for '{name}'." msgstr "Aucune tâche trouvée pour '{name}'." -#: sl/SL_Menu.py:2074 +#: sl/SL_Menu.py:2376 #, python-brace-format msgid "No open tasks to rename in '{name}'." msgstr "Aucune tâche ouverte à renommer dans '{name}'." -#: sl/SL_Menu.py:2093 +#: sl/SL_Menu.py:2395 #, python-brace-format msgid "Task '{old_name}' renamed to '{new_name}'." msgstr "Tâche '{old_name}' renommée en '{new_name}'." -#: sl/SL_Menu.py:2097 +#: sl/SL_Menu.py:2399 msgid "Error: Could not rename. The new name might already exist." msgstr "Erreur : impossible de renommer. Le nouveau nom existe peut-être déjà." -#: sl/SL_Menu.py:2110 +#: sl/SL_Menu.py:2412 msgid "No open projects to close." msgstr "Aucun projet ouvert à fermer." -#: sl/SL_Menu.py:2123 +#: sl/SL_Menu.py:2425 #, python-brace-format msgid "Project '{name}' has been closed." msgstr "Le projet '{name}' a été clôturé." -#: sl/SL_Menu.py:2127 sl/SL_Menu.py:2157 sl/SL_Menu.py:2188 +#: sl/SL_Menu.py:2429 sl/SL_Menu.py:2459 sl/SL_Menu.py:2490 msgid "Error: Project not found." msgstr "Erreur : projet introuvable." -#: sl/SL_Menu.py:2140 +#: sl/SL_Menu.py:2442 msgid "No closed projects to reopen." msgstr "Aucun projet fermé à rouvrir." -#: sl/SL_Menu.py:2153 +#: sl/SL_Menu.py:2455 #, python-brace-format msgid "Project '{name}' has been reopened." msgstr "Le projet '{name}' a été rouvert." -#: sl/SL_Menu.py:2179 +#: sl/SL_Menu.py:2481 msgid "" "This action cannot be undone. All associated tasks and time entries will be " "deleted." @@ -950,244 +1139,253 @@ msgstr "" "Cette action ne peut pas être annulée. Toutes les tâches et entrées de temps " "associées seront supprimées." -#: sl/SL_Menu.py:2184 +#: sl/SL_Menu.py:2486 #, python-brace-format msgid "Project '{name}' has been deleted." msgstr "Le projet '{name}' a été supprimé." -#: sl/SL_Menu.py:2204 +#: sl/SL_Menu.py:2506 #, python-brace-format msgid "Inactive Projects (> {weeks} weeks):" msgstr "Projets inactifs (> {weeks} semaines) :" -#: sl/SL_Menu.py:2209 +#: sl/SL_Menu.py:2511 #, python-brace-format msgid "No projects found inactive for more than {weeks} weeks." msgstr "Aucun projet trouvé inactif pendant plus de {weeks} semaines." -#: sl/SL_Menu.py:2218 sl/SL_Menu.py:2241 +#: sl/SL_Menu.py:2520 sl/SL_Menu.py:2543 msgid "Demote Project" msgstr "Rétrograder le projet" -#: sl/SL_Menu.py:2230 +#: sl/SL_Menu.py:2532 msgid "Select Project to Demote" msgstr "Sélectionner le projet à rétrograder" -#: sl/SL_Menu.py:2236 +#: sl/SL_Menu.py:2538 msgid "No other projects available to demote into." msgstr "Aucun autre projet disponible pour rétrograder." -#: sl/SL_Menu.py:2239 +#: sl/SL_Menu.py:2541 #, python-brace-format msgid "This will convert '{src}' into a task of '{dst}'." msgstr "Cela convertira '{src}' en une tâche de '{dst}'." -#: sl/SL_Menu.py:2264 +#: sl/SL_Menu.py:2566 msgid "Projects with only closed or no tasks:" msgstr "Projets avec uniquement des tâches fermées ou aucune tâche :" -#: sl/SL_Menu.py:2268 +#: sl/SL_Menu.py:2570 msgid "No completed projects found." msgstr "Aucun projet terminé trouvé." -#: sl/SL_Menu.py:2277 sl/SL_Menu.py:2411 sl/SL_Menu.py:2707 +#: sl/SL_Menu.py:2579 sl/SL_Menu.py:2713 sl/SL_Menu.py:3013 msgid "Step 1: Select Project" msgstr "Étape 1 : Sélectionner le projet" -#: sl/SL_Menu.py:2281 sl/SL_Menu.py:2599 +#: sl/SL_Menu.py:2583 sl/SL_Menu.py:2905 msgid "No open projects found. Please add one first." msgstr "Aucun projet ouvert trouvé. Veuillez d'abord en ajouter un." -#: sl/SL_Menu.py:2286 sl/SL_Menu.py:2418 sl/SL_Menu.py:2647 +#: sl/SL_Menu.py:2588 sl/SL_Menu.py:2720 sl/SL_Menu.py:2953 msgid "Project" msgstr "Projet" -#: sl/SL_Menu.py:2288 sl/SL_Menu.py:2419 sl/SL_Menu.py:2443 sl/SL_Menu.py:2719 +#: sl/SL_Menu.py:2590 sl/SL_Menu.py:2721 sl/SL_Menu.py:2745 sl/SL_Menu.py:3025 msgid "Next" msgstr "Suivant" -#: sl/SL_Menu.py:2303 sl/SL_Menu.py:2733 +#: sl/SL_Menu.py:2605 sl/SL_Menu.py:3039 msgid "No project selected. Please start again." msgstr "Aucun projet sélectionné. S'il vous plaît, recommencez." -#: sl/SL_Menu.py:2308 +#: sl/SL_Menu.py:2610 msgid "To Project:" msgstr "Au projet :" -#: sl/SL_Menu.py:2329 +#: sl/SL_Menu.py:2631 msgid "Name of the new task" msgstr "Nom de la nouvelle tâche" -#: sl/SL_Menu.py:2335 +#: sl/SL_Menu.py:2637 msgid "Due date" msgstr "Date d'échéance" -#: sl/SL_Menu.py:2341 sl/SL_Menu.py:2513 +#: sl/SL_Menu.py:2643 sl/SL_Menu.py:2815 msgid "Recurring" msgstr "Récurrent" -#: sl/SL_Menu.py:2357 sl/SL_Menu.py:2528 +#: sl/SL_Menu.py:2659 sl/SL_Menu.py:2830 msgid "daily" msgstr "quotidien" -#: sl/SL_Menu.py:2357 sl/SL_Menu.py:2528 +#: sl/SL_Menu.py:2659 sl/SL_Menu.py:2830 msgid "monthly" msgstr "mensuel" -#: sl/SL_Menu.py:2357 sl/SL_Menu.py:2528 +#: sl/SL_Menu.py:2659 sl/SL_Menu.py:2830 msgid "on all business days" msgstr "tous les jours ouvrables" -#: sl/SL_Menu.py:2357 sl/SL_Menu.py:2528 +#: sl/SL_Menu.py:2659 sl/SL_Menu.py:2830 msgid "userdefined" msgstr "# Rapport de temps journalier : {date}" -#: sl/SL_Menu.py:2357 sl/SL_Menu.py:2528 +#: sl/SL_Menu.py:2659 sl/SL_Menu.py:2830 msgid "weekly" msgstr "hebdomadaire" -#: sl/SL_Menu.py:2361 sl/SL_Menu.py:2537 +#: sl/SL_Menu.py:2663 sl/SL_Menu.py:2839 msgid "Frequency" msgstr "Fréquence" -#: sl/SL_Menu.py:2365 sl/SL_Menu.py:2540 +#: sl/SL_Menu.py:2667 sl/SL_Menu.py:2842 msgid "Days" msgstr "Aucun temps suivi pour le {date}." -#: sl/SL_Menu.py:2369 sl/SL_Menu.py:2542 +#: sl/SL_Menu.py:2671 sl/SL_Menu.py:2844 msgid "Edit" msgstr "Modifier" -#: sl/SL_Menu.py:2369 sl/SL_Menu.py:2542 +#: sl/SL_Menu.py:2671 sl/SL_Menu.py:2844 msgid "Preview" msgstr "Aperçu" -#: sl/SL_Menu.py:2374 sl/SL_Menu.py:2547 +#: sl/SL_Menu.py:2676 sl/SL_Menu.py:2849 msgid "No notes provided." msgstr "Aucune note fournie." -#: sl/SL_Menu.py:2379 sl/SL_Menu.py:2552 +#: sl/SL_Menu.py:2681 sl/SL_Menu.py:2854 msgid "A due date is required for recurring tasks." msgstr "Une date d'échéance est requise pour les tâches récurrentes." -#: sl/SL_Menu.py:2387 +#: sl/SL_Menu.py:2689 msgid "Please enter a name." msgstr "Veuillez entrer un nom." -#: sl/SL_Menu.py:2399 +#: sl/SL_Menu.py:2701 #, python-brace-format msgid "Task '{sub_name}' added to '{main_name}'." msgstr "Tâche '{sub_name}' ajoutée à '{main_name}'." -#: sl/SL_Menu.py:2431 sl/SL_Menu.py:2738 +#: sl/SL_Menu.py:2733 sl/SL_Menu.py:3044 msgid "Step 2: Select Task from" msgstr "Étape 2 : Sélectionner la tâche de" -#: sl/SL_Menu.py:2434 +#: sl/SL_Menu.py:2736 msgid "No open tasks found." msgstr "Aucune tâche ouverte trouvée." -#: sl/SL_Menu.py:2462 +#: sl/SL_Menu.py:2764 msgid "Task not found." msgstr "Tâche non trouvée." -#: sl/SL_Menu.py:2556 +#: sl/SL_Menu.py:2858 msgid "Save Changes" msgstr "Enregistrer les modifications" -#: sl/SL_Menu.py:2575 +#: sl/SL_Menu.py:2881 msgid "Task updated successfully." msgstr "Tâche mise à jour avec succès." -#: sl/SL_Menu.py:2581 +#: sl/SL_Menu.py:2887 msgid "Error: Could not update task." msgstr "Erreur : Impossible de mettre à jour la tâche." -#: sl/SL_Menu.py:2594 +#: sl/SL_Menu.py:2900 msgid "Start Work on Task" msgstr "Démarrer le travail sur la tâche" -#: sl/SL_Menu.py:2609 +#: sl/SL_Menu.py:2915 #, python-brace-format msgid "No open tasks to start work on in '{name}'." msgstr "" "Aucune tâche ouverte sur laquelle commencer à travailler dans '{name}'." -#: sl/SL_Menu.py:2620 +#: sl/SL_Menu.py:2926 msgid "Start Work" msgstr "Démarrer le travail" -#: sl/SL_Menu.py:2626 +#: sl/SL_Menu.py:2932 #, python-brace-format msgid "Work started on '{task_name}' in project '{main_name}'." msgstr "Travail commencé sur '{task_name}' dans le projet '{main_name}'." -#: sl/SL_Menu.py:2630 +#: sl/SL_Menu.py:2936 msgid "Error starting work." msgstr "Erreur lors du démarrage du travail." -#: sl/SL_Menu.py:2648 +#: sl/SL_Menu.py:2954 msgid "Task" msgstr "Tâche" -#: sl/SL_Menu.py:2649 +#: sl/SL_Menu.py:2955 msgid "Started at" msgstr "Démarré à" -#: sl/SL_Menu.py:2650 tt/TimeTracker.py:1454 +#: sl/SL_Menu.py:2956 tt/TimeTracker.py:1912 msgid "Duration" msgstr "Durée" -#: sl/SL_Menu.py:2664 sl/SL_Menu.py:2796 +#: sl/SL_Menu.py:2970 sl/SL_Menu.py:3102 msgid "Select Date" msgstr "Sélectionner la date" -#: sl/SL_Menu.py:2665 sl/SL_Menu.py:2689 sl/SL_Menu.py:2753 sl/SL_Menu.py:2779 -#: sl/SL_Menu.py:2797 +#: sl/SL_Menu.py:2971 sl/SL_Menu.py:2995 sl/SL_Menu.py:3059 sl/SL_Menu.py:3085 +#: sl/SL_Menu.py:3103 msgid "Generate Report" msgstr "Générer le rapport" -#: sl/SL_Menu.py:2685 +#: sl/SL_Menu.py:2991 msgid "Start Date" msgstr "Date de début" -#: sl/SL_Menu.py:2687 +#: sl/SL_Menu.py:2993 msgid "End Date" msgstr "Date de fin" -#: sl/SL_Menu.py:2693 +#: sl/SL_Menu.py:2999 msgid "Error: The start date cannot be after the end date." msgstr "" "Erreur : La date de début ne peut pas être postérieure à la date de fin." -#: sl/SL_Menu.py:2812 +#: sl/SL_Menu.py:3118 msgid "Report Result" msgstr "Résultat du rapport" -#: sl/SL_Menu.py:2841 +#: sl/SL_Menu.py:3147 msgid "Export Report" msgstr "Rapport d'exportation" -#: tt/TimeTracker.py:91 +#: tt/TimeTracker.py:191 #, python-brace-format msgid "Warning: Could not read {file}. Error: {error}" msgstr "Avertissement : Impossible de lire {file}. Erreur : {error}" -#: tt/TimeTracker.py:107 +#: tt/TimeTracker.py:207 msgid "Some required packages are missing. Attempting to install them..." msgstr "Certains paquets requis sont manquants. Tentative d'installation..." -#: tt/TimeTracker.py:110 +#: tt/TimeTracker.py:210 #, python-brace-format msgid "Installing {package}..." msgstr "Installation de {package}..." -#: tt/TimeTracker.py:114 +#: tt/TimeTracker.py:217 #, python-brace-format msgid "Failed to install {package}. Continuing without it." msgstr "Échec de l'installation de {package}. Continuer sans cela." -#: tt/TimeTracker.py:118 +#: tt/TimeTracker.py:220 +#, python-brace-format +msgid "" +"Timed out installing {package} (no internet connection?). Continuing without " +"it." +msgstr "" +"Délai dépassé lors de l'installation de {package} (pas de connexion " +"Internet ?). Poursuite sans ce paquet." + +#: tt/TimeTracker.py:224 msgid "" "\n" "Dependencies installed successfully." @@ -1195,12 +1393,12 @@ msgstr "" "\n" "Dépendances installées avec succès." -#: tt/TimeTracker.py:119 +#: tt/TimeTracker.py:225 msgid "Please restart the application for the changes to take effect." msgstr "" "Veuillez redémarrer l'application pour que les changements prennent effet." -#: tt/TimeTracker.py:122 +#: tt/TimeTracker.py:228 #, python-brace-format msgid "" "\n" @@ -1209,24 +1407,24 @@ msgstr "" "\n" "Attention : Certaines dépendances n'ont pas pu être installées :{packages}" -#: tt/TimeTracker.py:124 +#: tt/TimeTracker.py:230 #, python-brace-format msgid "An unexpected error occurred during dependency check: {error}" msgstr "" "Une erreur inattendue s'est produite lors de la vérification des " "dépendances : {error}" -#: tt/TimeTracker.py:251 +#: tt/TimeTracker.py:452 msgid "Info: Report content has been copied to the clipboard." msgstr "Info : Le contenu du rapport a été copié dans le presse-papiers." -#: tt/TimeTracker.py:253 +#: tt/TimeTracker.py:454 #, python-brace-format msgid "Warning: Could not copy to clipboard. Error: {error}" msgstr "" "Avertissement : Impossible de copier dans le presse-papiers. Erreur : {error}" -#: tt/TimeTracker.py:255 +#: tt/TimeTracker.py:456 msgid "" "Warning: Could not copy to clipboard. Please install 'pyperclip' (`pip " "install pyperclip`)." @@ -1234,56 +1432,56 @@ msgstr "" "Avertissement : Impossible de copier dans le presse-papiers. Veuillez " "installer 'pyperclip' (`pip install pyperclip`)." -#: tt/TimeTracker.py:275 +#: tt/TimeTracker.py:476 #, python-brace-format msgid "{hours} hours ({dlp} DLP)" msgstr "{hours} heures ({dlp} DLP)" -#: tt/TimeTracker.py:877 tt/TimeTracker.py:917 +#: tt/TimeTracker.py:1218 tt/TimeTracker.py:1263 #, python-brace-format msgid "Source main project '{name}' not found." msgstr "Source du projet principal '{name}' introuvable." -#: tt/TimeTracker.py:879 +#: tt/TimeTracker.py:1220 #, python-brace-format msgid "Destination main project '{name}' not found." msgstr "Projet principal de destination «{name}» introuvable." -#: tt/TimeTracker.py:891 +#: tt/TimeTracker.py:1237 #, python-brace-format msgid "Task '{task_name}' moved successfully." msgstr "La tâche '{task_name}' a été déplacée avec succès." -#: tt/TimeTracker.py:892 tt/TimeTracker.py:927 tt/TimeTracker.py:1416 +#: tt/TimeTracker.py:1238 tt/TimeTracker.py:1273 tt/TimeTracker.py:1874 #, python-brace-format msgid "Task '{task_name}' not found in '{main_name}'." msgstr "La tâche '{task_name}' n'a pas été trouvée dans '{main_name}'." -#: tt/TimeTracker.py:911 +#: tt/TimeTracker.py:1257 #, python-brace-format msgid "A main project named '{name}' already exists." msgstr "Un projet principal nommé «{name}» existe déjà." -#: tt/TimeTracker.py:936 +#: tt/TimeTracker.py:1305 msgid "General" msgstr "Général" -#: tt/TimeTracker.py:940 +#: tt/TimeTracker.py:1343 #, python-brace-format msgid "Task '{task_name}' was promoted to a new main project." msgstr "La tâche '{task_name}' a été promue en nouveau projet principal." -#: tt/TimeTracker.py:967 +#: tt/TimeTracker.py:1370 #, python-brace-format msgid "Main project to demote '{name}' not found." msgstr "Projet principal à rétrograder '{name}' introuvable." -#: tt/TimeTracker.py:969 +#: tt/TimeTracker.py:1372 #, python-brace-format msgid "New parent main project '{name}' not found." msgstr "Nouveau projet principal parent '{name}' introuvable." -#: tt/TimeTracker.py:994 +#: tt/TimeTracker.py:1427 #, python-brace-format msgid "" "Main project '{demoted_name}' was demoted to a sub-project under " @@ -1292,42 +1490,42 @@ msgstr "" "Le projet principal «{demoted_name}» a été rétrogradé au rang de sous-projet " "sous «{parent_name}»." -#: tt/TimeTracker.py:1076 +#: tt/TimeTracker.py:1521 msgid "Email import is not enabled." msgstr "L'importation d'e-mails n'est pas activée." -#: tt/TimeTracker.py:1085 +#: tt/TimeTracker.py:1530 msgid "Email settings are incomplete." msgstr "Les paramètres de messagerie sont incomplets." -#: tt/TimeTracker.py:1098 +#: tt/TimeTracker.py:1543 msgid "Error searching emails." msgstr "Erreur lors de la recherche d'e-mails." -#: tt/TimeTracker.py:1113 +#: tt/TimeTracker.py:1558 msgid "No Subject" msgstr "Aucun sujet" -#: tt/TimeTracker.py:1173 +#: tt/TimeTracker.py:1631 msgid "Unknown Task" msgstr "Tâche inconnue" -#: tt/TimeTracker.py:1373 +#: tt/TimeTracker.py:1831 #, python-brace-format msgid "- {name}: {hours} hours" msgstr "- {name}: {hours} heures" -#: tt/TimeTracker.py:1381 +#: tt/TimeTracker.py:1839 #, python-brace-format msgid "## {name} ({hours} hours)\n" msgstr "## {name} ({hours} heures)\n" -#: tt/TimeTracker.py:1390 +#: tt/TimeTracker.py:1848 #, python-brace-format msgid "# Daily Time Report: {date}\n" msgstr "# Rapport de temps journalier : {date}\n" -#: tt/TimeTracker.py:1391 +#: tt/TimeTracker.py:1849 #, python-brace-format msgid "" "\n" @@ -1336,104 +1534,104 @@ msgstr "" "\n" "**Durée quotidienne totale :{hours}heures**" -#: tt/TimeTracker.py:1395 tt/TimeTracker.py:1708 +#: tt/TimeTracker.py:1853 tt/TimeTracker.py:2166 #, python-brace-format msgid "No time tracked for {date}." msgstr "Aucun temps suivi pour le {date}." -#: tt/TimeTracker.py:1412 tt/TimeTracker.py:1507 +#: tt/TimeTracker.py:1870 tt/TimeTracker.py:1965 #, python-brace-format msgid "Main project '{name}' not found." msgstr "Projet principal «{name}» introuvable." -#: tt/TimeTracker.py:1420 +#: tt/TimeTracker.py:1878 #, python-brace-format msgid "No time entries found for task '{task_name}'." msgstr "Aucune entrée de temps trouvée pour la tâche '{task_name}'." -#: tt/TimeTracker.py:1453 tt/TimeTracker.py:1683 +#: tt/TimeTracker.py:1911 tt/TimeTracker.py:2141 msgid "now" msgstr "maintenant" -#: tt/TimeTracker.py:1458 +#: tt/TimeTracker.py:1916 #, python-brace-format msgid "# Detailed Report for Task: {name}" msgstr "# Rapport détaillé pour la tâche : {name}" -#: tt/TimeTracker.py:1459 +#: tt/TimeTracker.py:1917 #, python-brace-format msgid "Part of Main Project: {name}" msgstr "Fait partie du projet principal : {name}" -#: tt/TimeTracker.py:1462 +#: tt/TimeTracker.py:1920 msgid "Active (currently running)" msgstr "Actif (en cours)" -#: tt/TimeTracker.py:1462 tt/TimeTracker.py:1559 +#: tt/TimeTracker.py:1920 tt/TimeTracker.py:2017 msgid "Inactive" msgstr "Inactif" -#: tt/TimeTracker.py:1463 tt/TimeTracker.py:1560 +#: tt/TimeTracker.py:1921 tt/TimeTracker.py:2018 msgid "Status" msgstr "Statut" -#: tt/TimeTracker.py:1465 tt/TimeTracker.py:1562 +#: tt/TimeTracker.py:1923 tt/TimeTracker.py:2020 msgid "First entry" msgstr "Première entrée" -#: tt/TimeTracker.py:1467 tt/TimeTracker.py:1564 +#: tt/TimeTracker.py:1925 tt/TimeTracker.py:2022 msgid "Last activity" msgstr "Dernière activité" -#: tt/TimeTracker.py:1469 tt/TimeTracker.py:1566 +#: tt/TimeTracker.py:1927 tt/TimeTracker.py:2024 msgid "Total recorded time" msgstr "Temps total enregistré" -#: tt/TimeTracker.py:1470 tt/TimeTracker.py:1568 +#: tt/TimeTracker.py:1928 tt/TimeTracker.py:2026 msgid "Total work sessions" msgstr "Nombre total de sessions de travail" -#: tt/TimeTracker.py:1474 tt/TimeTracker.py:1572 +#: tt/TimeTracker.py:1932 tt/TimeTracker.py:2030 msgid "Average session duration" msgstr "Durée moyenne des sessions" -#: tt/TimeTracker.py:1477 tt/TimeTracker.py:1575 +#: tt/TimeTracker.py:1935 tt/TimeTracker.py:2033 msgid "Weekday Distribution" msgstr "Répartition par jour de la semaine" -#: tt/TimeTracker.py:1487 +#: tt/TimeTracker.py:1945 msgid "Daily Breakdown" msgstr "Répartition journalière" -#: tt/TimeTracker.py:1556 +#: tt/TimeTracker.py:2014 #, python-brace-format msgid "# Detailed Report for Main Project: {name}" msgstr "# Rapport détaillé pour le projet principal : {name}" -#: tt/TimeTracker.py:1559 +#: tt/TimeTracker.py:2017 #, python-brace-format msgid "Active (working on '{task_name}')" msgstr "Actif (travail sur '{task_name}')" -#: tt/TimeTracker.py:1567 +#: tt/TimeTracker.py:2025 msgid "Number of tasks" msgstr "Nombre de tâches" -#: tt/TimeTracker.py:1586 +#: tt/TimeTracker.py:2044 msgid "Task Breakdown" msgstr "Répartition par tâche" -#: tt/TimeTracker.py:1595 +#: tt/TimeTracker.py:2053 #, python-brace-format msgid "{num_sessions} sessions" msgstr "{num_sessions} sessions" -#: tt/TimeTracker.py:1648 +#: tt/TimeTracker.py:2106 #, python-brace-format msgid "# Time Report: {start_date} to {end_date}\n" msgstr "# Rapport de temps : du {start_date} au {end_date}\n" -#: tt/TimeTracker.py:1649 +#: tt/TimeTracker.py:2107 #, python-brace-format msgid "" "\n" @@ -1442,17 +1640,17 @@ msgstr "" "\n" "**Temps total sur la période : {total_time}**" -#: tt/TimeTracker.py:1653 +#: tt/TimeTracker.py:2111 #, python-brace-format msgid "No time tracked between {start_date} and {end_date}." msgstr "Aucun temps suivi entre le {start_date} et le {end_date}." -#: tt/TimeTracker.py:1669 +#: tt/TimeTracker.py:2127 #, python-brace-format msgid "# Detailed Daily Report: {date}" msgstr "# Rapport journalier détaillé : {date}" -#: update.py:35 +#: update.py:101 msgid "" "Warning: Update check skipped. 'github_repo' not found in config.json or " "file is invalid." @@ -1460,16 +1658,22 @@ msgstr "" "Avertissement : Vérification de mise à jour ignorée. 'github_repo' non " "trouvé dans config.json ou fichier invalide." -#: update.py:55 +#: update.py:121 msgid "Error: Download URL for the new version not found." msgstr "Erreur : URL de téléchargement pour la nouvelle version non trouvée." -#: update.py:59 +#: update.py:125 +msgid "Warning: Update check timed out (no internet connection?). Skipping." +msgstr "" +"Avertissement : délai dépassé lors de la vérification des mises à jour (pas " +"de connexion internet ?). Ignorée." + +#: update.py:127 #, python-brace-format msgid "Error checking for updates: {error}" msgstr "Erreur lors de la recherche de mises à jour : {error}" -#: update.py:61 +#: update.py:129 #, python-brace-format msgid "An unexpected error occurred while checking for updates: {error}" msgstr "" @@ -1477,69 +1681,76 @@ msgstr "" "{error}Une erreur inattendue s'est produite lors de la recherche de mises à " "jour : {error}" -#: update.py:73 +#: update.py:141 msgid "Downloading update..." msgstr "Téléchargement de la mise à jour..." -#: update.py:79 +#: update.py:147 msgid "Download complete. The update will be installed on the next start." msgstr "" "Téléchargement terminé. La mise à jour sera installée au prochain démarrage." -#: update.py:82 +#: update.py:150 +msgid "" +"Error: Connecting to the update server timed out (no internet connection?)." +msgstr "" +"Erreur : délai dépassé lors de la connexion au serveur de mise à jour (pas " +"de connexion internet ?)." + +#: update.py:155 #, python-brace-format msgid "Error downloading the update: {error}" msgstr "Erreur lors du téléchargement de la mise à jour : {error}" -#: update.py:98 +#: update.py:171 msgid "Restarting application to apply the update..." msgstr "Redémarrage de l'application pour appliquer la mise à jour..." -#: update.py:122 +#: update.py:195 msgid "Creating backup of current version before update..." msgstr "" "Création d'une sauvegarde de la version actuelle avant la mise à jour..." -#: update.py:132 +#: update.py:205 #, python-brace-format msgid "Backup created successfully as {filename}." msgstr "Sauvegarde créée avec succès sous le nom {filename}." -#: update.py:134 +#: update.py:207 #, python-brace-format msgid "Warning: Could not create backup. Error: {error}" msgstr "Avertissement : Impossible de créer la sauvegarde. Erreur : {error}" -#: update.py:136 +#: update.py:209 msgid "Installing update..." msgstr "Installation de la mise à jour..." -#: update.py:156 +#: update.py:229 #, python-brace-format msgid "Skipping protected file: {filename}. It will not be overwritten." msgstr "Fichier protégé ignoré : {filename}. Il ne sera pas écrasé." -#: update.py:165 +#: update.py:238 msgid "Update installed successfully." msgstr "Mise à jour installée avec succès." -#: update.py:167 +#: update.py:240 #, python-brace-format msgid "Error during update installation: {error}" msgstr "Erreur lors de l'installation de la mise à jour : {error}" -#: update.py:182 +#: update.py:255 #, python-brace-format msgid "Error: No previous version backup '{filename}' found." msgstr "" "Erreur : Aucune sauvegarde de la version précédente '{filename}' trouvée." -#: update.py:185 +#: update.py:258 #, python-brace-format msgid "Restoring previous version from '{filename}'..." msgstr "Restauration de la version précédente depuis '{filename}'..." -#: update.py:206 +#: update.py:279 #, python-brace-format msgid "" "Skipping user data file: {filename}. It will not be overwritten during " @@ -1548,34 +1759,34 @@ msgstr "" "Ignorer le fichier de données utilisateur : {filename}. Il ne sera pas " "écrasé lors de la restauration." -#: update.py:213 +#: update.py:286 msgid "Previous version restored successfully." msgstr "Version précédente restaurée avec succès." -#: update.py:215 +#: update.py:288 msgid "Restarting application to apply changes..." msgstr "Redémarrage de l'application pour appliquer les modifications..." -#: update.py:218 +#: update.py:291 #, python-brace-format msgid "Error during restoration: {error}" msgstr "Erreur lors de la restauration : {error}" -#: update.py:219 +#: update.py:292 #, python-brace-format msgid "The backup file '{filename}' was not deleted." msgstr "Le fichier de sauvegarde '{filename}' n'a pas été supprimé." -#: update.py:226 +#: update.py:299 msgid "Error: Could not import TimeTracker to get the current version." msgstr "" "Erreur : Impossible d'importer TimeTracker pour obtenir la version actuelle." -#: update.py:229 +#: update.py:302 msgid "Checking for updates..." msgstr "Vérification des mises à jour..." -#: update.py:236 +#: update.py:309 msgid "No updates available." msgstr "Aucune mise à jour disponible." diff --git a/locale/timetracker.pot b/locale/timetracker.pot index f1c0b26..5ab4f43 100644 --- a/locale/timetracker.pot +++ b/locale/timetracker.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-08-06 10:27+0200\n" +"POT-Creation-Date: 2026-08-11 17:36+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,1504 +17,1695 @@ msgstr "" "Content-Type: text/plain; charset=CHARSET\n" "Content-Transfer-Encoding: 8bit\n" -#: sl/SL_Menu.py:278 sl/SL_Menu.py:1005 sl/SL_Menu.py:2343 sl/SL_Menu.py:2515 +#: sl/SL_Menu.py:294 sl/SL_Menu.py:1134 sl/SL_Menu.py:2645 sl/SL_Menu.py:2817 msgid "Priority" msgstr "" -#: sl/SL_Menu.py:462 +#: sl/SL_Menu.py:547 #, python-brace-format msgid "Version {version}" msgstr "" -#: sl/SL_Menu.py:467 update.py:49 +#: sl/SL_Menu.py:552 update.py:115 #, python-brace-format msgid "A new version ({version}) is available." msgstr "" -#: sl/SL_Menu.py:469 +#: sl/SL_Menu.py:554 msgid "Restart and install the update" msgstr "" -#: sl/SL_Menu.py:470 +#: sl/SL_Menu.py:555 msgid "Downloading and installing update..." msgstr "" -#: sl/SL_Menu.py:501 +#: sl/SL_Menu.py:590 +#, python-brace-format +msgid "" +"{count} time entries were discarded because the task they belonged to had " +"been deleted on another machine." +msgstr "" + +#: sl/SL_Menu.py:613 +#, python-brace-format +msgid "Synchronisation is paused: {reason}" +msgstr "" + +#: sl/SL_Menu.py:635 msgid "New" msgstr "" -#: sl/SL_Menu.py:502 +#: sl/SL_Menu.py:636 msgid "New Project" msgstr "" -#: sl/SL_Menu.py:505 +#: sl/SL_Menu.py:639 msgid "New Task" msgstr "" -#: sl/SL_Menu.py:510 +#: sl/SL_Menu.py:644 msgid "Project & Task Management" msgstr "" -#: sl/SL_Menu.py:511 sl/SL_Menu.py:1217 +#: sl/SL_Menu.py:645 sl/SL_Menu.py:1349 msgid "Main Project Management" msgstr "" -#: sl/SL_Menu.py:512 sl/SL_Menu.py:1233 sl/SL_Menu.py:1628 +#: sl/SL_Menu.py:646 sl/SL_Menu.py:1365 sl/SL_Menu.py:1930 msgid "Add Project" msgstr "" -#: sl/SL_Menu.py:515 sl/SL_Menu.py:1236 sl/SL_Menu.py:1976 +#: sl/SL_Menu.py:649 sl/SL_Menu.py:1368 sl/SL_Menu.py:2278 msgid "List Projects" msgstr "" -#: sl/SL_Menu.py:518 sl/SL_Menu.py:1239 sl/SL_Menu.py:1991 +#: sl/SL_Menu.py:652 sl/SL_Menu.py:1371 sl/SL_Menu.py:2293 msgid "Rename Project" msgstr "" -#: sl/SL_Menu.py:521 sl/SL_Menu.py:1242 sl/SL_Menu.py:2106 sl/SL_Menu.py:2119 +#: sl/SL_Menu.py:655 sl/SL_Menu.py:1374 sl/SL_Menu.py:2408 sl/SL_Menu.py:2421 msgid "Close Project" msgstr "" -#: sl/SL_Menu.py:524 sl/SL_Menu.py:1245 sl/SL_Menu.py:2136 sl/SL_Menu.py:2149 +#: sl/SL_Menu.py:658 sl/SL_Menu.py:1377 sl/SL_Menu.py:2438 sl/SL_Menu.py:2451 msgid "Re-open Project" msgstr "" -#: sl/SL_Menu.py:527 sl/SL_Menu.py:1248 sl/SL_Menu.py:2166 sl/SL_Menu.py:2180 +#: sl/SL_Menu.py:661 sl/SL_Menu.py:1380 sl/SL_Menu.py:2468 sl/SL_Menu.py:2482 msgid "Delete Project" msgstr "" -#: sl/SL_Menu.py:530 sl/SL_Menu.py:1251 sl/SL_Menu.py:2197 +#: sl/SL_Menu.py:664 sl/SL_Menu.py:1383 sl/SL_Menu.py:2499 msgid "List Inactive Projects" msgstr "" -#: sl/SL_Menu.py:533 sl/SL_Menu.py:1254 +#: sl/SL_Menu.py:667 sl/SL_Menu.py:1386 msgid "Demote Project to Task" msgstr "" -#: sl/SL_Menu.py:536 sl/SL_Menu.py:1257 sl/SL_Menu.py:2259 +#: sl/SL_Menu.py:670 sl/SL_Menu.py:1389 sl/SL_Menu.py:2561 msgid "List Completed Projects" msgstr "" -#: sl/SL_Menu.py:540 sl/SL_Menu.py:1219 sl/SL_Menu.py:1270 +#: sl/SL_Menu.py:674 sl/SL_Menu.py:1351 sl/SL_Menu.py:1402 msgid "Task Management" msgstr "" -#: sl/SL_Menu.py:541 sl/SL_Menu.py:1272 sl/SL_Menu.py:2277 sl/SL_Menu.py:2308 -#: sl/SL_Menu.py:2383 +#: sl/SL_Menu.py:675 sl/SL_Menu.py:1404 sl/SL_Menu.py:2579 sl/SL_Menu.py:2610 +#: sl/SL_Menu.py:2685 msgid "Add Task" msgstr "" -#: sl/SL_Menu.py:544 sl/SL_Menu.py:1275 sl/SL_Menu.py:2026 +#: sl/SL_Menu.py:678 sl/SL_Menu.py:1407 sl/SL_Menu.py:2328 msgid "List Tasks" msgstr "" -#: sl/SL_Menu.py:547 sl/SL_Menu.py:1278 sl/SL_Menu.py:2059 +#: sl/SL_Menu.py:681 sl/SL_Menu.py:1410 sl/SL_Menu.py:2361 msgid "Rename Task" msgstr "" -#: sl/SL_Menu.py:550 sl/SL_Menu.py:1281 sl/SL_Menu.py:1643 sl/SL_Menu.py:1669 -#: sl/SL_Menu.py:1848 +#: sl/SL_Menu.py:684 sl/SL_Menu.py:1413 sl/SL_Menu.py:1945 sl/SL_Menu.py:1971 +#: sl/SL_Menu.py:2150 msgid "Close Task" msgstr "" -#: sl/SL_Menu.py:553 sl/SL_Menu.py:1284 sl/SL_Menu.py:1688 sl/SL_Menu.py:1714 +#: sl/SL_Menu.py:687 sl/SL_Menu.py:1416 sl/SL_Menu.py:1990 sl/SL_Menu.py:2016 msgid "Re-open Task" msgstr "" -#: sl/SL_Menu.py:556 sl/SL_Menu.py:1287 sl/SL_Menu.py:1733 sl/SL_Menu.py:1760 +#: sl/SL_Menu.py:690 sl/SL_Menu.py:1419 sl/SL_Menu.py:2035 sl/SL_Menu.py:2062 msgid "Delete Task" msgstr "" -#: sl/SL_Menu.py:559 sl/SL_Menu.py:1290 sl/SL_Menu.py:1779 sl/SL_Menu.py:1815 +#: sl/SL_Menu.py:693 sl/SL_Menu.py:1422 sl/SL_Menu.py:2081 sl/SL_Menu.py:2117 msgid "Move Task" msgstr "" -#: sl/SL_Menu.py:562 sl/SL_Menu.py:1293 sl/SL_Menu.py:1834 +#: sl/SL_Menu.py:696 sl/SL_Menu.py:1425 sl/SL_Menu.py:2136 msgid "List Inactive Tasks" msgstr "" -#: sl/SL_Menu.py:565 sl/SL_Menu.py:1296 sl/SL_Menu.py:1861 +#: sl/SL_Menu.py:699 sl/SL_Menu.py:1428 sl/SL_Menu.py:2163 msgid "List All Closed Tasks" msgstr "" -#: sl/SL_Menu.py:568 sl/SL_Menu.py:727 sl/SL_Menu.py:813 sl/SL_Menu.py:1027 -#: sl/SL_Menu.py:1299 sl/SL_Menu.py:2411 sl/SL_Menu.py:2431 sl/SL_Menu.py:2466 +#: sl/SL_Menu.py:702 sl/SL_Menu.py:861 sl/SL_Menu.py:945 sl/SL_Menu.py:1155 +#: sl/SL_Menu.py:1431 sl/SL_Menu.py:2713 sl/SL_Menu.py:2733 sl/SL_Menu.py:2768 msgid "Edit Task" msgstr "" -#: sl/SL_Menu.py:571 sl/SL_Menu.py:1302 sl/SL_Menu.py:1886 +#: sl/SL_Menu.py:705 sl/SL_Menu.py:1434 sl/SL_Menu.py:2188 msgid "Delete All Closed Tasks" msgstr "" -#: sl/SL_Menu.py:574 sl/SL_Menu.py:1305 sl/SL_Menu.py:1928 +#: sl/SL_Menu.py:708 sl/SL_Menu.py:1437 sl/SL_Menu.py:2230 msgid "Promote Task to Project" msgstr "" -#: sl/SL_Menu.py:579 +#: sl/SL_Menu.py:713 msgid "Today View" msgstr "" -#: sl/SL_Menu.py:584 sl/SL_Menu.py:643 +#: sl/SL_Menu.py:718 sl/SL_Menu.py:777 msgid "Task Planning" msgstr "" -#: sl/SL_Menu.py:589 sl/SL_Menu.py:1062 +#: sl/SL_Menu.py:723 sl/SL_Menu.py:1189 msgid "E-Mail Task Assignment" msgstr "" -#: sl/SL_Menu.py:594 sl/SL_Menu.py:723 sl/SL_Menu.py:809 sl/SL_Menu.py:1023 +#: sl/SL_Menu.py:728 sl/SL_Menu.py:857 sl/SL_Menu.py:941 sl/SL_Menu.py:1151 msgid "Start work on task" msgstr "" -#: sl/SL_Menu.py:599 +#: sl/SL_Menu.py:733 msgid "Show current work" msgstr "" -#: sl/SL_Menu.py:604 +#: sl/SL_Menu.py:738 msgid "Stop current work" msgstr "" -#: sl/SL_Menu.py:606 +#: sl/SL_Menu.py:740 msgid "Work session stopped successfully." msgstr "" -#: sl/SL_Menu.py:608 +#: sl/SL_Menu.py:742 msgid "No active work session to stop." msgstr "" -#: sl/SL_Menu.py:612 sl/SL_Menu.py:1318 +#: sl/SL_Menu.py:746 sl/SL_Menu.py:1450 msgid "Reporting" msgstr "" -#: sl/SL_Menu.py:613 sl/SL_Menu.py:1321 +#: sl/SL_Menu.py:747 sl/SL_Menu.py:1453 msgid "Daily Report (Today)" msgstr "" -#: sl/SL_Menu.py:618 sl/SL_Menu.py:1327 sl/SL_Menu.py:2661 +#: sl/SL_Menu.py:752 sl/SL_Menu.py:1459 sl/SL_Menu.py:2967 msgid "Daily Report (Specific Day)" msgstr "" -#: sl/SL_Menu.py:621 sl/SL_Menu.py:1330 sl/SL_Menu.py:2680 +#: sl/SL_Menu.py:755 sl/SL_Menu.py:1462 sl/SL_Menu.py:2986 msgid "Date Range Report" msgstr "" -#: sl/SL_Menu.py:624 sl/SL_Menu.py:1333 sl/SL_Menu.py:2707 sl/SL_Menu.py:2738 +#: sl/SL_Menu.py:758 sl/SL_Menu.py:1465 sl/SL_Menu.py:3013 sl/SL_Menu.py:3044 msgid "Detailed Task Report" msgstr "" -#: sl/SL_Menu.py:627 sl/SL_Menu.py:1336 sl/SL_Menu.py:2766 +#: sl/SL_Menu.py:761 sl/SL_Menu.py:1468 sl/SL_Menu.py:3072 msgid "Detailed Project Report" msgstr "" -#: sl/SL_Menu.py:630 sl/SL_Menu.py:1339 sl/SL_Menu.py:2793 +#: sl/SL_Menu.py:764 sl/SL_Menu.py:1471 sl/SL_Menu.py:3099 msgid "Detailed Daily Report" msgstr "" -#: sl/SL_Menu.py:635 sl/SL_Menu.py:1356 +#: sl/SL_Menu.py:769 sl/SL_Menu.py:1518 msgid "Settings" msgstr "" -#: sl/SL_Menu.py:648 sl/SL_Menu.py:668 sl/SL_Menu.py:734 sl/SL_Menu.py:820 -#: sl/SL_Menu.py:1177 sl/SL_Menu.py:2338 sl/SL_Menu.py:2509 +#: sl/SL_Menu.py:782 sl/SL_Menu.py:802 sl/SL_Menu.py:868 sl/SL_Menu.py:952 +#: sl/SL_Menu.py:1304 sl/SL_Menu.py:2640 sl/SL_Menu.py:2811 msgid "Today" msgstr "" -#: sl/SL_Menu.py:649 sl/SL_Menu.py:669 +#: sl/SL_Menu.py:783 sl/SL_Menu.py:803 msgid "Tomorrow" msgstr "" -#: sl/SL_Menu.py:650 sl/SL_Menu.py:670 +#: sl/SL_Menu.py:784 sl/SL_Menu.py:804 msgid "Weekly overview" msgstr "" -#: sl/SL_Menu.py:651 sl/SL_Menu.py:671 +#: sl/SL_Menu.py:785 sl/SL_Menu.py:805 msgid "Overdue tasks" msgstr "" -#: sl/SL_Menu.py:652 sl/SL_Menu.py:672 +#: sl/SL_Menu.py:786 sl/SL_Menu.py:806 msgid "Unplanned tasks" msgstr "" -#: sl/SL_Menu.py:653 +#: sl/SL_Menu.py:787 msgid "All" msgstr "" -#: sl/SL_Menu.py:660 +#: sl/SL_Menu.py:794 msgid "Filter" msgstr "" -#: sl/SL_Menu.py:679 +#: sl/SL_Menu.py:813 msgid "Tasks" msgstr "" -#: sl/SL_Menu.py:686 tt/TimeTracker.py:1479 tt/TimeTracker.py:1577 +#: sl/SL_Menu.py:820 tt/TimeTracker.py:1937 tt/TimeTracker.py:2035 msgid "Friday" msgstr "" -#: sl/SL_Menu.py:686 tt/TimeTracker.py:1479 tt/TimeTracker.py:1577 +#: sl/SL_Menu.py:820 tt/TimeTracker.py:1937 tt/TimeTracker.py:2035 msgid "Monday" msgstr "" -#: sl/SL_Menu.py:686 tt/TimeTracker.py:1479 tt/TimeTracker.py:1577 +#: sl/SL_Menu.py:820 tt/TimeTracker.py:1937 tt/TimeTracker.py:2035 msgid "Saturday" msgstr "" -#: sl/SL_Menu.py:686 tt/TimeTracker.py:1479 tt/TimeTracker.py:1577 +#: sl/SL_Menu.py:820 tt/TimeTracker.py:1937 tt/TimeTracker.py:2035 msgid "Sunday" msgstr "" -#: sl/SL_Menu.py:686 tt/TimeTracker.py:1479 tt/TimeTracker.py:1577 +#: sl/SL_Menu.py:820 tt/TimeTracker.py:1937 tt/TimeTracker.py:2035 msgid "Thursday" msgstr "" -#: sl/SL_Menu.py:686 tt/TimeTracker.py:1479 tt/TimeTracker.py:1577 +#: sl/SL_Menu.py:820 tt/TimeTracker.py:1937 tt/TimeTracker.py:2035 msgid "Tuesday" msgstr "" -#: sl/SL_Menu.py:686 tt/TimeTracker.py:1479 tt/TimeTracker.py:1577 +#: sl/SL_Menu.py:820 tt/TimeTracker.py:1937 tt/TimeTracker.py:2035 msgid "Wednesday" msgstr "" -#: sl/SL_Menu.py:747 sl/SL_Menu.py:833 sl/SL_Menu.py:885 sl/SL_Menu.py:1034 -#: sl/SL_Menu.py:2511 +#: sl/SL_Menu.py:880 sl/SL_Menu.py:964 sl/SL_Menu.py:1015 sl/SL_Menu.py:1162 +#: sl/SL_Menu.py:2813 msgid "Done" msgstr "" -#: sl/SL_Menu.py:797 sl/SL_Menu.py:993 +#: sl/SL_Menu.py:929 sl/SL_Menu.py:1122 msgid "Due" msgstr "" -#: sl/SL_Menu.py:848 +#: sl/SL_Menu.py:978 msgid "No tasks found." msgstr "" -#: sl/SL_Menu.py:850 sl/SL_Menu.py:1206 sl/SL_Menu.py:1224 sl/SL_Menu.py:1263 -#: sl/SL_Menu.py:1311 sl/SL_Menu.py:1345 sl/SL_Menu.py:1615 sl/SL_Menu.py:1648 -#: sl/SL_Menu.py:1659 sl/SL_Menu.py:1693 sl/SL_Menu.py:1704 sl/SL_Menu.py:1738 -#: sl/SL_Menu.py:1749 sl/SL_Menu.py:1784 sl/SL_Menu.py:1795 sl/SL_Menu.py:1803 -#: sl/SL_Menu.py:1854 sl/SL_Menu.py:1879 sl/SL_Menu.py:1901 sl/SL_Menu.py:1933 -#: sl/SL_Menu.py:1944 sl/SL_Menu.py:1984 sl/SL_Menu.py:1996 sl/SL_Menu.py:2031 -#: sl/SL_Menu.py:2052 sl/SL_Menu.py:2064 sl/SL_Menu.py:2075 sl/SL_Menu.py:2111 -#: sl/SL_Menu.py:2141 sl/SL_Menu.py:2171 sl/SL_Menu.py:2211 sl/SL_Menu.py:2223 -#: sl/SL_Menu.py:2270 sl/SL_Menu.py:2282 sl/SL_Menu.py:2416 sl/SL_Menu.py:2435 -#: sl/SL_Menu.py:2448 sl/SL_Menu.py:2463 sl/SL_Menu.py:2600 sl/SL_Menu.py:2610 -#: sl/SL_Menu.py:2654 sl/SL_Menu.py:2673 sl/SL_Menu.py:2700 sl/SL_Menu.py:2712 -#: sl/SL_Menu.py:2724 sl/SL_Menu.py:2743 sl/SL_Menu.py:2759 sl/SL_Menu.py:2771 -#: sl/SL_Menu.py:2786 sl/SL_Menu.py:2805 sl/SL_Menu.py:2846 +#: sl/SL_Menu.py:980 sl/SL_Menu.py:1338 sl/SL_Menu.py:1356 sl/SL_Menu.py:1395 +#: sl/SL_Menu.py:1443 sl/SL_Menu.py:1477 sl/SL_Menu.py:1917 sl/SL_Menu.py:1950 +#: sl/SL_Menu.py:1961 sl/SL_Menu.py:1995 sl/SL_Menu.py:2006 sl/SL_Menu.py:2040 +#: sl/SL_Menu.py:2051 sl/SL_Menu.py:2086 sl/SL_Menu.py:2097 sl/SL_Menu.py:2105 +#: sl/SL_Menu.py:2156 sl/SL_Menu.py:2181 sl/SL_Menu.py:2203 sl/SL_Menu.py:2235 +#: sl/SL_Menu.py:2246 sl/SL_Menu.py:2286 sl/SL_Menu.py:2298 sl/SL_Menu.py:2333 +#: sl/SL_Menu.py:2354 sl/SL_Menu.py:2366 sl/SL_Menu.py:2377 sl/SL_Menu.py:2413 +#: sl/SL_Menu.py:2443 sl/SL_Menu.py:2473 sl/SL_Menu.py:2513 sl/SL_Menu.py:2525 +#: sl/SL_Menu.py:2572 sl/SL_Menu.py:2584 sl/SL_Menu.py:2718 sl/SL_Menu.py:2737 +#: sl/SL_Menu.py:2750 sl/SL_Menu.py:2765 sl/SL_Menu.py:2906 sl/SL_Menu.py:2916 +#: sl/SL_Menu.py:2960 sl/SL_Menu.py:2979 sl/SL_Menu.py:3006 sl/SL_Menu.py:3018 +#: sl/SL_Menu.py:3030 sl/SL_Menu.py:3049 sl/SL_Menu.py:3065 sl/SL_Menu.py:3077 +#: sl/SL_Menu.py:3092 sl/SL_Menu.py:3111 sl/SL_Menu.py:3152 msgid "Back" msgstr "" -#: sl/SL_Menu.py:861 +#: sl/SL_Menu.py:991 msgid "Today's Tasks" msgstr "" -#: sl/SL_Menu.py:880 sl/SL_Menu.py:2639 +#: sl/SL_Menu.py:1010 sl/SL_Menu.py:2945 msgid "Current Active Work" msgstr "" -#: sl/SL_Menu.py:882 sl/SL_Menu.py:2652 +#: sl/SL_Menu.py:1012 sl/SL_Menu.py:2958 msgid "No active work session." msgstr "" -#: sl/SL_Menu.py:899 +#: sl/SL_Menu.py:1028 msgid "Edit current task" msgstr "" -#: sl/SL_Menu.py:923 +#: sl/SL_Menu.py:1052 msgid "Show only open tasks" msgstr "" -#: sl/SL_Menu.py:936 +#: sl/SL_Menu.py:1065 msgid "Sort by priority" msgstr "" -#: sl/SL_Menu.py:1008 sl/SL_Menu.py:2343 sl/SL_Menu.py:2515 +#: sl/SL_Menu.py:1137 sl/SL_Menu.py:2645 sl/SL_Menu.py:2817 msgid "0 (lowest) to 9 (highest)" msgstr "" -#: sl/SL_Menu.py:1049 +#: sl/SL_Menu.py:1176 msgid "No open tasks for today." msgstr "" -#: sl/SL_Menu.py:1051 +#: sl/SL_Menu.py:1178 msgid "No tasks for today." msgstr "" -#: sl/SL_Menu.py:1055 +#: sl/SL_Menu.py:1182 msgid "Exit" msgstr "" -#: sl/SL_Menu.py:1068 +#: sl/SL_Menu.py:1195 msgid "Fetching emails..." msgstr "" -#: sl/SL_Menu.py:1071 +#: sl/SL_Menu.py:1198 #, python-brace-format msgid "Error fetching emails: {error}" msgstr "" -#: sl/SL_Menu.py:1074 +#: sl/SL_Menu.py:1201 #, python-brace-format msgid "{count} new tasks created from emails." msgstr "" -#: sl/SL_Menu.py:1076 +#: sl/SL_Menu.py:1203 msgid "No new emails found." msgstr "" -#: sl/SL_Menu.py:1101 +#: sl/SL_Menu.py:1228 #, python-brace-format msgid "{remaining} of {total} emails still to process" msgstr "" -#: sl/SL_Menu.py:1105 +#: sl/SL_Menu.py:1232 msgid "No unassigned email tasks available." msgstr "" -#: sl/SL_Menu.py:1117 +#: sl/SL_Menu.py:1244 msgid "Assign Project" msgstr "" -#: sl/SL_Menu.py:1128 +#: sl/SL_Menu.py:1255 msgid "Are you sure you want to delete this task?" msgstr "" -#: sl/SL_Menu.py:1131 +#: sl/SL_Menu.py:1258 msgid "Yes, delete" msgstr "" -#: sl/SL_Menu.py:1136 +#: sl/SL_Menu.py:1263 msgid "No, cancel" msgstr "" -#: sl/SL_Menu.py:1143 +#: sl/SL_Menu.py:1270 msgid "Delete" msgstr "" -#: sl/SL_Menu.py:1147 +#: sl/SL_Menu.py:1274 msgid "Edit Details" msgstr "" -#: sl/SL_Menu.py:1155 sl/SL_Menu.py:2495 +#: sl/SL_Menu.py:1282 sl/SL_Menu.py:2797 msgid "Task Name" msgstr "" -#: sl/SL_Menu.py:1164 sl/SL_Menu.py:2499 +#: sl/SL_Menu.py:1291 sl/SL_Menu.py:2801 msgid "Due Date" msgstr "" -#: sl/SL_Menu.py:1172 sl/SL_Menu.py:2503 +#: sl/SL_Menu.py:1299 sl/SL_Menu.py:2805 msgid "Clear" msgstr "" -#: sl/SL_Menu.py:1179 sl/SL_Menu.py:2371 sl/SL_Menu.py:2544 +#: sl/SL_Menu.py:1306 sl/SL_Menu.py:2673 sl/SL_Menu.py:2846 msgid "Notes (Markdown)" msgstr "" -#: sl/SL_Menu.py:1198 +#: sl/SL_Menu.py:1330 msgid "Task details updated successfully." msgstr "" -#: sl/SL_Menu.py:1204 +#: sl/SL_Menu.py:1336 msgid "Error updating task details." msgstr "" -#: sl/SL_Menu.py:1215 sl/SL_Menu.py:1231 +#: sl/SL_Menu.py:1347 sl/SL_Menu.py:1363 msgid "Project Management" msgstr "" -#: sl/SL_Menu.py:1359 +#: sl/SL_Menu.py:1490 +msgid "No server address is set. Enter one above and save it first." +msgstr "" + +#: sl/SL_Menu.py:1491 +msgid "" +"The address must start with https:// - a token sent over plain HTTP could be " +"read by anyone on the way." +msgstr "" + +#: sl/SL_Menu.py:1493 +msgid "Please enter both a username and a password." +msgstr "" + +#: sl/SL_Menu.py:1494 +msgid "Wrong username or password." +msgstr "" + +#: sl/SL_Menu.py:1495 +msgid "Too many sign-in attempts on the server. Try again in a minute." +msgstr "" + +#: sl/SL_Menu.py:1496 +msgid "The server's certificate could not be verified." +msgstr "" + +#: sl/SL_Menu.py:1497 +msgid "The server did not answer in time." +msgstr "" + +#: sl/SL_Menu.py:1498 +msgid "The server could not be reached. Check the address and your connection." +msgstr "" + +#: sl/SL_Menu.py:1499 +msgid "" +"The address answered, but not like a TimeControl sync server. Check that it " +"points at the right directory." +msgstr "" + +#: sl/SL_Menu.py:1501 +msgid "The server is reachable but has not been set up yet." +msgstr "" + +#: sl/SL_Menu.py:1503 +msgid "This device is not signed in to the server." +msgstr "" + +#: sl/SL_Menu.py:1504 sl/SL_Menu.py:1887 +msgid "This device is no longer signed in. Please sign in again." +msgstr "" + +#: sl/SL_Menu.py:1505 +msgid "The synchronisation files on this computer could not be written." +msgstr "" + +#: sl/SL_Menu.py:1507 +#, python-brace-format +msgid "Sign-in failed ({code})." +msgstr "" + +#: sl/SL_Menu.py:1521 msgid "Change Language" msgstr "" -#: sl/SL_Menu.py:1377 +#: sl/SL_Menu.py:1539 msgid "Select Language" msgstr "" -#: sl/SL_Menu.py:1378 sl/SL_Menu.py:1418 sl/SL_Menu.py:1459 sl/SL_Menu.py:1472 -#: sl/SL_Menu.py:1492 sl/SL_Menu.py:1526 sl/SL_Menu.py:1549 sl/SL_Menu.py:1604 +#: sl/SL_Menu.py:1540 sl/SL_Menu.py:1580 sl/SL_Menu.py:1621 sl/SL_Menu.py:1634 +#: sl/SL_Menu.py:1654 sl/SL_Menu.py:1688 sl/SL_Menu.py:1711 sl/SL_Menu.py:1766 +#: sl/SL_Menu.py:1804 msgid "Save" msgstr "" -#: sl/SL_Menu.py:1384 +#: sl/SL_Menu.py:1546 msgid "" "Language changed. Please restart the application for the changes to take " "effect." msgstr "" -#: sl/SL_Menu.py:1387 +#: sl/SL_Menu.py:1549 msgid "Restore Previous Version" msgstr "" -#: sl/SL_Menu.py:1390 +#: sl/SL_Menu.py:1552 msgid "The 'update' module is not available. This feature is disabled." msgstr "" -#: sl/SL_Menu.py:1392 +#: sl/SL_Menu.py:1554 #, python-brace-format msgid "No previous version backup '{filename}' found." msgstr "" -#: sl/SL_Menu.py:1394 +#: sl/SL_Menu.py:1556 msgid "" "This will restore the application to the previously backed-up version. The " "application will then restart. You may need to manually refresh your browser " "if it does not reconnect automatically." msgstr "" -#: sl/SL_Menu.py:1395 +#: sl/SL_Menu.py:1557 msgid "Restore and Restart" msgstr "" -#: sl/SL_Menu.py:1396 +#: sl/SL_Menu.py:1558 msgid "Restoring and restarting..." msgstr "" -#: sl/SL_Menu.py:1399 +#: sl/SL_Menu.py:1561 msgid "Restore complete. Please restart the application." msgstr "" -#: sl/SL_Menu.py:1401 +#: sl/SL_Menu.py:1563 msgid "Change Data Storage Location" msgstr "" -#: sl/SL_Menu.py:1403 +#: sl/SL_Menu.py:1565 msgid "Current data file" msgstr "" -#: sl/SL_Menu.py:1406 +#: sl/SL_Menu.py:1568 msgid "New Path for data file" msgstr "" -#: sl/SL_Menu.py:1412 +#: sl/SL_Menu.py:1574 msgid "Move existing data to the new location" msgstr "" -#: sl/SL_Menu.py:1415 +#: sl/SL_Menu.py:1577 msgid "" "If unchecked, the old data file will remain, and a new empty one might be " "created at the new location on restart." msgstr "" -#: sl/SL_Menu.py:1422 +#: sl/SL_Menu.py:1584 msgid "Please enter a new path." msgstr "" -#: sl/SL_Menu.py:1430 +#: sl/SL_Menu.py:1592 msgid "" "Error: For security, the data file must be located within the application " "directory." msgstr "" -#: sl/SL_Menu.py:1434 +#: sl/SL_Menu.py:1596 #, python-brace-format msgid "Error: The directory '{dir}' does not exist." msgstr "" -#: sl/SL_Menu.py:1439 +#: sl/SL_Menu.py:1601 msgid "" "Storage location updated. Please restart the application for the changes to " "take effect." msgstr "" -#: sl/SL_Menu.py:1444 +#: sl/SL_Menu.py:1606 msgid "Data moved successfully." msgstr "" -#: sl/SL_Menu.py:1446 +#: sl/SL_Menu.py:1608 #, python-brace-format msgid "Error moving data: {error}" msgstr "" -#: sl/SL_Menu.py:1453 +#: sl/SL_Menu.py:1615 msgid "Report Format" msgstr "" -#: sl/SL_Menu.py:1458 +#: sl/SL_Menu.py:1620 msgid "Select Format" msgstr "" -#: sl/SL_Menu.py:1463 +#: sl/SL_Menu.py:1625 msgid "Report format updated." msgstr "" -#: sl/SL_Menu.py:1466 +#: sl/SL_Menu.py:1628 msgid "Streamlit Port Settings" msgstr "" -#: sl/SL_Menu.py:1468 +#: sl/SL_Menu.py:1630 msgid "Current Streamlit Port" msgstr "" -#: sl/SL_Menu.py:1471 +#: sl/SL_Menu.py:1633 msgid "New Port" msgstr "" -#: sl/SL_Menu.py:1476 +#: sl/SL_Menu.py:1638 #, python-brace-format msgid "Port updated to {port}. Please restart Streamlit." msgstr "" -#: sl/SL_Menu.py:1479 +#: sl/SL_Menu.py:1641 msgid "Email Settings" msgstr "" -#: sl/SL_Menu.py:1485 +#: sl/SL_Menu.py:1647 msgid "Enable email import" msgstr "" -#: sl/SL_Menu.py:1486 +#: sl/SL_Menu.py:1648 msgid "IMAP Server" msgstr "" -#: sl/SL_Menu.py:1487 +#: sl/SL_Menu.py:1649 msgid "Port" msgstr "" -#: sl/SL_Menu.py:1488 +#: sl/SL_Menu.py:1650 sl/SL_Menu.py:1896 msgid "Username" msgstr "" -#: sl/SL_Menu.py:1489 +#: sl/SL_Menu.py:1651 sl/SL_Menu.py:1897 msgid "Password" msgstr "" -#: sl/SL_Menu.py:1490 +#: sl/SL_Menu.py:1652 msgid "Use SSL" msgstr "" -#: sl/SL_Menu.py:1503 +#: sl/SL_Menu.py:1665 msgid "Email settings saved." msgstr "" -#: sl/SL_Menu.py:1506 +#: sl/SL_Menu.py:1668 msgid "Change CSS Style" msgstr "" -#: sl/SL_Menu.py:1508 +#: sl/SL_Menu.py:1670 msgid "Current CSS file" msgstr "" -#: sl/SL_Menu.py:1525 +#: sl/SL_Menu.py:1687 msgid "Select CSS File" msgstr "" -#: sl/SL_Menu.py:1531 +#: sl/SL_Menu.py:1693 msgid "" "CSS style updated. Please restart the application for the changes to take " "effect." msgstr "" -#: sl/SL_Menu.py:1534 +#: sl/SL_Menu.py:1696 msgid "Change View Mode" msgstr "" -#: sl/SL_Menu.py:1538 +#: sl/SL_Menu.py:1700 msgid "App Window (Webview)" msgstr "" -#: sl/SL_Menu.py:1538 +#: sl/SL_Menu.py:1700 msgid "System Browser" msgstr "" -#: sl/SL_Menu.py:1548 +#: sl/SL_Menu.py:1710 msgid "Select View Mode" msgstr "" -#: sl/SL_Menu.py:1555 +#: sl/SL_Menu.py:1717 msgid "" "View mode updated. Please restart the application for the changes to take " "effect." msgstr "" -#: sl/SL_Menu.py:1558 +#: sl/SL_Menu.py:1720 msgid "MCP Server Settings" msgstr "" -#: sl/SL_Menu.py:1560 +#: sl/SL_Menu.py:1722 msgid "HTTP (Streamable HTTP)" msgstr "" -#: sl/SL_Menu.py:1561 +#: sl/SL_Menu.py:1723 msgid "stdio (recommended for Claude Desktop)" msgstr "" -#: sl/SL_Menu.py:1575 +#: sl/SL_Menu.py:1737 msgid "Transport" msgstr "" -#: sl/SL_Menu.py:1586 +#: sl/SL_Menu.py:1748 msgid "Enable MCP server" msgstr "" -#: sl/SL_Menu.py:1589 +#: sl/SL_Menu.py:1751 msgid "" "Not used with stdio - the MCP client starts and stops the server itself." msgstr "" -#: sl/SL_Menu.py:1592 +#: sl/SL_Menu.py:1754 msgid "Port (HTTP only)" msgstr "" -#: sl/SL_Menu.py:1599 +#: sl/SL_Menu.py:1761 msgid "" "With stdio, the app does not start the MCP server itself - the MCP client " "(e.g. Claude Desktop) launches it directly, and the port is ignored." msgstr "" -#: sl/SL_Menu.py:1610 +#: sl/SL_Menu.py:1772 msgid "" "MCP server settings saved. Please restart the application for the changes to " "take effect." msgstr "" -#: sl/SL_Menu.py:1624 +#: sl/SL_Menu.py:1775 +msgid "Sync Server Settings" +msgstr "" + +#: sl/SL_Menu.py:1777 +msgid "" +"The sync client is unavailable because the 'requests' package is missing." +msgstr "" + +#: sl/SL_Menu.py:1788 +msgid "Server address" +msgstr "" + +#: sl/SL_Menu.py:1793 +msgid "Enable synchronisation" +msgstr "" + +#: sl/SL_Menu.py:1795 +msgid "Without this, TimeControl works entirely locally, exactly as before." +msgstr "" + +#: sl/SL_Menu.py:1798 +msgid "Sync every (minutes)" +msgstr "" + +#: sl/SL_Menu.py:1802 +msgid "Synchronisation also runs whenever you switch to a different view." +msgstr "" + +#: sl/SL_Menu.py:1816 +msgid "Sync server settings saved." +msgstr "" + +#: sl/SL_Menu.py:1842 +#, python-brace-format +msgid "Last synchronised at {time}." +msgstr "" + +#: sl/SL_Menu.py:1845 +msgid "Not synchronised yet." +msgstr "" + +#: sl/SL_Menu.py:1847 +#, python-brace-format +msgid "{count} changes are waiting to be sent." +msgstr "" + +#: sl/SL_Menu.py:1853 +#, python-brace-format +msgid "Signed in as {user}." +msgstr "" + +#: sl/SL_Menu.py:1855 +#, python-brace-format +msgid "Access expires on {date}." +msgstr "" + +#: sl/SL_Menu.py:1859 +msgid "Check connection" +msgstr "" + +#: sl/SL_Menu.py:1861 sl/SL_Menu.py:1879 sl/SL_Menu.py:1899 +msgid "Contacting the server..." +msgstr "" + +#: sl/SL_Menu.py:1869 +msgid "The server answered." +msgstr "" + +#: sl/SL_Menu.py:1878 +msgid "Sign out" +msgstr "" + +#: sl/SL_Menu.py:1881 +msgid "Signed out on this device." +msgstr "" + +#: sl/SL_Menu.py:1889 +#, python-brace-format +msgid "The server could not be reached ({reason})." +msgstr "" + +#: sl/SL_Menu.py:1893 +msgid "" +"Signing in stores an access token for this device only. It is kept outside " +"the project directory and is never written to config.json." +msgstr "" + +#: sl/SL_Menu.py:1898 +msgid "Sign in" +msgstr "" + +#: sl/SL_Menu.py:1906 +msgid "Signed in successfully." +msgstr "" + +#: sl/SL_Menu.py:1912 +#, python-brace-format +msgid "This device: {name} ({uid})" +msgstr "" + +#: sl/SL_Menu.py:1926 msgid "Add New Project" msgstr "" -#: sl/SL_Menu.py:1627 +#: sl/SL_Menu.py:1929 msgid "Name of the project" msgstr "" -#: sl/SL_Menu.py:1631 +#: sl/SL_Menu.py:1933 #, python-brace-format msgid "Project '{name}' added." msgstr "" -#: sl/SL_Menu.py:1635 sl/SL_Menu.py:1681 sl/SL_Menu.py:1726 sl/SL_Menu.py:1772 -#: sl/SL_Menu.py:1827 sl/SL_Menu.py:1921 sl/SL_Menu.py:1969 sl/SL_Menu.py:2019 -#: sl/SL_Menu.py:2099 sl/SL_Menu.py:2129 sl/SL_Menu.py:2159 sl/SL_Menu.py:2190 -#: sl/SL_Menu.py:2252 sl/SL_Menu.py:2292 sl/SL_Menu.py:2404 sl/SL_Menu.py:2423 -#: sl/SL_Menu.py:2584 sl/SL_Menu.py:2632 +#: sl/SL_Menu.py:1937 sl/SL_Menu.py:1983 sl/SL_Menu.py:2028 sl/SL_Menu.py:2074 +#: sl/SL_Menu.py:2129 sl/SL_Menu.py:2223 sl/SL_Menu.py:2271 sl/SL_Menu.py:2321 +#: sl/SL_Menu.py:2401 sl/SL_Menu.py:2431 sl/SL_Menu.py:2461 sl/SL_Menu.py:2492 +#: sl/SL_Menu.py:2554 sl/SL_Menu.py:2594 sl/SL_Menu.py:2706 sl/SL_Menu.py:2725 +#: sl/SL_Menu.py:2890 sl/SL_Menu.py:2938 msgid "Cancel" msgstr "" -#: sl/SL_Menu.py:1647 sl/SL_Menu.py:1692 sl/SL_Menu.py:1737 sl/SL_Menu.py:1783 -#: sl/SL_Menu.py:1932 sl/SL_Menu.py:2063 sl/SL_Menu.py:2222 sl/SL_Menu.py:2415 +#: sl/SL_Menu.py:1949 sl/SL_Menu.py:1994 sl/SL_Menu.py:2039 sl/SL_Menu.py:2085 +#: sl/SL_Menu.py:2234 sl/SL_Menu.py:2365 sl/SL_Menu.py:2524 sl/SL_Menu.py:2717 msgid "No open projects found." msgstr "" -#: sl/SL_Menu.py:1653 sl/SL_Menu.py:1698 sl/SL_Menu.py:1743 sl/SL_Menu.py:1938 -#: sl/SL_Menu.py:2001 sl/SL_Menu.py:2036 sl/SL_Menu.py:2069 sl/SL_Menu.py:2118 -#: sl/SL_Menu.py:2148 sl/SL_Menu.py:2178 sl/SL_Menu.py:2605 sl/SL_Menu.py:2717 -#: sl/SL_Menu.py:2778 +#: sl/SL_Menu.py:1955 sl/SL_Menu.py:2000 sl/SL_Menu.py:2045 sl/SL_Menu.py:2240 +#: sl/SL_Menu.py:2303 sl/SL_Menu.py:2338 sl/SL_Menu.py:2371 sl/SL_Menu.py:2420 +#: sl/SL_Menu.py:2450 sl/SL_Menu.py:2480 sl/SL_Menu.py:2911 sl/SL_Menu.py:3023 +#: sl/SL_Menu.py:3084 msgid "Select Project" msgstr "" -#: sl/SL_Menu.py:1658 +#: sl/SL_Menu.py:1960 #, python-brace-format msgid "No open tasks to close in '{name}'." msgstr "" -#: sl/SL_Menu.py:1665 sl/SL_Menu.py:1710 sl/SL_Menu.py:1755 sl/SL_Menu.py:1809 -#: sl/SL_Menu.py:1950 sl/SL_Menu.py:2079 sl/SL_Menu.py:2438 sl/SL_Menu.py:2616 -#: sl/SL_Menu.py:2748 +#: sl/SL_Menu.py:1967 sl/SL_Menu.py:2012 sl/SL_Menu.py:2057 sl/SL_Menu.py:2111 +#: sl/SL_Menu.py:2252 sl/SL_Menu.py:2381 sl/SL_Menu.py:2740 sl/SL_Menu.py:2922 +#: sl/SL_Menu.py:3054 msgid "Select Task" msgstr "" -#: sl/SL_Menu.py:1675 +#: sl/SL_Menu.py:1977 #, python-brace-format msgid "Task '{sub_name}' in '{main_name}' has been closed." msgstr "" -#: sl/SL_Menu.py:1679 sl/SL_Menu.py:1724 sl/SL_Menu.py:1770 +#: sl/SL_Menu.py:1981 sl/SL_Menu.py:2026 sl/SL_Menu.py:2072 msgid "Error: Main project or task not found." msgstr "" -#: sl/SL_Menu.py:1703 +#: sl/SL_Menu.py:2005 #, python-brace-format msgid "No closed tasks to reopen in '{name}'." msgstr "" -#: sl/SL_Menu.py:1720 +#: sl/SL_Menu.py:2022 #, python-brace-format msgid "Task '{sub_name}' in '{main_name}' has been reopened." msgstr "" -#: sl/SL_Menu.py:1748 +#: sl/SL_Menu.py:2050 #, python-brace-format msgid "No open tasks to delete in '{name}'." msgstr "" -#: sl/SL_Menu.py:1759 +#: sl/SL_Menu.py:2061 msgid "This action cannot be undone." msgstr "" -#: sl/SL_Menu.py:1766 +#: sl/SL_Menu.py:2068 #, python-brace-format msgid "Task '{sub_name}' deleted from '{main_name}'." msgstr "" -#: sl/SL_Menu.py:1789 +#: sl/SL_Menu.py:2091 msgid "Select Source Project" msgstr "" -#: sl/SL_Menu.py:1794 +#: sl/SL_Menu.py:2096 #, python-brace-format msgid "No tasks found in '{name}'." msgstr "" -#: sl/SL_Menu.py:1802 +#: sl/SL_Menu.py:2104 msgid "No other projects available to move to." msgstr "" -#: sl/SL_Menu.py:1813 sl/SL_Menu.py:2238 +#: sl/SL_Menu.py:2115 sl/SL_Menu.py:2540 msgid "Select Target Project" msgstr "" -#: sl/SL_Menu.py:1821 +#: sl/SL_Menu.py:2123 #, python-brace-format msgid "Task '{sub}' moved from '{src}' to '{dst}'." msgstr "" -#: sl/SL_Menu.py:1825 +#: sl/SL_Menu.py:2127 msgid "Error: Could not move task." msgstr "" -#: sl/SL_Menu.py:1836 sl/SL_Menu.py:2199 +#: sl/SL_Menu.py:2138 sl/SL_Menu.py:2501 msgid "Weeks of inactivity" msgstr "" -#: sl/SL_Menu.py:1841 +#: sl/SL_Menu.py:2143 #, python-brace-format msgid "Inactive Tasks (> {weeks} weeks):" msgstr "" -#: sl/SL_Menu.py:1846 sl/SL_Menu.py:2207 +#: sl/SL_Menu.py:2148 sl/SL_Menu.py:2509 msgid "Last Activity" msgstr "" -#: sl/SL_Menu.py:1852 +#: sl/SL_Menu.py:2154 #, python-brace-format msgid "No tasks found inactive for more than {weeks} weeks." msgstr "" -#: sl/SL_Menu.py:1877 sl/SL_Menu.py:1900 +#: sl/SL_Menu.py:2179 sl/SL_Menu.py:2202 msgid "No closed tasks found." msgstr "" -#: sl/SL_Menu.py:1905 +#: sl/SL_Menu.py:2207 #, python-brace-format msgid "" "Are you sure you want to delete {count} closed tasks? This action cannot be " "undone." msgstr "" -#: sl/SL_Menu.py:1907 +#: sl/SL_Menu.py:2209 msgid "Show projects to delete" msgstr "" -#: sl/SL_Menu.py:1911 +#: sl/SL_Menu.py:2213 msgid "Delete All" msgstr "" -#: sl/SL_Menu.py:1917 +#: sl/SL_Menu.py:2219 #, python-brace-format msgid "Successfully deleted {count} tasks." msgstr "" -#: sl/SL_Menu.py:1943 +#: sl/SL_Menu.py:2245 #, python-brace-format msgid "No open tasks to promote in '{name}'." msgstr "" -#: sl/SL_Menu.py:1954 +#: sl/SL_Menu.py:2256 msgid "" "This will create a new Project with the task's name and move all time " "entries to a 'General' task within it." msgstr "" -#: sl/SL_Menu.py:1956 +#: sl/SL_Menu.py:2258 msgid "Promote to Project" msgstr "" -#: sl/SL_Menu.py:1980 sl/SL_Menu.py:2044 +#: sl/SL_Menu.py:2282 sl/SL_Menu.py:2346 msgid "closed" msgstr "" -#: sl/SL_Menu.py:1983 sl/SL_Menu.py:2030 sl/SL_Menu.py:2170 sl/SL_Menu.py:2711 -#: sl/SL_Menu.py:2770 +#: sl/SL_Menu.py:2285 sl/SL_Menu.py:2332 sl/SL_Menu.py:2472 sl/SL_Menu.py:3017 +#: sl/SL_Menu.py:3076 msgid "No projects found." msgstr "" -#: sl/SL_Menu.py:1995 +#: sl/SL_Menu.py:2297 msgid "No open projects to rename." msgstr "" -#: sl/SL_Menu.py:2004 sl/SL_Menu.py:2084 +#: sl/SL_Menu.py:2306 sl/SL_Menu.py:2386 msgid "New Name" msgstr "" -#: sl/SL_Menu.py:2005 sl/SL_Menu.py:2085 +#: sl/SL_Menu.py:2307 sl/SL_Menu.py:2387 msgid "Rename" msgstr "" -#: sl/SL_Menu.py:2009 sl/SL_Menu.py:2089 +#: sl/SL_Menu.py:2311 sl/SL_Menu.py:2391 msgid "Please enter a new name." msgstr "" -#: sl/SL_Menu.py:2011 sl/SL_Menu.py:2091 +#: sl/SL_Menu.py:2313 sl/SL_Menu.py:2393 msgid "New name is the same as the old name." msgstr "" -#: sl/SL_Menu.py:2013 +#: sl/SL_Menu.py:2315 #, python-brace-format msgid "Project '{old_name}' successfully renamed to '{new_name}'." msgstr "" -#: sl/SL_Menu.py:2017 +#: sl/SL_Menu.py:2319 #, python-brace-format msgid "Error: Could not rename. The new name '{new_name}' might already exist." msgstr "" -#: sl/SL_Menu.py:2041 +#: sl/SL_Menu.py:2343 #, python-brace-format msgid "Tasks for '{name}':" msgstr "" -#: sl/SL_Menu.py:2050 sl/SL_Menu.py:2742 +#: sl/SL_Menu.py:2352 sl/SL_Menu.py:3048 #, python-brace-format msgid "No tasks found for '{name}'." msgstr "" -#: sl/SL_Menu.py:2074 +#: sl/SL_Menu.py:2376 #, python-brace-format msgid "No open tasks to rename in '{name}'." msgstr "" -#: sl/SL_Menu.py:2093 +#: sl/SL_Menu.py:2395 #, python-brace-format msgid "Task '{old_name}' renamed to '{new_name}'." msgstr "" -#: sl/SL_Menu.py:2097 +#: sl/SL_Menu.py:2399 msgid "Error: Could not rename. The new name might already exist." msgstr "" -#: sl/SL_Menu.py:2110 +#: sl/SL_Menu.py:2412 msgid "No open projects to close." msgstr "" -#: sl/SL_Menu.py:2123 +#: sl/SL_Menu.py:2425 #, python-brace-format msgid "Project '{name}' has been closed." msgstr "" -#: sl/SL_Menu.py:2127 sl/SL_Menu.py:2157 sl/SL_Menu.py:2188 +#: sl/SL_Menu.py:2429 sl/SL_Menu.py:2459 sl/SL_Menu.py:2490 msgid "Error: Project not found." msgstr "" -#: sl/SL_Menu.py:2140 +#: sl/SL_Menu.py:2442 msgid "No closed projects to reopen." msgstr "" -#: sl/SL_Menu.py:2153 +#: sl/SL_Menu.py:2455 #, python-brace-format msgid "Project '{name}' has been reopened." msgstr "" -#: sl/SL_Menu.py:2179 +#: sl/SL_Menu.py:2481 msgid "" "This action cannot be undone. All associated tasks and time entries will be " "deleted." msgstr "" -#: sl/SL_Menu.py:2184 +#: sl/SL_Menu.py:2486 #, python-brace-format msgid "Project '{name}' has been deleted." msgstr "" -#: sl/SL_Menu.py:2204 +#: sl/SL_Menu.py:2506 #, python-brace-format msgid "Inactive Projects (> {weeks} weeks):" msgstr "" -#: sl/SL_Menu.py:2209 +#: sl/SL_Menu.py:2511 #, python-brace-format msgid "No projects found inactive for more than {weeks} weeks." msgstr "" -#: sl/SL_Menu.py:2218 sl/SL_Menu.py:2241 +#: sl/SL_Menu.py:2520 sl/SL_Menu.py:2543 msgid "Demote Project" msgstr "" -#: sl/SL_Menu.py:2230 +#: sl/SL_Menu.py:2532 msgid "Select Project to Demote" msgstr "" -#: sl/SL_Menu.py:2236 +#: sl/SL_Menu.py:2538 msgid "No other projects available to demote into." msgstr "" -#: sl/SL_Menu.py:2239 +#: sl/SL_Menu.py:2541 #, python-brace-format msgid "This will convert '{src}' into a task of '{dst}'." msgstr "" -#: sl/SL_Menu.py:2264 +#: sl/SL_Menu.py:2566 msgid "Projects with only closed or no tasks:" msgstr "" -#: sl/SL_Menu.py:2268 +#: sl/SL_Menu.py:2570 msgid "No completed projects found." msgstr "" -#: sl/SL_Menu.py:2277 sl/SL_Menu.py:2411 sl/SL_Menu.py:2707 +#: sl/SL_Menu.py:2579 sl/SL_Menu.py:2713 sl/SL_Menu.py:3013 msgid "Step 1: Select Project" msgstr "" -#: sl/SL_Menu.py:2281 sl/SL_Menu.py:2599 +#: sl/SL_Menu.py:2583 sl/SL_Menu.py:2905 msgid "No open projects found. Please add one first." msgstr "" -#: sl/SL_Menu.py:2286 sl/SL_Menu.py:2418 sl/SL_Menu.py:2647 +#: sl/SL_Menu.py:2588 sl/SL_Menu.py:2720 sl/SL_Menu.py:2953 msgid "Project" msgstr "" -#: sl/SL_Menu.py:2288 sl/SL_Menu.py:2419 sl/SL_Menu.py:2443 sl/SL_Menu.py:2719 +#: sl/SL_Menu.py:2590 sl/SL_Menu.py:2721 sl/SL_Menu.py:2745 sl/SL_Menu.py:3025 msgid "Next" msgstr "" -#: sl/SL_Menu.py:2303 sl/SL_Menu.py:2733 +#: sl/SL_Menu.py:2605 sl/SL_Menu.py:3039 msgid "No project selected. Please start again." msgstr "" -#: sl/SL_Menu.py:2308 +#: sl/SL_Menu.py:2610 msgid "To Project:" msgstr "" -#: sl/SL_Menu.py:2329 +#: sl/SL_Menu.py:2631 msgid "Name of the new task" msgstr "" -#: sl/SL_Menu.py:2335 +#: sl/SL_Menu.py:2637 msgid "Due date" msgstr "" -#: sl/SL_Menu.py:2341 sl/SL_Menu.py:2513 +#: sl/SL_Menu.py:2643 sl/SL_Menu.py:2815 msgid "Recurring" msgstr "" -#: sl/SL_Menu.py:2357 sl/SL_Menu.py:2528 +#: sl/SL_Menu.py:2659 sl/SL_Menu.py:2830 msgid "daily" msgstr "" -#: sl/SL_Menu.py:2357 sl/SL_Menu.py:2528 +#: sl/SL_Menu.py:2659 sl/SL_Menu.py:2830 msgid "monthly" msgstr "" -#: sl/SL_Menu.py:2357 sl/SL_Menu.py:2528 +#: sl/SL_Menu.py:2659 sl/SL_Menu.py:2830 msgid "on all business days" msgstr "" -#: sl/SL_Menu.py:2357 sl/SL_Menu.py:2528 +#: sl/SL_Menu.py:2659 sl/SL_Menu.py:2830 msgid "userdefined" msgstr "" -#: sl/SL_Menu.py:2357 sl/SL_Menu.py:2528 +#: sl/SL_Menu.py:2659 sl/SL_Menu.py:2830 msgid "weekly" msgstr "" -#: sl/SL_Menu.py:2361 sl/SL_Menu.py:2537 +#: sl/SL_Menu.py:2663 sl/SL_Menu.py:2839 msgid "Frequency" msgstr "" -#: sl/SL_Menu.py:2365 sl/SL_Menu.py:2540 +#: sl/SL_Menu.py:2667 sl/SL_Menu.py:2842 msgid "Days" msgstr "" -#: sl/SL_Menu.py:2369 sl/SL_Menu.py:2542 +#: sl/SL_Menu.py:2671 sl/SL_Menu.py:2844 msgid "Edit" msgstr "" -#: sl/SL_Menu.py:2369 sl/SL_Menu.py:2542 +#: sl/SL_Menu.py:2671 sl/SL_Menu.py:2844 msgid "Preview" msgstr "" -#: sl/SL_Menu.py:2374 sl/SL_Menu.py:2547 +#: sl/SL_Menu.py:2676 sl/SL_Menu.py:2849 msgid "No notes provided." msgstr "" -#: sl/SL_Menu.py:2379 sl/SL_Menu.py:2552 +#: sl/SL_Menu.py:2681 sl/SL_Menu.py:2854 msgid "A due date is required for recurring tasks." msgstr "" -#: sl/SL_Menu.py:2387 +#: sl/SL_Menu.py:2689 msgid "Please enter a name." msgstr "" -#: sl/SL_Menu.py:2399 +#: sl/SL_Menu.py:2701 #, python-brace-format msgid "Task '{sub_name}' added to '{main_name}'." msgstr "" -#: sl/SL_Menu.py:2431 sl/SL_Menu.py:2738 +#: sl/SL_Menu.py:2733 sl/SL_Menu.py:3044 msgid "Step 2: Select Task from" msgstr "" -#: sl/SL_Menu.py:2434 +#: sl/SL_Menu.py:2736 msgid "No open tasks found." msgstr "" -#: sl/SL_Menu.py:2462 +#: sl/SL_Menu.py:2764 msgid "Task not found." msgstr "" -#: sl/SL_Menu.py:2556 +#: sl/SL_Menu.py:2858 msgid "Save Changes" msgstr "" -#: sl/SL_Menu.py:2575 +#: sl/SL_Menu.py:2881 msgid "Task updated successfully." msgstr "" -#: sl/SL_Menu.py:2581 +#: sl/SL_Menu.py:2887 msgid "Error: Could not update task." msgstr "" -#: sl/SL_Menu.py:2594 +#: sl/SL_Menu.py:2900 msgid "Start Work on Task" msgstr "" -#: sl/SL_Menu.py:2609 +#: sl/SL_Menu.py:2915 #, python-brace-format msgid "No open tasks to start work on in '{name}'." msgstr "" -#: sl/SL_Menu.py:2620 +#: sl/SL_Menu.py:2926 msgid "Start Work" msgstr "" -#: sl/SL_Menu.py:2626 +#: sl/SL_Menu.py:2932 #, python-brace-format msgid "Work started on '{task_name}' in project '{main_name}'." msgstr "" -#: sl/SL_Menu.py:2630 +#: sl/SL_Menu.py:2936 msgid "Error starting work." msgstr "" -#: sl/SL_Menu.py:2648 +#: sl/SL_Menu.py:2954 msgid "Task" msgstr "" -#: sl/SL_Menu.py:2649 +#: sl/SL_Menu.py:2955 msgid "Started at" msgstr "" -#: sl/SL_Menu.py:2650 tt/TimeTracker.py:1454 +#: sl/SL_Menu.py:2956 tt/TimeTracker.py:1912 msgid "Duration" msgstr "" -#: sl/SL_Menu.py:2664 sl/SL_Menu.py:2796 +#: sl/SL_Menu.py:2970 sl/SL_Menu.py:3102 msgid "Select Date" msgstr "" -#: sl/SL_Menu.py:2665 sl/SL_Menu.py:2689 sl/SL_Menu.py:2753 sl/SL_Menu.py:2779 -#: sl/SL_Menu.py:2797 +#: sl/SL_Menu.py:2971 sl/SL_Menu.py:2995 sl/SL_Menu.py:3059 sl/SL_Menu.py:3085 +#: sl/SL_Menu.py:3103 msgid "Generate Report" msgstr "" -#: sl/SL_Menu.py:2685 +#: sl/SL_Menu.py:2991 msgid "Start Date" msgstr "" -#: sl/SL_Menu.py:2687 +#: sl/SL_Menu.py:2993 msgid "End Date" msgstr "" -#: sl/SL_Menu.py:2693 +#: sl/SL_Menu.py:2999 msgid "Error: The start date cannot be after the end date." msgstr "" -#: sl/SL_Menu.py:2812 +#: sl/SL_Menu.py:3118 msgid "Report Result" msgstr "" -#: sl/SL_Menu.py:2841 +#: sl/SL_Menu.py:3147 msgid "Export Report" msgstr "" -#: tt/TimeTracker.py:91 +#: tt/TimeTracker.py:191 #, python-brace-format msgid "Warning: Could not read {file}. Error: {error}" msgstr "" -#: tt/TimeTracker.py:107 +#: tt/TimeTracker.py:207 msgid "Some required packages are missing. Attempting to install them..." msgstr "" -#: tt/TimeTracker.py:110 +#: tt/TimeTracker.py:210 #, python-brace-format msgid "Installing {package}..." msgstr "" -#: tt/TimeTracker.py:114 +#: tt/TimeTracker.py:217 #, python-brace-format msgid "Failed to install {package}. Continuing without it." msgstr "" -#: tt/TimeTracker.py:118 +#: tt/TimeTracker.py:220 +#, python-brace-format +msgid "" +"Timed out installing {package} (no internet connection?). Continuing without " +"it." +msgstr "" + +#: tt/TimeTracker.py:224 msgid "" "\n" "Dependencies installed successfully." msgstr "" -#: tt/TimeTracker.py:119 +#: tt/TimeTracker.py:225 msgid "Please restart the application for the changes to take effect." msgstr "" -#: tt/TimeTracker.py:122 +#: tt/TimeTracker.py:228 #, python-brace-format msgid "" "\n" "Warning: Some dependencies could not be installed: {packages}" msgstr "" -#: tt/TimeTracker.py:124 +#: tt/TimeTracker.py:230 #, python-brace-format msgid "An unexpected error occurred during dependency check: {error}" msgstr "" -#: tt/TimeTracker.py:251 +#: tt/TimeTracker.py:452 msgid "Info: Report content has been copied to the clipboard." msgstr "" -#: tt/TimeTracker.py:253 +#: tt/TimeTracker.py:454 #, python-brace-format msgid "Warning: Could not copy to clipboard. Error: {error}" msgstr "" -#: tt/TimeTracker.py:255 +#: tt/TimeTracker.py:456 msgid "" "Warning: Could not copy to clipboard. Please install 'pyperclip' (`pip " "install pyperclip`)." msgstr "" -#: tt/TimeTracker.py:275 +#: tt/TimeTracker.py:476 #, python-brace-format msgid "{hours} hours ({dlp} DLP)" msgstr "" -#: tt/TimeTracker.py:877 tt/TimeTracker.py:917 +#: tt/TimeTracker.py:1218 tt/TimeTracker.py:1263 #, python-brace-format msgid "Source main project '{name}' not found." msgstr "" -#: tt/TimeTracker.py:879 +#: tt/TimeTracker.py:1220 #, python-brace-format msgid "Destination main project '{name}' not found." msgstr "" -#: tt/TimeTracker.py:891 +#: tt/TimeTracker.py:1237 #, python-brace-format msgid "Task '{task_name}' moved successfully." msgstr "" -#: tt/TimeTracker.py:892 tt/TimeTracker.py:927 tt/TimeTracker.py:1416 +#: tt/TimeTracker.py:1238 tt/TimeTracker.py:1273 tt/TimeTracker.py:1874 #, python-brace-format msgid "Task '{task_name}' not found in '{main_name}'." msgstr "" -#: tt/TimeTracker.py:911 +#: tt/TimeTracker.py:1257 #, python-brace-format msgid "A main project named '{name}' already exists." msgstr "" -#: tt/TimeTracker.py:936 +#: tt/TimeTracker.py:1305 msgid "General" msgstr "" -#: tt/TimeTracker.py:940 +#: tt/TimeTracker.py:1343 #, python-brace-format msgid "Task '{task_name}' was promoted to a new main project." msgstr "" -#: tt/TimeTracker.py:967 +#: tt/TimeTracker.py:1370 #, python-brace-format msgid "Main project to demote '{name}' not found." msgstr "" -#: tt/TimeTracker.py:969 +#: tt/TimeTracker.py:1372 #, python-brace-format msgid "New parent main project '{name}' not found." msgstr "" -#: tt/TimeTracker.py:994 +#: tt/TimeTracker.py:1427 #, python-brace-format msgid "" "Main project '{demoted_name}' was demoted to a sub-project under " "'{parent_name}'." msgstr "" -#: tt/TimeTracker.py:1076 +#: tt/TimeTracker.py:1521 msgid "Email import is not enabled." msgstr "" -#: tt/TimeTracker.py:1085 +#: tt/TimeTracker.py:1530 msgid "Email settings are incomplete." msgstr "" -#: tt/TimeTracker.py:1098 +#: tt/TimeTracker.py:1543 msgid "Error searching emails." msgstr "" -#: tt/TimeTracker.py:1113 +#: tt/TimeTracker.py:1558 msgid "No Subject" msgstr "" -#: tt/TimeTracker.py:1173 +#: tt/TimeTracker.py:1631 msgid "Unknown Task" msgstr "" -#: tt/TimeTracker.py:1373 +#: tt/TimeTracker.py:1831 #, python-brace-format msgid "- {name}: {hours} hours" msgstr "" -#: tt/TimeTracker.py:1381 +#: tt/TimeTracker.py:1839 #, python-brace-format msgid "## {name} ({hours} hours)\n" msgstr "" -#: tt/TimeTracker.py:1390 +#: tt/TimeTracker.py:1848 #, python-brace-format msgid "# Daily Time Report: {date}\n" msgstr "" -#: tt/TimeTracker.py:1391 +#: tt/TimeTracker.py:1849 #, python-brace-format msgid "" "\n" "**Total Daily Time: {hours} hours**" msgstr "" -#: tt/TimeTracker.py:1395 tt/TimeTracker.py:1708 +#: tt/TimeTracker.py:1853 tt/TimeTracker.py:2166 #, python-brace-format msgid "No time tracked for {date}." msgstr "" -#: tt/TimeTracker.py:1412 tt/TimeTracker.py:1507 +#: tt/TimeTracker.py:1870 tt/TimeTracker.py:1965 #, python-brace-format msgid "Main project '{name}' not found." msgstr "" -#: tt/TimeTracker.py:1420 +#: tt/TimeTracker.py:1878 #, python-brace-format msgid "No time entries found for task '{task_name}'." msgstr "" -#: tt/TimeTracker.py:1453 tt/TimeTracker.py:1683 +#: tt/TimeTracker.py:1911 tt/TimeTracker.py:2141 msgid "now" msgstr "" -#: tt/TimeTracker.py:1458 +#: tt/TimeTracker.py:1916 #, python-brace-format msgid "# Detailed Report for Task: {name}" msgstr "" -#: tt/TimeTracker.py:1459 +#: tt/TimeTracker.py:1917 #, python-brace-format msgid "Part of Main Project: {name}" msgstr "" -#: tt/TimeTracker.py:1462 +#: tt/TimeTracker.py:1920 msgid "Active (currently running)" msgstr "" -#: tt/TimeTracker.py:1462 tt/TimeTracker.py:1559 +#: tt/TimeTracker.py:1920 tt/TimeTracker.py:2017 msgid "Inactive" msgstr "" -#: tt/TimeTracker.py:1463 tt/TimeTracker.py:1560 +#: tt/TimeTracker.py:1921 tt/TimeTracker.py:2018 msgid "Status" msgstr "" -#: tt/TimeTracker.py:1465 tt/TimeTracker.py:1562 +#: tt/TimeTracker.py:1923 tt/TimeTracker.py:2020 msgid "First entry" msgstr "" -#: tt/TimeTracker.py:1467 tt/TimeTracker.py:1564 +#: tt/TimeTracker.py:1925 tt/TimeTracker.py:2022 msgid "Last activity" msgstr "" -#: tt/TimeTracker.py:1469 tt/TimeTracker.py:1566 +#: tt/TimeTracker.py:1927 tt/TimeTracker.py:2024 msgid "Total recorded time" msgstr "" -#: tt/TimeTracker.py:1470 tt/TimeTracker.py:1568 +#: tt/TimeTracker.py:1928 tt/TimeTracker.py:2026 msgid "Total work sessions" msgstr "" -#: tt/TimeTracker.py:1474 tt/TimeTracker.py:1572 +#: tt/TimeTracker.py:1932 tt/TimeTracker.py:2030 msgid "Average session duration" msgstr "" -#: tt/TimeTracker.py:1477 tt/TimeTracker.py:1575 +#: tt/TimeTracker.py:1935 tt/TimeTracker.py:2033 msgid "Weekday Distribution" msgstr "" -#: tt/TimeTracker.py:1487 +#: tt/TimeTracker.py:1945 msgid "Daily Breakdown" msgstr "" -#: tt/TimeTracker.py:1556 +#: tt/TimeTracker.py:2014 #, python-brace-format msgid "# Detailed Report for Main Project: {name}" msgstr "" -#: tt/TimeTracker.py:1559 +#: tt/TimeTracker.py:2017 #, python-brace-format msgid "Active (working on '{task_name}')" msgstr "" -#: tt/TimeTracker.py:1567 +#: tt/TimeTracker.py:2025 msgid "Number of tasks" msgstr "" -#: tt/TimeTracker.py:1586 +#: tt/TimeTracker.py:2044 msgid "Task Breakdown" msgstr "" -#: tt/TimeTracker.py:1595 +#: tt/TimeTracker.py:2053 #, python-brace-format msgid "{num_sessions} sessions" msgstr "" -#: tt/TimeTracker.py:1648 +#: tt/TimeTracker.py:2106 #, python-brace-format msgid "# Time Report: {start_date} to {end_date}\n" msgstr "" -#: tt/TimeTracker.py:1649 +#: tt/TimeTracker.py:2107 #, python-brace-format msgid "" "\n" "**Total Time in Period: {total_time}**" msgstr "" -#: tt/TimeTracker.py:1653 +#: tt/TimeTracker.py:2111 #, python-brace-format msgid "No time tracked between {start_date} and {end_date}." msgstr "" -#: tt/TimeTracker.py:1669 +#: tt/TimeTracker.py:2127 #, python-brace-format msgid "# Detailed Daily Report: {date}" msgstr "" -#: update.py:35 +#: update.py:101 msgid "" "Warning: Update check skipped. 'github_repo' not found in config.json or " "file is invalid." msgstr "" -#: update.py:55 +#: update.py:121 msgid "Error: Download URL for the new version not found." msgstr "" -#: update.py:59 +#: update.py:125 +msgid "Warning: Update check timed out (no internet connection?). Skipping." +msgstr "" + +#: update.py:127 #, python-brace-format msgid "Error checking for updates: {error}" msgstr "" -#: update.py:61 +#: update.py:129 #, python-brace-format msgid "An unexpected error occurred while checking for updates: {error}" msgstr "" -#: update.py:73 +#: update.py:141 msgid "Downloading update..." msgstr "" -#: update.py:79 +#: update.py:147 msgid "Download complete. The update will be installed on the next start." msgstr "" -#: update.py:82 +#: update.py:150 +msgid "" +"Error: Connecting to the update server timed out (no internet connection?)." +msgstr "" + +#: update.py:155 #, python-brace-format msgid "Error downloading the update: {error}" msgstr "" -#: update.py:98 +#: update.py:171 msgid "Restarting application to apply the update..." msgstr "" -#: update.py:122 +#: update.py:195 msgid "Creating backup of current version before update..." msgstr "" -#: update.py:132 +#: update.py:205 #, python-brace-format msgid "Backup created successfully as {filename}." msgstr "" -#: update.py:134 +#: update.py:207 #, python-brace-format msgid "Warning: Could not create backup. Error: {error}" msgstr "" -#: update.py:136 +#: update.py:209 msgid "Installing update..." msgstr "" -#: update.py:156 +#: update.py:229 #, python-brace-format msgid "Skipping protected file: {filename}. It will not be overwritten." msgstr "" -#: update.py:165 +#: update.py:238 msgid "Update installed successfully." msgstr "" -#: update.py:167 +#: update.py:240 #, python-brace-format msgid "Error during update installation: {error}" msgstr "" -#: update.py:182 +#: update.py:255 #, python-brace-format msgid "Error: No previous version backup '{filename}' found." msgstr "" -#: update.py:185 +#: update.py:258 #, python-brace-format msgid "Restoring previous version from '{filename}'..." msgstr "" -#: update.py:206 +#: update.py:279 #, python-brace-format msgid "" "Skipping user data file: {filename}. It will not be overwritten during " "restore." msgstr "" -#: update.py:213 +#: update.py:286 msgid "Previous version restored successfully." msgstr "" -#: update.py:215 +#: update.py:288 msgid "Restarting application to apply changes..." msgstr "" -#: update.py:218 +#: update.py:291 #, python-brace-format msgid "Error during restoration: {error}" msgstr "" -#: update.py:219 +#: update.py:292 #, python-brace-format msgid "The backup file '{filename}' was not deleted." msgstr "" -#: update.py:226 +#: update.py:299 msgid "Error: Could not import TimeTracker to get the current version." msgstr "" -#: update.py:229 +#: update.py:302 msgid "Checking for updates..." msgstr "" -#: update.py:236 +#: update.py:309 msgid "No updates available." msgstr "" diff --git a/php-server/README.md b/php-server/README.md new file mode 100644 index 0000000..13fdf14 --- /dev/null +++ b/php-server/README.md @@ -0,0 +1,172 @@ +# TimeControl sync server – PHP implementation + +A small PHP service so one person can synchronise their `data.json` between +their own machines. It stores everything in files, needs no database, and is +built for a plain shared webspace with FTP access and no shell. + +Those constraints are what shaped it, and they are the reason this lives +under `php-server/` rather than `server/`: a different implementation, freed +from "no database" and "no long-running process", would look substantially +different while speaking the same protocol to the same client. + +This directory holds two things: + +- **`tc/`** – the server itself. This is what gets uploaded. +- **`tcprobe/`** – a throwaway diagnostic that measures whether a given + host is suitable. Worth re-running after a PHP version change or a hosting + migration, because those are exactly when `.htaccess` handling and file + permissions get rewritten underneath you. + +## What is implemented so far + +Authentication and the operation log. What is *not* here yet: compaction, so +the log grows without bound, and a machine that has been away for a long time +has to replay everything rather than fetching a snapshot. + +| Action | Method | Purpose | +|---|---|---| +| `?a=login` | POST | username + password + device id → token | +| `?a=ping` | GET | proves a token is still valid | +| `?a=logout` | GET | revokes the token that was presented | +| `?a=head` | GET | current sequence number – the cheap poll | +| `?a=push` | POST | submit operations, and receive everything newer | +| `?a=pull` | GET | catch up from a given sequence number | + +### How the log works + +Clients exchange intentions, not documents: twelve operation types +(`project.create`, `task.set`, `entry.close`, …), each naming the +entity by the `uid` the client generated. `*.set` carries only the fields +that actually changed, so two machines editing different attributes of the +same task both keep their change. + +**The server's sequence number is the only ordering.** No vector clocks, no +comparing wall clocks – the timestamps this app records are naive local +time and the two machines are allowed to disagree by minutes. "Last writer +wins" means "last to reach the server", which both replicas compute +identically. Timestamps travel along for display and are never consulted for +conflict resolution. + +`push` and `pull` are one round trip: submitting work and learning what +happened elsewhere are the same conversation. A push does not echo the +caller's own operations back – it already holds their bodies and only +needs to be told which sequence numbers they were given. + +**Repeating a push is safe.** Each operation carries a counter the client +never reuses; the server keeps the high-water mark per device and reports +anything at or below it as a duplicate instead of appending it again. A push +whose response was lost can simply be sent again. + +The device an operation is credited to comes from the token, never from the +request body – otherwise one device could move another's duplicate +counter and make that device's retries disappear. + +Requests carry the credential in an `X-TC-Token` header; `Authorization: +Bearer` is accepted as an alternative but not relied upon, because some hosts +strip it before PHP sees it. + +Every failure returns a stable `error` code alongside the human message, so a +client can tell "your token is gone, sign in again" (`invalid_token`) apart +from a network problem it should simply retry. + +## Installing + +**1. Upload.** Copy the contents of `tc/` into a directory on the web space, +for example `/tc/`. + +**2. Check that both `.htaccess` files actually arrived.** There are two, in +two different directories: + + /tc/.htaccess + /tc/lib/.htaccess + +Most FTP clients hide names beginning with a dot and will skip them without +saying so – turn on "show hidden files" before uploading, or upload them +under a temporary name and rename them on the server. The one in `/tc/` is +what keeps the next step's passphrase from being served to the world. + +To confirm afterwards, open `https:///tc/lib/store.php` in a browser. +It must say **Forbidden**. A blank page means the file is being executed +instead of blocked, and the `.htaccess` did not arrive. + +**3. Open the setup window.** Create a file `setup.enable` next to +`setup.php`, containing a passphrase of your choosing (12 characters or +more). Any text editor will do. + +Write access to the directory is what proves you are the operator. It is the +one capability guaranteed on every shared host – it is how the code got +there – and it needs no shell, no cron and no admin account. + +**4. Install.** Open `https:///tc/setup.php`, enter the passphrase and +press *Install*. Without `setup.enable`, or over plain HTTP, the page is a +bare 404 and does nothing. + +Setup picks a location for the store, preferring one above the document root +and falling back to a randomly named directory inside the web space. It then +**proves** the store cannot be fetched over the web by writing a marker file +and trying to retrieve it. If the marker comes back, or if the check cannot +be completed, nothing is installed and it tells you why. Protection is +demonstrated, never assumed. + +**5. Create an account.** `setup.enable` is deleted after every change, so +upload it again, then use *Create an account*. + +**6. Check.** *Show status* lists the store path, the accounts and the number +of live tokens. It does not consume `setup.enable`. + +## Things worth knowing + +**TLS is mandatory.** A bearer token is worth exactly as much as the channel +carrying it, so both entry points refuse plain HTTP before looking at any +credential. + +**Tokens expire** 90 days after being issued and 30 days after last use. The +absolute limit is the only thing that ever ends a compromise nobody noticed: +a copied credential shows up as the device that is legitimately there +already, so there is no new entry to spot. + +**Signing in again from the same device replaces that device's token** rather +than adding one. A client whose response got lost can simply retry. + +**Password checking is rate limited to 30 attempts per minute in total** – +one global budget, not one per account or per IP address. Per-account +counters let anyone lock you out by name, and per-IP counters let an attacker +fill the disk with small files. The trade-off is real and deliberate: while +the budget is exhausted, your own sign-in is refused too, for up to a minute. + +**Stored files carry a PHP guard line.** If a directory's `.htaccess` ever +stops being honoured, the files are executed rather than served and yield +nothing. The marker file used during installation deliberately does *not* +carry it – a guarded file would report "protected" whether or not +`.htaccess` works, which would be the check confirming itself. + +**`setup.php` checks that its own passphrase file is unreachable** before it +will do anything, by fetching `setup.enable` the way an outsider would. If +the passphrase comes back it stops and tells you to treat it as compromised. +This exists because step 2 is exactly the kind of step that gets skipped, and +a protection that is merely assumed is not one. + +**Permissions are set explicitly, never left to the umask.** The probe found +new files arriving as `0640` with a group id shared with other customers, so +every write chmods to `0600` and every directory to `0700`. + +**Locking is used to serialise, never for integrity.** `flock()` reported +success on the target host, but that does not prove it locks – silently +doing nothing is a known behaviour on NFS-backed storage and cannot be +disproved from a single request. Integrity comes from writing to a temporary +file and renaming it into place, which is atomic. + +**An interrupted append is discarded, not adopted.** The log is written +before the pointer describing it, so a request killed by the execution limit +leaves a segment holding more bytes than the state file admits to, possibly +ending mid-line. Readers ignore anything past the recorded length, and the +next write truncates it away. Keeping those bytes would mean one sequence +number standing for different content on different machines, which nothing +could later repair – and the client never got its acknowledgement, so +it will send the same operations again and the duplicate counter makes that +land exactly once. + +## Removing an installation + +Delete the `tc/` directory and the store. The store path is recorded in +`tc/config.php`; *Show status* prints it. diff --git a/php-server/check-login.sh b/php-server/check-login.sh new file mode 100755 index 0000000..1fbee93 --- /dev/null +++ b/php-server/check-login.sh @@ -0,0 +1,37 @@ +#!/bin/sh +# +# Signs in against the sync server, proves the token works, signs out again, +# and checks the token really stopped working. +# +# Uses curl rather than Python's urllib on purpose: a python.org install on +# macOS ships with an empty certificate store until "Install Certificates" +# has been run, so urllib cannot verify TLS there while curl - which uses the +# system store - can. Python is used only to build and read JSON, never to +# make the request. The client itself is unaffected: it uses requests, which +# brings its own certificate bundle. +# +# The password is read without echo and never appears in the command line or +# the shell history. +URL=https://www.familiefaulstich.de/tc/index.php +printf 'Kontoname [frank]: '; read TCUSER; [ -z "$TCUSER" ] && TCUSER=frank +printf 'Kontopasswort: '; stty -echo; read TCPW; stty echo; echo + +DEV=$(python3 -c 'import secrets;print(secrets.token_hex(8))') +BODY=$(TCUSER="$TCUSER" TCPW="$TCPW" DEV="$DEV" python3 -c ' +import json,os +print(json.dumps({"username":os.environ["TCUSER"],"password":os.environ["TCPW"], + "device_uid":os.environ["DEV"],"device_name":"test"}))') + +RESP=$(printf '%s' "$BODY" | curl -s -m 20 -X POST \ + -H 'Content-Type: application/json' --data-binary @- "$URL?a=login") +TOK=$(printf '%s' "$RESP" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("token",""))') + +if [ -z "$TOK" ]; then + echo "anmelden : FEHLGESCHLAGEN -> $RESP" +else + echo "anmelden : ok" + echo "ping : $(curl -s -m 20 -H "X-TC-Token: $TOK" "$URL?a=ping")" + echo "abmelden : $(curl -s -m 20 -H "X-TC-Token: $TOK" "$URL?a=logout")" + echo "danach : $(curl -s -m 20 -H "X-TC-Token: $TOK" "$URL?a=ping")" +fi +unset TCPW BODY TOK diff --git a/php-server/check-oplog.py b/php-server/check-oplog.py new file mode 100755 index 0000000..2c8ee61 --- /dev/null +++ b/php-server/check-oplog.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +""" +Exercises the operation log against a running server. + +Run this against a THROWAWAY account, not your real one - it writes +operations into that account's log, and the log is not something you can +selectively clean up afterwards. Create one in setup.php, run this, delete it +again in setup.php. + +Uses requests rather than urllib on purpose: a python.org install on macOS +ships with an empty certificate store until "Install Certificates" has been +run, and urllib then cannot verify TLS at all. requests brings its own +bundle, which is also why the sync client will use it. +""" + +import getpass +import json +import secrets +import sys + +try: + import requests +except ImportError: + sys.exit("requests is missing - pip install -r requirements.txt") + +BASE = "https://www.familiefaulstich.de/tc/index.php" + +passed = 0 +failed = 0 + + +def check(label, condition, detail=""): + global passed, failed + if condition: + passed += 1 + print(" ok %s" % label) + else: + failed += 1 + print(" FAIL %s %s" % (label, detail)) + + +def call(action, payload=None, token=None, params=None): + headers = {"Content-Type": "application/json"} + if token: + headers["X-TC-Token"] = token + query = {"a": action} + query.update(params or {}) + if payload is None: + r = requests.get(BASE, params=query, headers=headers, timeout=30) + else: + r = requests.post(BASE, params=query, headers=headers, + data=json.dumps(payload), timeout=30) + try: + return r.json() + except ValueError: + return {"ok": False, "error": "not_json", "raw": r.text[:200], "status": r.status_code} + + +def main(): + user = input("Throwaway account name: ").strip() + if not user: + sys.exit("No account given.") + password = getpass.getpass("Password: ") + + dev_a, dev_b = secrets.token_hex(8), secrets.token_hex(8) + + def login(device, name): + r = call("login", {"username": user, "password": password, + "device_uid": device, "device_name": name}) + if not r.get("ok"): + sys.exit("Sign-in failed: %s" % r.get("error", r)) + return r["token"] + + print("\nSigning in two devices") + a, b = login(dev_a, "laptop"), login(dev_b, "desktop") + start = call("head", token=a)["head"] + print(" log starts at sequence %d" % start) + + uid_p, uid_t, uid_e = (secrets.token_hex(8) for _ in range(3)) + + print("\nA submits three operations") + r = call("push", {"base_seq": start, "ops": [ + {"op": "project.create", "uid": uid_p, "f": {"name": "Probe"}, + "ts": "2026-08-09T10:00:00", "lc": 1}, + {"op": "task.create", "uid": uid_t, "project": uid_p, + "f": {"task_name": "Entwurf", "priority": 5}, "ts": "2026-08-09T10:01:00", "lc": 2}, + {"op": "entry.add", "uid": uid_e, "task": uid_t, + "start": "2026-08-09T10:02:00", "lc": 3}, + ]}, token=a) + check("accepted", r.get("ok"), r.get("error", "")) + check("three sequence numbers handed out", len(r.get("assigned", [])) == 3, r.get("assigned")) + check("own operations not echoed back", r.get("ops") == [], r.get("ops")) + after_a = r["head"] + + print("\nB catches up") + r = call("pull", token=b, params={"since": start}) + ops = r.get("ops", []) + check("sees all three", len(ops) == 3, len(ops)) + check("in the right order", + [o["op"] for o in ops] == ["project.create", "task.create", "entry.add"], + [o.get("op") for o in ops]) + check("credited to A's device", all(o["dev"] == dev_a for o in ops)) + + print("\nB changes the same task") + r = call("push", {"base_seq": after_a, "ops": [ + {"op": "task.set", "uid": uid_t, "f": {"priority": 9}, "ts": "x", "lc": 1}]}, token=b) + check("accepted", r.get("ok"), r.get("error", "")) + after_b = r["head"] + check("sequence advanced by one", after_b == after_a + 1, (after_a, after_b)) + + print("\nA picks up B's change") + r = call("push", {"base_seq": after_a, "ops": []}, token=a) + ops = r.get("ops", []) + check("exactly one foreign operation", len(ops) == 1, len(ops)) + check("it is the priority change", + ops and ops[0]["op"] == "task.set" and ops[0]["f"] == {"priority": 9}, + ops[0] if ops else None) + + print("\nB repeats its push (a lost response)") + r = call("push", {"base_seq": after_b, "ops": [ + {"op": "task.set", "uid": uid_t, "f": {"priority": 9}, "ts": "x", "lc": 1}]}, token=b) + check("reported as a duplicate", r.get("dups") == [1], r.get("dups")) + check("nothing appended", r.get("assigned") == [], r.get("assigned")) + check("sequence did not move", r.get("head") == after_b, (after_b, r.get("head"))) + + print("\nMalformed input is refused") + for label, ops_in, expected in [ + ("unknown verb", [{"op": "task.explode", "uid": uid_t, "lc": 50}], "unknown_op"), + ("uid with path characters", [{"op": "task.set", "uid": "../../etc", "lc": 51}], "bad_uid"), + ("missing counter", [{"op": "task.set", "uid": uid_t}], "bad_lc"), + ]: + r = call("push", {"base_seq": after_b, "ops": ops_in}, token=a) + check(label, r.get("error") == expected, r.get("error")) + + print("\nThe device is taken from the token, not the body") + r = call("push", {"base_seq": after_b, "ops": [ + {"op": "task.set", "uid": uid_t, "f": {"priority": 1}, "dev": dev_a, "lc": 900}]}, token=b) + seq = r["assigned"][0][1] + found = [o for o in call("pull", token=a, params={"since": seq - 1})["ops"] if o["s"] == seq] + check("body's claim ignored", found and found[0]["dev"] == dev_b, + found[0]["dev"][:8] if found else None) + + print("\nSigning both devices out") + call("logout", token=a) + call("logout", token=b) + check("token no longer works", call("head", token=a).get("error") == "invalid_token") + + print("\n%d passed, %d failed" % (passed, failed)) + if failed: + print("\nThe account still holds these test operations. Delete the account\n" + "in setup.php to remove them.") + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/php-server/check-sync-apply.py b/php-server/check-sync-apply.py new file mode 100644 index 0000000..7d570c3 --- /dev/null +++ b/php-server/check-sync-apply.py @@ -0,0 +1,301 @@ +#!/usr/bin/env python3 +""" +Two machines against the running server, using the real applier. + +The unit tests prove the merge rules in isolation. This proves the same code +against the real op log: that what one machine sends is what the other can +rebuild, that concurrent edits land the same way on both, and that a task +deleted on one machine takes its recorded hours with it on the other too. + +Run this against a THROWAWAY account, not your real one - it writes into that +account's log, which cannot be selectively cleaned up afterwards. Create one +in setup.php, run this, delete it again in setup.php. + +requests rather than urllib on purpose: a python.org install on macOS ships +with an empty certificate store until "Install Certificates" has been run, +and urllib then cannot verify TLS at all. +""" + +import copy +import getpass +import json +import os +import secrets +import sys + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +try: + import requests +except ImportError: + sys.exit("requests is missing - pip install -r requirements.txt") + +from tt.sync_apply import apply_ops, reconcile, seed_operations + +BASE = "https://www.familiefaulstich.de/tc/index.php" + +passed = 0 +failed = 0 + + +def check(label, condition, detail=""): + global passed, failed + if condition: + passed += 1 + print(" ok %s" % label) + else: + failed += 1 + print(" FAIL %s %s" % (label, detail)) + + +def call(action, payload=None, token=None, params=None): + headers = {"Content-Type": "application/json"} + if token: + headers["X-TC-Token"] = token + query = {"a": action} + query.update(params or {}) + if payload is None: + r = requests.get(BASE, params=query, headers=headers, timeout=30) + else: + r = requests.post(BASE, params=query, headers=headers, + data=json.dumps(payload), timeout=30) + try: + return r.json() + except ValueError: + return {"ok": False, "error": "not_json", "raw": r.text[:200]} + + +class Machine: + """A client: a token, a queue with its own numbering, and a document.""" + + def __init__(self, label, token): + self.label = label + self.token = token + self.lc = 0 + self.seq = 0 + self.outbox = [] + self.doc = {"projects": [], "next_id": 1, "_deleted": [], "schema_version": 2} + + def queue(self, op, **fields): + self.lc += 1 + entry = {"op": op, "lc": self.lc} + entry.update({k: v for k, v in fields.items() if v is not None}) + self.outbox.append(entry) + return entry + + def sync(self): + """One cycle: send what is queued, take in what is not, merge both.""" + sending = list(self.outbox) + if sending: + r = call("push", {"base_seq": self.seq, "ops": sending}, token=self.token) + else: + r = call("pull", token=self.token, params={"since": self.seq}) + if not r.get("ok"): + raise SystemExit("%s: sync refused: %s" % (self.label, r)) + if r.get("more"): + # These logs are a handful of operations long. If the server is + # holding some back, the comparisons below are meaningless. + raise SystemExit("%s: the log is longer than one batch - use a " + "fresh account" % self.label) + + report = reconcile(self.doc, r.get("ops", []), sending) + self.seq = max(self.seq, int(r.get("head", 0))) + self.outbox = [] + return r, report + + +def uid(): + return secrets.token_hex(8) + + +def find_task(doc, name): + for p in doc["projects"]: + for t in p.get("tasks", []): + if t["task_name"] == name: + return t + return None + + +def find_entry(doc, entry_uid): + for p in doc["projects"]: + for t in p.get("tasks", []): + for e in t.get("time_entries", []): + if e["uid"] == entry_uid: + return e, t + return None, None + + +def main(): + user = input("Throwaway account name: ").strip() + if not user: + sys.exit("No account given.") + password = getpass.getpass("Password: ") + + def login(name): + r = call("login", {"username": user, "password": password, + "device_uid": uid(), "device_name": name}) + if not r.get("ok"): + sys.exit("Sign-in failed for %s: %s" % (name, r)) + return Machine(name, r["token"]) + + a, b = login("machine-a"), login("machine-b") + print("\nSigned in twice - two devices, one account.\n") + + tag = secrets.token_hex(3) + p_uid, t_uid, t2_uid = uid(), uid(), uid() + + # -- 1. one machine fills an empty account ------------------------------- + print("1. The first machine seeds, the second rebuilds") + + a.doc = { + "projects": [{ + "uid": p_uid, "main_project_name": "Website " + tag, "status": "open", + "last_started": None, + "tasks": [{ + "uid": t_uid, "id": 1, "task_name": "Relaunch", "status": "open", + "due_date": None, "today": False, "note": "", "recurring": False, + "frequency": "daily", "userdefined_days": 1, "priority": 4, + "last_started": "2026-08-10 09:00:00", + "time_entries": [{"uid": uid(), "start_time": "2026-08-10 09:00:00", + "end_time": "2026-08-10 10:00:00"}], + }], + }], + "next_id": 2, "_deleted": [], "schema_version": 2, + } + seeded = copy.deepcopy(a.doc) + for op in seed_operations(a.doc): + a.queue(op.pop("op"), **op) + r, _ = a.sync() + check("the seed was accepted", r.get("ok") and len(r.get("assigned", [])) == 4, + str(r.get("assigned"))) + check("the document is unchanged by seeding it", a.doc["projects"] == seeded["projects"]) + + r, _ = b.sync() + check("the second machine rebuilt the same projects", + b.doc["projects"] == a.doc["projects"], + "\n got %s" % json.dumps(b.doc["projects"])[:300]) + check("and the same id counter", b.doc["next_id"] == a.doc["next_id"], + "%s vs %s" % (b.doc["next_id"], a.doc["next_id"])) + + # -- 2. concurrent edits to the same task -------------------------------- + print("\n2. Both machines edit the same task before either syncs") + + find_task(a.doc, "Relaunch")["priority"] = 8 + a.queue("task.set", uid=t_uid, f={"priority": 8}) + + task_b = find_task(b.doc, "Relaunch") + task_b["due_date"] = "2026-09-01" + task_b["priority"] = 3 + b.queue("task.set", uid=t_uid, f={"due_date": "2026-09-01"}) + b.queue("task.set", uid=t_uid, f={"priority": 3}) + + a.sync() # a reaches the server first + b.sync() # b sees a's change and puts its own after it + a.sync() # a catches up + + check("both machines agree on the task", find_task(a.doc, "Relaunch") == find_task(b.doc, "Relaunch"), + "\n a=%s\n b=%s" % (find_task(a.doc, "Relaunch"), find_task(b.doc, "Relaunch"))) + check("the later change won the field both touched", + find_task(a.doc, "Relaunch")["priority"] == 3, + str(find_task(a.doc, "Relaunch")["priority"])) + check("the change only one of them made survived", + find_task(a.doc, "Relaunch")["due_date"] == "2026-09-01") + + # -- 3. time booked against a task deleted elsewhere --------------------- + print("\n3. One machine deletes a task while the other books time on it") + + a.queue("task.create", uid=t2_uid, project=p_uid, f={"task_name": "Doomed"}) + a.sync() + b.sync() + check("both machines have the new task", + find_task(a.doc, "Doomed") and find_task(b.doc, "Doomed")) + + e_uid = uid() + # a deletes it; b, not yet knowing, starts working on it. + a.doc["_deleted"].append({"uid": t2_uid, "kind": "task", "at": "2026-08-10 12:00:00"}) + for p in a.doc["projects"]: + p["tasks"] = [t for t in p.get("tasks", []) if t["uid"] != t2_uid] + a.queue("task.delete", uid=t2_uid, ts="2026-08-10 12:00:00") + + b.queue("entry.add", uid=e_uid, task=t2_uid, start="2026-08-10 12:30:00") + b.queue("entry.close", uid=e_uid, end="2026-08-10 13:30:00") + entry_b = {"uid": e_uid, "start_time": "2026-08-10 12:30:00", + "end_time": "2026-08-10 13:30:00"} + find_task(b.doc, "Doomed")["time_entries"].append(entry_b) + + a.sync() + _, report_b = b.sync() + a.sync() + + found_b, _parent_b = find_entry(b.doc, e_uid) + found_a, _parent_a = find_entry(a.doc, e_uid) + # The hour goes with the task. That is what deleting a task has always + # done locally, and it is the only answer both machines can reach. + check("the hour went with the deleted task where it was booked", found_b is None) + check("and on the machine that deleted it", found_a is None) + check("the machine that had to discard it said so", + report_b.discarded_time == 1, str(report_b)) + check("the deleted task itself stayed deleted", + find_task(a.doc, "Doomed") is None and find_task(b.doc, "Doomed") is None) + + # -- 4. a session left running elsewhere --------------------------------- + print("\n4. Starting work on one machine ends the session left running on the other") + + running, other = uid(), uid() + a.queue("entry.add", uid=running, task=t_uid, start="2026-08-11 09:00:00") + find_task(a.doc, "Relaunch")["time_entries"].append( + {"uid": running, "start_time": "2026-08-11 09:00:00"}) + a.sync() + b.sync() + + open_on_b, _ = find_entry(b.doc, running) + check("the second machine sees it running", "end_time" not in (open_on_b or {"end_time": 1})) + + b.queue("entry.add", uid=other, task=t_uid, start="2026-08-11 10:00:00") + find_task(b.doc, "Relaunch")["time_entries"].append( + {"uid": other, "start_time": "2026-08-11 10:00:00"}) + _, report_b = b.sync() + _, report_a = a.sync() + + closed_a, _ = find_entry(a.doc, running) + closed_b, _ = find_entry(b.doc, running) + check("the earlier session was ended on both", + closed_a.get("end_time") == "2026-08-11 10:00:00" + and closed_b.get("end_time") == "2026-08-11 10:00:00", + "\n a=%s b=%s" % (closed_a.get("end_time"), closed_b.get("end_time"))) + check("ended exactly where the new one began, so no time is counted twice", + closed_a.get("end_time") == "2026-08-11 10:00:00") + check("only one session is still running", + sum(1 for p in a.doc["projects"] for t in p["tasks"] + for e in t["time_entries"] if "end_time" not in e) == 1) + + # -- 5. the documents are the same --------------------------------------- + print("\n5. After everything, the two machines hold the same document") + + a.sync() + b.sync() + check("projects match", a.doc["projects"] == b.doc["projects"], + "\n a=%s\n b=%s" % (json.dumps(a.doc["projects"])[:400], + json.dumps(b.doc["projects"])[:400])) + check("deletions match", sorted(t["uid"] for t in a.doc["_deleted"]) + == sorted(t["uid"] for t in b.doc["_deleted"])) + + # -- 6. repeating a sync must be harmless -------------------------------- + print("\n6. A lost answer means the same push arrives twice") + + before = copy.deepcopy(a.doc) + replayed = [{"op": "task.set", "lc": 1, "uid": t_uid, "f": {"priority": 3}}] + r = call("push", {"base_seq": a.seq, "ops": replayed}, token=a.token) + check("the server recognised the repeat", r.get("dups") == [1], str(r)) + reconcile(a.doc, r.get("ops", []), replayed) + check("and nothing in the document changed", a.doc["projects"] == before["projects"]) + + for m in (a, b): + call("logout", {}, token=m.token) + + print("\n%d passed, %d failed" % (passed, failed)) + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/php-server/check-sync-cycle.py b/php-server/check-sync-cycle.py new file mode 100644 index 0000000..1494422 --- /dev/null +++ b/php-server/check-sync-cycle.py @@ -0,0 +1,316 @@ +#!/usr/bin/env python3 +""" +Two machines running the real sync engine against the running server. + +check-sync-apply.py proved the merge rules against the real log using a +hand-written client. This proves the shipped engine: the queue, the cycle, +the cursor, the inbox and the applying, exactly as the app runs them - only +with the two machines' configuration directories side by side in /tmp +instead of on two computers. + +Run this against a THROWAWAY account, not your real one. It writes into that +account's log, which cannot be selectively cleaned up. Create one in +setup.php, run this, delete it again in setup.php. +""" + +import getpass +import os +import shutil +import sys +import tempfile + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +try: + import requests # noqa: F401 +except ImportError: + sys.exit("requests is missing - pip install -r requirements.txt") + +from tt import sync_client, sync_engine +from tt.sync_outbox import Outbox +from tt.TimeTracker import TimeTracker + +SERVER = "https://www.familiefaulstich.de/tc/" + +passed = 0 +failed = 0 + + +def check(label, condition, detail=""): + global passed, failed + if condition: + passed += 1 + print(" ok %s" % label) + else: + failed += 1 + print(" FAIL %s %s" % (label, detail)) + + +class Machine: + """ + One computer: its own configuration directory, its own data file, and + therefore its own device identity, queue, cursor and inbox. + """ + + def __init__(self, name, root): + self.name = name + self.config = os.path.join(root, name, 'config') + self.data = os.path.join(root, name, 'data.json') + os.makedirs(self.config, exist_ok=True) + with self: + self.tracker = TimeTracker(file_path=self.data, op_outbox=Outbox()) + + # Swapping the directory is what makes two machines out of one process. + # Everything in the engine reaches for it through this one function. + def __enter__(self): + self._saved = sync_client.config_dir + sync_client.config_dir = lambda: self.config + return self + + def __exit__(self, *exc): + sync_client.config_dir = self._saved + return False + + def sign_in(self, user, password): + with self: + return sync_client.login(SERVER, user, password) + + def sync(self): + """One full round: the worker's half, then the interface's half.""" + with self: + outcome = sync_engine.run_cycle(self.tracker.op_outbox) + summary = sync_engine.apply_pending(self.tracker) + return outcome, summary + + def offer(self): + with self: + return sync_engine.offer_document(self.tracker) + + def state(self): + with self: + return sync_engine.read_state() + + def reopen(self): + """As if the application had been restarted on this machine.""" + with self: + self.tracker = TimeTracker(file_path=self.data, op_outbox=Outbox()) + + +def task_of(machine, name): + for project in machine.tracker.data['projects']: + for task in project.get('tasks', []): + if task['task_name'] == name: + return task + return None + + +def entry_of(machine, uid): + for project in machine.tracker.data['projects']: + for task in project.get('tasks', []): + for entry in task.get('time_entries', []): + if entry['uid'] == uid: + return entry, task + return None, None + + +def running_entries(machine): + return [e for p in machine.tracker.data['projects'] + for t in p.get('tasks', []) + for e in t.get('time_entries', []) if 'end_time' not in e] + + +def main(): + user = input("Throwaway account name: ").strip() + if not user: + sys.exit("No account given.") + password = getpass.getpass("Password: ") + + root = tempfile.mkdtemp(prefix='tc-sync-check-') + print("\nTwo machines under %s\n" % root) + try: + return run(user, password, root) + finally: + shutil.rmtree(root, ignore_errors=True) + + +def run(user, password, root): + a = Machine('machine-a', root) + b = Machine('machine-b', root) + + for machine in (a, b): + result = machine.sign_in(user, password) + if not result.get('ok'): + sys.exit("Sign-in failed for %s: %s" % (machine.name, result)) + check("both machines signed in with identities of their own", + a.state() is not None and b.state() is not None) + + # -- 1. the first machine offers what it already had -------------------- + print("\n1. A document that existed before synchronisation was switched on") + + a.tracker.add_main_project("Website") + a.tracker.add_task("Website", "Relaunch", priority=4) + a.tracker.start_work("Website", "Relaunch") + a.tracker.stop_work() + + offered = a.offer() + check("the existing document was offered", offered >= 4, "queued %s" % offered) + a.sync() + b.sync() + + check("the second machine has the project", task_of(b, "Relaunch") is not None) + check("with the priority that was set", task_of(b, "Relaunch") and + task_of(b, "Relaunch")['priority'] == 4) + check("and the hour that was worked", + task_of(b, "Relaunch") and len(task_of(b, "Relaunch")['time_entries']) == 1) + check("the file on disk holds it too, not just the object in memory", + TimeTracker(file_path=b.data)._get_project("Website") is not None) + + # -- 2. the queue empties and keeps counting ---------------------------- + print("\n2. The queue empties, and the numbering carries on") + + check("nothing of A's is still waiting", a.tracker.op_outbox.count() == 0, + "%s left" % a.tracker.op_outbox.count()) + + a.tracker.update_task("Website", "Relaunch", priority=7) + a.sync() + b.sync() + check("a change made after the queue emptied still arrives", + task_of(b, "Relaunch") and task_of(b, "Relaunch")['priority'] == 7, + "this is the failure that would end synchronisation silently") + + # -- 3. both machines edit at once -------------------------------------- + print("\n3. Both edit the same task before either syncs") + + # A due date nobody touched stays as it is, so only the field each machine + # actually changes travels - A's priority here, B's due date below. That is + # what makes the last two checks meaningful. + a.tracker.update_task("Website", "Relaunch", priority=8) + b.tracker.update_task("Website", "Relaunch", due_date="2026-09-01") + b.tracker.update_task("Website", "Relaunch", priority=3) + + a.sync() + b.sync() + a.sync() + + left, right = task_of(a, "Relaunch"), task_of(b, "Relaunch") + check("the two machines agree", left == right, + "\n a=%s\n b=%s" % (left, right)) + check("the later change won the field both touched", left and left['priority'] == 3) + check("the change only one of them made survived", + left and left['due_date'] == "2026-09-01") + + # -- 4. work booked against a task deleted elsewhere -------------------- + print("\n4. One deletes a task while the other is still booking time to it") + + a.tracker.add_task("Website", "Doomed") + a.sync() + b.sync() + doomed = task_of(b, "Doomed") + check("both have the task", task_of(a, "Doomed") and doomed) + + a.tracker.delete_task("Website", "Doomed") + b.tracker.start_work("Website", "Doomed") + b.tracker.stop_work() + discarded_uid = doomed['time_entries'][0]['uid'] + + a.sync() + _outcome, summary_b = b.sync() + a.sync() + + found_b, _parent_b = entry_of(b, discarded_uid) + found_a, _parent_a = entry_of(a, discarded_uid) + # The hour goes with the task, on both machines. Keeping it on one and not + # the other is the divergence this rule exists to prevent. + check("the time went with the task where it was booked", found_b is None) + check("and where the task was deleted", found_a is None) + check("the machine that had to discard it said so", + summary_b and summary_b['discarded_time'] == 1, str(summary_b)) + check("the task itself stayed deleted", + task_of(a, "Doomed") is None and task_of(b, "Doomed") is None) + + # -- 5. a session left running on the other machine --------------------- + print("\n5. Starting work here ends the session left running there") + + a.tracker.start_work("Website", "Relaunch") + a.sync() + b.sync() + check("B sees A's session running", len(running_entries(b)) == 1) + + b.tracker.start_work("Website", "Relaunch") + b.sync() + a.sync() + + check("only one session is running on each", + len(running_entries(a)) == 1 and len(running_entries(b)) == 1, + "a=%d b=%d" % (len(running_entries(a)), len(running_entries(b)))) + check("and it is the same one on both", + running_entries(a)[0]['uid'] == running_entries(b)[0]['uid']) + + b.tracker.stop_work() + b.sync() + a.sync() + check("stopping it stops it on both", + not running_entries(a) and not running_entries(b)) + + # -- 6. the ending of a session travels --------------------------------- + print("\n6. The end that one machine worked out for itself reaches the other") + + third = Machine('machine-c', root) + result = third.sign_in(user, password) + check("a third machine can join", result.get('ok'), str(result)) + third.sync() + while third.state()['base_seq'] < a.state()['base_seq']: + outcome, _ = third.sync() + if not outcome.get('ok'): + break + + check("it rebuilt the same projects", + [p['main_project_name'] for p in sorted(third.tracker.data['projects'], + key=lambda p: p['uid'])] + == [p['main_project_name'] for p in sorted(a.tracker.data['projects'], + key=lambda p: p['uid'])], + str([p['main_project_name'] for p in third.tracker.data['projects']])) + check("with no session left running", not running_entries(third)) + check("and the same tracked time", + _total_entries(third) == _total_entries(a), + "c=%d a=%d" % (_total_entries(third), _total_entries(a))) + + # -- 7. restarting changes nothing -------------------------------------- + print("\n7. Restarting the application picks up where it left off") + + a.tracker.add_task("Website", "After restart") + before = a.state()['base_seq'] + a.reopen() + check("the cursor survived the restart", a.state()['base_seq'] == before) + a.sync() + b.sync() + check("work queued before the restart still arrives", + task_of(b, "After restart") is not None) + + # -- 8. an idle cycle costs nothing ------------------------------------- + print("\n8. With nothing to do, a cycle files nothing") + + a.sync() + with a: + idle_inbox = sync_engine.read_inbox() + outcome, summary = a.sync() + check("the cycle succeeded", outcome.get('ok'), str(outcome)) + check("and had nothing to apply", summary is None, str(summary)) + check("the inbox stayed empty", idle_inbox == []) + + for machine in (a, b, third): + with machine: + sync_client.logout() + + print("\n%d passed, %d failed" % (passed, failed)) + return 1 if failed else 0 + + +def _total_entries(machine): + return sum(len(t.get('time_entries', [])) + for p in machine.tracker.data['projects'] + for t in p.get('tasks', [])) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/php-server/tc/.htaccess b/php-server/tc/.htaccess new file mode 100644 index 0000000..a633280 --- /dev/null +++ b/php-server/tc/.htaccess @@ -0,0 +1,21 @@ +# Stops any rewriting inherited from an application installed further up the +# tree; without this a front controller in the web root would answer instead +# of index.php. + + RewriteEngine Off + + +Options -Indexes + +# setup.enable carries the operator passphrase in plain text. It lives here +# because write access to this directory IS the proof of being the operator, +# but it must never be readable over the web. + + + Require all denied + + + Order allow,deny + Deny from all + + diff --git a/php-server/tc/index.php b/php-server/tc/index.php new file mode 100644 index 0000000..4c592f8 --- /dev/null +++ b/php-server/tc/index.php @@ -0,0 +1,198 @@ + $issued['token'], + 'expires_at' => $issued['expires_at'], + 'username' => $user['username'], + ]); + break; + + // ----------------------------------------------------------------- + case 'ping': + $session = tc_token_check($store, tc_presented_token()); + if (!$session) { + // One code for every reason the token is not usable - expired, + // revoked, account switched off. The client's response is the + // same in all three cases: log in again. Distinguishing them + // here would only tell an attacker which tokens once existed. + tc_fail(401, 'invalid_token', 'Token is missing, expired or revoked.'); + } + tc_ok([ + 'device_uid' => $session['device_uid'], + 'expires_at' => $session['exp'], + 'server_time' => time(), + ]); + break; + + // ----------------------------------------------------------------- + case 'logout': + $session = tc_token_check($store, tc_presented_token()); + if (!$session) { + // Already not usable - which is the state the caller wanted. + tc_ok(['revoked' => false]); + } + tc_token_revoke($store, $session['token_id']); + tc_ok(['revoked' => true]); + break; + + // ----------------------------------------------------------------- + // The cheap poll. A client that syncs every few minutes asks this first + // and only pushes or pulls when the answer has moved. + case 'head': + $session = tc_token_check($store, tc_presented_token()); + if (!$session) { + tc_fail(401, 'invalid_token', 'Token is missing, expired or revoked.'); + } + $state = tc_log_state($store, $session['uid']); + tc_ok(['head' => (int)$state['head'], 'server_time' => time()]); + break; + + // ----------------------------------------------------------------- + // Push and pull are one round trip: submitting work and learning what + // happened elsewhere are the same conversation, and splitting them would + // double the requests for no gain. + case 'push': + if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { + tc_fail(405, 'method_not_allowed', 'Use POST.'); + } + $session = tc_token_check($store, tc_presented_token()); + if (!$session) { + tc_fail(401, 'invalid_token', 'Token is missing, expired or revoked.'); + } + $body = tc_body(); + $baseSeq = isset($body['base_seq']) ? (int)$body['base_seq'] : 0; + $ops = $body['ops'] ?? []; + + $bad = tc_ops_validate($ops); + if ($bad !== null) { + tc_fail(400, $bad, 'The batch was rejected: ' . $bad . '.'); + } + + // The device comes from the token, never from the body. Letting a + // caller name its own device would let it move another device's + // duplicate counter and make that device's retries vanish. + $result = tc_log_append($store, $session['uid'], $session['device_uid'], $ops); + if ($result === null) { + tc_fail(503, 'busy', 'The log is locked right now. Retry.'); + } + + $read = tc_log_read($store, $session['uid'], $baseSeq, TC_PULL_MAX_OPS, $session['device_uid']); + tc_ok([ + 'head' => $result['head'], + 'assigned' => $result['assigned'], + 'dups' => $result['dups'], + 'ops' => $read['ops'], + 'more' => $read['more'], + ]); + break; + + // ----------------------------------------------------------------- + // Catching up without anything to contribute. + case 'pull': + $session = tc_token_check($store, tc_presented_token()); + if (!$session) { + tc_fail(401, 'invalid_token', 'Token is missing, expired or revoked.'); + } + $since = isset($_GET['since']) ? (int)$_GET['since'] : 0; + $limit = isset($_GET['limit']) ? max(1, min(TC_PULL_MAX_OPS, (int)$_GET['limit'])) : TC_PULL_MAX_OPS; + + // No device is excluded here: a client asking to catch up from a + // given point wants everything after it, including its own earlier + // work - that is what a fresh machine, or one restoring a backup, + // needs in order to rebuild. + $read = tc_log_read($store, $session['uid'], $since, $limit); + tc_ok(['head' => $read['head'], 'ops' => $read['ops'], 'more' => $read['more']]); + break; + + // ----------------------------------------------------------------- + default: + tc_fail(404, 'unknown_action', 'Unknown action.'); +} diff --git a/php-server/tc/lib/.htaccess b/php-server/tc/lib/.htaccess new file mode 100644 index 0000000..6b68584 --- /dev/null +++ b/php-server/tc/lib/.htaccess @@ -0,0 +1,11 @@ +# These files are includes, never entry points. Nothing here should ever be +# fetched directly, and the store's own protection must not be the only thing +# standing between a misconfiguration and the source. +Options -Indexes + + Require all denied + + + Order allow,deny + Deny from all + diff --git a/php-server/tc/lib/auth.php b/php-server/tc/lib/auth.php new file mode 100644 index 0000000..78f1478 --- /dev/null +++ b/php-server/tc/lib/auth.php @@ -0,0 +1,247 @@ +. + * + * The token_id is public and is literally the filename the token lives under, + * so validating a token is one computed path rather than a scan of every + * token on the system. Only sha256(secret) is stored, so the store holds + * nothing that can be replayed as a credential. + */ + +require_once __DIR__ . '/store.php'; + +const TC_BCRYPT_COST = 12; + +// A token dies 90 days after it was issued no matter what, and 30 days after +// it was last used. The absolute limit is the only thing that ever terminates +// a compromise nobody noticed - a copied credential shows up as the device +// that is legitimately there already, so there is no new entry to spot. +const TC_TOKEN_TTL = 7776000; // 90 days +const TC_IDLE_TTL = 2592000; // 30 days + +// Password checks are deliberately expensive, which makes them a lever for +// anyone wanting to tie up the host. This is a single global allowance rather +// than a per-user or per-IP one: the probe showed REMOTE_ADDR is the real +// client address here, but an attacker picks that, and a counter per attacker +// -supplied key is a way to fill the filesystem with small files. One counter +// cannot be inflated and cannot lock out a specific account by name. +const TC_HASH_BUDGET_PER_MINUTE = 30; + +function tc_users_file($store) { return $store . '/users.dat.php'; } +function tc_users_lock($store) { return $store . '/users.lock'; } +function tc_tokens_dir($store) { return $store . '/tokens'; } +function tc_user_dir($store, $uid) { return $store . '/users/' . $uid; } + +/** + * Looks up an account by name. + * + * @return array|null The record with its username attached, or null. + */ +function tc_user_find($store, $username) +{ + $data = tc_read_json(tc_users_file($store)); + if (!$data || empty($data['users']) || !isset($data['users'][$username])) { + return null; + } + $user = $data['users'][$username]; + $user['username'] = $username; + return $user; +} + +/** + * Consumes one unit of the global password-checking allowance. + * + * @return bool False when the allowance for this minute is used up. + */ +function tc_hash_budget_take($store) +{ + $path = $store . '/rate.dat.php'; + $lock = tc_lock($store . '/rate.lock'); + if (!$lock) { + // Refusing rather than waving it through: the budget exists to stop + // this endpoint being used to burn the host's CPU, and an unenforced + // budget is no budget. + return false; + } + try { + $now = time(); + $window = intdiv($now, 60); + $state = tc_read_json($path); + if (!is_array($state) || ($state['win'] ?? null) !== $window) { + $state = ['win' => $window, 'n' => 0]; + } + if ($state['n'] >= TC_HASH_BUDGET_PER_MINUTE) { + return false; + } + $state['n']++; + tc_write_json($path, $state); + return true; + } finally { + tc_unlock($lock); + } +} + +/** + * Issues a token for a device, replacing any token that device already holds. + * + * Replacing rather than appending is what makes a repeated login harmless: a + * client whose response was lost retries, and gets one row rather than a + * second live credential nothing will ever clean up. + * + * @return array{token: string, expires_at: int}|null + */ +function tc_token_issue($store, $uid, $deviceUid, $deviceName) +{ + $userDir = tc_user_dir($store, $uid); + $lock = tc_lock($userDir . '/user.lock'); + if (!$lock) { + return null; + } + try { + $user = tc_read_json($userDir . '/user.dat.php'); + if (!is_array($user)) { + $user = ['devices' => []]; + } + if (!isset($user['devices']) || !is_array($user['devices'])) { + $user['devices'] = []; + } + + // Drop the device's previous token file, if any. + foreach ($user['devices'] as $existing) { + if (($existing['device_uid'] ?? null) === $deviceUid + && !empty($existing['token_id'])) { + @unlink(tc_tokens_dir($store) . '/' . $existing['token_id'] . '.dat.php'); + } + } + $user['devices'] = array_values(array_filter( + $user['devices'], + function ($d) use ($deviceUid) { return ($d['device_uid'] ?? null) !== $deviceUid; } + )); + + $tokenId = bin2hex(random_bytes(8)); + $secret = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '='); + $now = time(); + $expires = $now + TC_TOKEN_TTL; + + $written = tc_write_json( + tc_tokens_dir($store) . '/' . $tokenId . '.dat.php', + [ + 'uid' => $uid, + 'device_uid' => $deviceUid, + 'hash' => hash('sha256', $secret), + 'iat' => $now, + 'exp' => $expires, + ] + ); + if (!$written) { + return null; + } + + $user['devices'][] = [ + 'device_uid' => $deviceUid, + 'device_name' => $deviceName, + 'token_id' => $tokenId, + 'iat' => $now, + 'exp' => $expires, + ]; + tc_write_json($userDir . '/user.dat.php', $user); + tc_touch_seen($store, $uid, $deviceUid); + + return ['token' => 'tc1.' . $tokenId . '.' . $secret, 'expires_at' => $expires]; + } finally { + tc_unlock($lock); + } +} + +/** + * Records that a device was just seen. + * + * A zero-byte file whose mtime carries the whole meaning. Kept apart from the + * token file on purpose: updating the token file on every request would race + * with a revocation and could re-create a credential that had just been + * withdrawn. A stray file here grants nothing. + */ +function tc_touch_seen($store, $uid, $deviceUid) +{ + $dir = tc_user_dir($store, $uid) . '/seen'; + if (!is_dir($dir) && !tc_secure_mkdir($dir)) { + return; + } + $path = $dir . '/' . $deviceUid; + if (!is_file($path)) { + @file_put_contents($path, ''); + @chmod($path, 0600); + } else { + @touch($path); + } +} + +/** + * Validates a presented credential. + * + * @return array|null ['uid'=>…, 'device_uid'=>…, 'exp'=>…] or null. + */ +function tc_token_check($store, $presented) +{ + if (!is_string($presented)) { + return null; + } + $parts = explode('.', $presented); + if (count($parts) !== 3 || $parts[0] !== 'tc1') { + return null; + } + list(, $tokenId, $secret) = $parts; + + // The id becomes a filename, so nothing but hex may pass. + if (!preg_match('/^[a-f0-9]{16}$/', $tokenId)) { + return null; + } + + $record = tc_read_json(tc_tokens_dir($store) . '/' . $tokenId . '.dat.php'); + if (!$record) { + return null; + } + if (!hash_equals((string)($record['hash'] ?? ''), hash('sha256', $secret))) { + return null; + } + + $now = time(); + if ($now >= (int)($record['exp'] ?? 0)) { + @unlink(tc_tokens_dir($store) . '/' . $tokenId . '.dat.php'); + return null; + } + + // Idle expiry, from the sidecar's mtime. + $seen = tc_user_dir($store, $record['uid']) . '/seen/' . $record['device_uid']; + $last = @filemtime($seen); + if ($last !== false && ($now - $last) > TC_IDLE_TTL) { + @unlink(tc_tokens_dir($store) . '/' . $tokenId . '.dat.php'); + return null; + } + + // An account can be switched off without hunting down its tokens. + $user = tc_read_json(tc_user_dir($store, $record['uid']) . '/user.dat.php'); + if (is_array($user) && !empty($user['disabled'])) { + return null; + } + + tc_touch_seen($store, $record['uid'], $record['device_uid']); + return [ + 'uid' => $record['uid'], + 'device_uid' => $record['device_uid'], + 'exp' => (int)$record['exp'], + 'token_id' => $tokenId, + ]; +} + +function tc_token_revoke($store, $tokenId) +{ + if (!preg_match('/^[a-f0-9]{16}$/', $tokenId)) { + return false; + } + return @unlink(tc_tokens_dir($store) . '/' . $tokenId . '.dat.php'); +} diff --git a/php-server/tc/lib/http.php b/php-server/tc/lib/http.php new file mode 100644 index 0000000..f8434fc --- /dev/null +++ b/php-server/tc/lib/http.php @@ -0,0 +1,87 @@ + false, 'error' => $code, 'message' => $text]); +} + +function tc_ok(array $payload = []) +{ + tc_json(200, ['ok' => true] + $payload); +} + +/** + * Reads and decodes the request body. + * + * Capped, and with a bounded nesting depth: this endpoint is reachable by + * anyone, and neither an enormous body nor a deeply nested structure should + * be able to exhaust memory before the credential has even been looked at. + */ +function tc_body() +{ + $raw = file_get_contents('php://input', false, null, 0, 1048576); + if ($raw === false || $raw === '') { + return []; + } + $data = json_decode($raw, true, 32); + return is_array($data) ? $data : []; +} + +/** + * Returns the presented credential, or null. + * + * X-TC-Token is the primary carrier. Authorization is accepted too, but is + * not relied upon: some shared hosts strip it before PHP ever sees it, and + * recovering it needs an .htaccess rule - authentication should not depend on + * a file whose effect we cannot guarantee. + */ +function tc_presented_token() +{ + if (!empty($_SERVER['HTTP_X_TC_TOKEN'])) { + return trim($_SERVER['HTTP_X_TC_TOKEN']); + } + $auth = $_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? ''; + if (stripos($auth, 'Bearer ') === 0) { + return trim(substr($auth, 7)); + } + return null; +} + +/** + * True when the request arrived over TLS. + * + * Checked directly rather than trusting a forwarding header: the probe showed + * REMOTE_ADDR is the real client address on this host, so there is no proxy + * whose X-Forwarded-Proto would be authoritative - which means anything + * claiming to be one is the client talking about itself. + */ +function tc_is_https() +{ + if (!empty($_SERVER['HTTPS']) && strtolower($_SERVER['HTTPS']) !== 'off') { + return true; + } + return (int)($_SERVER['SERVER_PORT'] ?? 0) === 443; +} diff --git a/php-server/tc/lib/oplog.php b/php-server/tc/lib/oplog.php new file mode 100644 index 0000000..8aaed24 --- /dev/null +++ b/php-server/tc/lib/oplog.php @@ -0,0 +1,323 @@ + 0, 'segments' => [], 'devices' => (object)[]]; + } + if (!isset($state['segments']) || !is_array($state['segments'])) { + $state['segments'] = []; + } + $state['devices'] = (array)($state['devices'] ?? []); + return $state; +} + +/** + * Discards anything an interrupted append left behind. + * + * A request can be killed part-way through by max_execution_time. The log is + * written before the pointer that describes it, so what survives such a kill + * is a segment with more bytes in it than the state file admits to - possibly + * ending in half a line. Those bytes are cut away rather than adopted: the + * pushing client never received its acknowledgement, so it will send the same + * operations again, and the per-device counter below makes that retry land + * exactly once. Keeping them would mean a sequence number describing + * different content on different machines, which nothing could repair. + */ +function tc_log_reconcile($store, $uid, array $state) +{ + if (!$state['segments']) { + return $state; + } + $last = $state['segments'][count($state['segments']) - 1]; + $path = tc_log_dir($store, $uid) . '/' . $last['f']; + $size = @filesize($path); + if ($size !== false && $size > $last['bytes']) { + $fh = @fopen($path, 'r+'); + if ($fh) { + @ftruncate($fh, $last['bytes']); + @fclose($fh); + } + } + return $state; +} + +/** + * Appends operations and gives each one its place in the order. + * + * @param string $deviceUid Taken from the caller's token, never from the + * request body - a device must not be able to + * submit work under another device's name, which + * would corrupt that device's duplicate counter. + * @param array $ops Each needs 'op', and 'lc' - a counter the client + * increments per operation and never reuses. + * @return array{assigned: array, dups: array, head: int}|null + */ +function tc_log_append($store, $uid, $deviceUid, array $ops) +{ + $lock = tc_lock(tc_log_lock_path($store, $uid)); + if (!$lock) { + return null; + } + try { + $dir = tc_log_dir($store, $uid); + if (!is_dir($dir) && !tc_secure_mkdir($dir)) { + return null; + } + $state = tc_log_reconcile($store, $uid, tc_log_state($store, $uid)); + + $maxLc = (int)($state['devices'][$deviceUid]['max_lc'] ?? 0); + $assigned = []; + $dups = []; + $lines = ''; + $head = (int)$state['head']; + $newMaxLc = $maxLc; + + foreach ($ops as $op) { + $lc = isset($op['lc']) ? (int)$op['lc'] : 0; + // Anything at or below the high-water mark has already been + // recorded - this is a retry of a push whose response was lost. + // Reporting it rather than appending it is what makes the whole + // exchange safe to repeat. + if ($lc <= $maxLc) { + $dups[] = $lc; + continue; + } + $head++; + $entry = [ + 's' => $head, + 'op' => $op['op'], + 'dev' => $deviceUid, + 'lc' => $lc, + ]; + foreach (['uid', 'f', 'ts', 'project', 'task', 'start', 'end'] as $k) { + if (array_key_exists($k, $op)) { + $entry[$k] = $op[$k]; + } + } + $lines .= json_encode($entry, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n"; + $assigned[] = [$lc, $head]; + if ($lc > $newMaxLc) { + $newMaxLc = $lc; + } + } + + if ($lines !== '') { + $seg = tc_log_current_segment($state, $head - count($assigned) + 1); + $path = $dir . '/' . $seg['f']; + if (!is_file($path)) { + @file_put_contents($path, TC_GUARD); + @chmod($path, 0600); + $seg['bytes'] = strlen(TC_GUARD); + } + $fh = @fopen($path, 'ab'); + if (!$fh) { + return null; + } + $written = @fwrite($fh, $lines); + @fflush($fh); + @fclose($fh); + if ($written !== strlen($lines)) { + // Partial write. Leave head where it was; the truncation on + // the next call removes the fragment and the client retries. + return null; + } + + $seg['last'] = $head; + $seg['bytes'] = (int)$seg['bytes'] + strlen($lines); + $seg['n'] = (int)($seg['n'] ?? 0) + count($assigned); + tc_log_put_segment($state, $seg); + + $state['head'] = $head; + $state['devices'][$deviceUid] = ['max_lc' => $newMaxLc, 'seen' => time()]; + // Written only after the log itself is safely on disk. + tc_write_json(tc_log_state_path($store, $uid), $state); + } + + return ['assigned' => $assigned, 'dups' => $dups, 'head' => (int)$state['head']]; + } finally { + tc_unlock($lock); + } +} + +/** + * Returns the segment currently being written, starting a new one when the + * open segment has grown past its limits. Segments keep any single read + * bounded, which matters when the execution limit is short. + * + * The limits are checked once per batch, not once per operation, so a + * segment can overshoot by up to one batch - a push of 500 arriving at a + * segment holding 999 leaves 1499 in it. That is deliberate: splitting a + * batch across two files would mean two appends to keep consistent instead + * of one, and the overshoot is bounded by TC_PUSH_MAX_OPS either way. Do not + * "fix" a segment that is over TC_SEG_MAX_OPS; it is working as intended. + */ +function tc_log_current_segment(array &$state, $nextSeq) +{ + $n = count($state['segments']); + if ($n > 0) { + $seg = $state['segments'][$n - 1]; + if ((int)$seg['n'] < TC_SEG_MAX_OPS && (int)$seg['bytes'] < TC_SEG_MAX_BYTES) { + return $seg; + } + } + return [ + 'f' => sprintf('seg-%07d.log.php', $n + 1), + 'first' => $nextSeq, + 'last' => $nextSeq - 1, + 'bytes' => 0, + 'n' => 0, + ]; +} + +function tc_log_put_segment(array &$state, array $seg) +{ + foreach ($state['segments'] as $i => $existing) { + if ($existing['f'] === $seg['f']) { + $state['segments'][$i] = $seg; + return; + } + } + $state['segments'][] = $seg; +} + +/** + * Reads operations newer than $since. + * + * @param string|null $excludeDevice Operations this device submitted itself + * are left out. It already holds their + * bodies and only needs to be told which + * sequence numbers they got, which the push + * response carries - sending them back + * would double the traffic for nothing. + * @return array{ops: array, head: int, more: bool} + */ +function tc_log_read($store, $uid, $since, $limit, $excludeDevice = null) +{ + $state = tc_log_state($store, $uid); + $head = (int)$state['head']; + $out = []; + $more = false; + + foreach ($state['segments'] as $seg) { + if ((int)$seg['last'] <= $since) { + continue; // wholly in the past + } + $path = tc_log_dir($store, $uid) . '/' . $seg['f']; + $fh = @fopen($path, 'rb'); + if (!$fh) { + continue; + } + // Only the bytes the state file vouches for; anything past that is + // the tail of an interrupted append. + $budget = (int)$seg['bytes']; + $read = 0; + while (($line = fgets($fh)) !== false) { + $read += strlen($line); + if ($read > $budget) { + break; + } + if ($line === '' || $line[0] !== '{') { + continue; // the guard line + } + $entry = json_decode($line, true); + if (!is_array($entry) || (int)($entry['s'] ?? 0) <= $since) { + continue; + } + if ($excludeDevice !== null && ($entry['dev'] ?? null) === $excludeDevice) { + continue; + } + if (count($out) >= $limit) { + $more = true; + break 2; + } + $out[] = $entry; + } + @fclose($fh); + } + + return ['ops' => $out, 'head' => $head, 'more' => $more]; +} + +/** + * Rejects anything that is not a well-formed operation. + * + * The server does not interpret operations, but it does refuse to store + * nonsense: whatever it accepts here it will hand to the other machine, and + * a client meeting a verb it has no rule for can only stop. + * + * @return string|null An error code, or null when the batch is acceptable. + */ +function tc_ops_validate($ops) +{ + if (!is_array($ops)) { + return 'ops_not_a_list'; + } + if (count($ops) > TC_PUSH_MAX_OPS) { + return 'too_many_ops'; + } + foreach ($ops as $op) { + if (!is_array($op)) { + return 'op_not_an_object'; + } + if (!isset($op['op']) || !in_array($op['op'], TC_OPS, true)) { + return 'unknown_op'; + } + if (!isset($op['lc']) || !is_int($op['lc']) || $op['lc'] < 1) { + return 'bad_lc'; + } + foreach (['uid', 'project', 'task'] as $k) { + if (isset($op[$k]) && !preg_match('/^[a-f0-9]{16}$/', (string)$op[$k])) { + return 'bad_uid'; + } + } + if (isset($op['f']) && !is_array($op['f'])) { + return 'bad_fields'; + } + } + return null; +} diff --git a/php-server/tc/lib/store.php b/php-server/tc/lib/store.php new file mode 100644 index 0000000..6407e07 --- /dev/null +++ b/php-server/tc/lib/store.php @@ -0,0 +1,156 @@ +\n"; + +// Content of the .htaccess dropped into every directory that must never be +// served. Both the 2.4 and the 2.2 form are present because which one a +// shared host honours is not something we get to choose. +const TC_DENY_HTACCESS = "Options -Indexes\n" + . "\n Require all denied\n\n" + . "\n Order allow,deny\n Deny from all\n\n"; + +/** + * Creates a directory nobody but the owner can enter. + */ +function tc_secure_mkdir($path) +{ + if (is_dir($path)) { + @chmod($path, 0700); + return true; + } + if (!@mkdir($path, 0700, true)) { + return false; + } + @chmod($path, 0700); + return true; +} + +/** + * Writes a file atomically and leaves it readable only by its owner. + * + * The rename is what makes this safe against a request being killed by + * max_execution_time: either the previous generation of the file is intact, + * or the new one is, never a half-written mixture. + */ +function tc_write_secure($path, $contents) +{ + $tmp = dirname($path) . '/.tmp' . bin2hex(random_bytes(6)); + if (@file_put_contents($tmp, $contents) === false) { + return false; + } + // Before the rename, so the file is never briefly visible at its final + // name with the umask's permissions still on it. + @chmod($tmp, 0600); + if (!@rename($tmp, $path)) { + @unlink($tmp); + return false; + } + @chmod($path, 0600); + return true; +} + +function tc_write_json($path, array $data) +{ + $json = json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); + if ($json === false) { + return false; + } + return tc_write_secure($path, TC_GUARD . $json . "\n"); +} + +/** + * Reads a stored JSON file, stripping the guard line. + * + * The prefix length is taken from the constant rather than hardcoded - an + * off-by-one here would make every stored file unreadable, and would do so + * silently. + */ +function tc_read_json($path) +{ + $raw = @file_get_contents($path); + if ($raw === false) { + return null; + } + $guardLen = strlen(TC_GUARD); + if (strncmp($raw, TC_GUARD, $guardLen) === 0) { + $raw = substr($raw, $guardLen); + } + $data = json_decode($raw, true); + return is_array($data) ? $data : null; +} + +/** + * Takes an advisory lock, giving up rather than queueing. + * + * Blocking would be worse than failing here: with a short execution limit a + * queue of stalled requests ties up worker processes for the whole vhost, + * and the caller can simply retry. + * + * @return resource|null The open handle to pass to tc_unlock, or null. + */ +function tc_lock($lockPath) +{ + $fh = @fopen($lockPath, 'c'); + if (!$fh) { + return null; + } + @chmod($lockPath, 0600); + $deadline = microtime(true) + 5.0; + while (!@flock($fh, LOCK_EX | LOCK_NB)) { + if (microtime(true) >= $deadline) { + @fclose($fh); + return null; + } + usleep(20000); + } + return $fh; +} + +function tc_unlock($fh) +{ + if ($fh) { + @flock($fh, LOCK_UN); + @fclose($fh); + } +} + +/** + * Loads the installed configuration, or null when setup has not run. + */ +function tc_config() +{ + static $config = null; + if ($config !== null) { + return $config; + } + $path = dirname(__DIR__) . '/config.php'; + if (!is_file($path)) { + return null; + } + $loaded = include $path; + if (!is_array($loaded) || empty($loaded['store']) || !is_dir($loaded['store'])) { + return null; + } + $config = $loaded; + return $config; +} diff --git a/php-server/tc/setup.php b/php-server/tc/setup.php new file mode 100644 index 0000000..f651eb7 --- /dev/null +++ b/php-server/tc/setup.php @@ -0,0 +1,427 @@ + (object)[]]); + + $config = " $path, 'installed' => date('c')], true) . ";\n"; + if (!tc_write_secure(__DIR__ . '/config.php', $config)) { + return [null, 'Could not write config.php.']; + } + // Left at the 0600 tc_write_secure gives it. PHP runs as the owner + // here, so nothing needs wider access - and this file names the + // store directory, which is the one thing worth knowing for anyone + // who gets as far as reading files on this account. + + return [$path, null]; + } + return [null, 'Could not create a store directory anywhere.']; +} + +/** + * Removes a store directory an aborted install had just created. + * + * Without this, every refused attempt leaves another empty, randomly named + * directory behind - so the one thing a worried operator does, try again, + * quietly litters the web space. Only the shallow contents this function's + * caller can have created are removed; it never recurses, so a directory + * that somehow already held data is left alone rather than deleted. + */ +function tc_discard_dir($path) +{ + foreach (['canary.txt', '.htaccess', 'index.html'] as $name) { + @unlink($path . '/' . $name); + } + @rmdir($path); +} + +/** + * Removes a directory and everything under it. + * + * Refuses to touch anything outside the store. A recursive delete driven by + * a path is worth being paranoid about even when the caller looks + * trustworthy, because the cost of being wrong is unbounded. + */ +function tc_rmtree($path, $store) +{ + $real = realpath($path); + $inside = realpath($store); + if ($real === false || $inside === false || strpos($real, $inside . DIRECTORY_SEPARATOR) !== 0) { + return false; + } + foreach (scandir($real) ?: [] as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $child = $real . '/' . $entry; + if (is_dir($child) && !is_link($child)) { + tc_rmtree($child, $store); + } else { + @unlink($child); + } + } + return @rmdir($real); +} + +/** + * @return string 'protected' | 'leaked' | 'unknown' + */ +function tc_fetch_verdict($url, $marker) +{ + $body = false; + if (function_exists('curl_init')) { + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 8, + CURLOPT_FOLLOWLOCATION => false, + ]); + $body = curl_exec($ch); + } elseif (filter_var(ini_get('allow_url_fopen'), FILTER_VALIDATE_BOOLEAN)) { + $body = @file_get_contents($url); + } + if ($body === false) { + return 'unknown'; + } + return (strpos((string)$body, $marker) !== false) ? 'leaked' : 'protected'; +} + +// --------------------------------------------------------------------------- + +if (($_SERVER['REQUEST_METHOD'] ?? '') === 'POST') { + $given = (string)($_POST['passphrase'] ?? ''); + if (!hash_equals($expected, $given)) { + $errors[] = 'Wrong passphrase.'; + } else { + $action = (string)($_POST['action'] ?? ''); + $scheme = 'https'; + $baseUrl = $scheme . '://' . ($_SERVER['HTTP_HOST'] ?? '') + . rtrim(dirname($_SERVER['SCRIPT_NAME'] ?? '/'), '/'); + + if ($action === 'install') { + if (tc_config() !== null) { + $errors[] = 'Already installed. Delete config.php first if you really mean to reinstall.'; + } else { + list($path, $err) = tc_install($baseUrl); + if ($err) { + $errors[] = $err; + } else { + $notices[] = 'Installed. Store: ' . $path; + $done = true; + } + } + } elseif ($action === 'adduser') { + $config = tc_config(); + if ($config === null) { + $errors[] = 'Not installed yet.'; + } else { + $name = trim((string)($_POST['username'] ?? '')); + $pass = (string)($_POST['password'] ?? ''); + if (!preg_match('/^[A-Za-z0-9._-]{3,32}$/', $name)) { + $errors[] = 'Username must be 3-32 characters, letters/digits/dot/underscore/hyphen.'; + } elseif (strlen($pass) < 12) { + $errors[] = 'Password must be at least 12 characters.'; + } else { + $store = $config['store']; + $lock = tc_lock(tc_users_lock($store)); + if (!$lock) { + $errors[] = 'Could not lock the user store.'; + } else { + $data = tc_read_json(tc_users_file($store)); + if (!is_array($data) || !isset($data['users'])) { + $data = ['users' => []]; + } + if (isset($data['users'][$name])) { + $errors[] = 'That account already exists.'; + } else { + $uid = bin2hex(random_bytes(16)); + $data['users'][$name] = [ + 'uid' => $uid, + 'pass' => password_hash($pass, PASSWORD_BCRYPT, ['cost' => TC_BCRYPT_COST]), + 'created' => date('c'), + ]; + tc_write_json(tc_users_file($store), $data); + tc_secure_mkdir(tc_user_dir($store, $uid)); + tc_secure_mkdir(tc_user_dir($store, $uid) . '/seen'); + tc_write_json(tc_user_dir($store, $uid) . '/user.dat.php', + ['disabled' => false, 'devices' => []]); + $notices[] = 'Account "' . $name . '" created.'; + $done = true; + } + tc_unlock($lock); + } + } + } + } elseif ($action === 'deluser') { + $config = tc_config(); + if ($config === null) { + $errors[] = 'Not installed yet.'; + } else { + $name = trim((string)($_POST['username'] ?? '')); + $store = $config['store']; + $lock = tc_lock(tc_users_lock($store)); + if (!$lock) { + $errors[] = 'Could not lock the user store.'; + } else { + $data = tc_read_json(tc_users_file($store)); + if (!is_array($data) || !isset($data['users'][$name])) { + $errors[] = 'No such account.'; + } else { + $uid = $data['users'][$name]['uid']; + // Tokens live in a shared directory keyed by token id, + // so they have to go individually - dropping the user + // directory alone would leave working credentials + // pointing at an account that no longer exists. + $rec = tc_read_json(tc_user_dir($store, $uid) . '/user.dat.php'); + foreach (($rec['devices'] ?? []) as $d) { + if (!empty($d['token_id'])) { + tc_token_revoke($store, $d['token_id']); + } + } + tc_rmtree(tc_user_dir($store, $uid), $store); + unset($data['users'][$name]); + tc_write_json(tc_users_file($store), $data); + $notices[] = 'Account "' . $name . '" and all of its data were deleted.'; + $done = true; + } + tc_unlock($lock); + } + } + } elseif ($action === 'status') { + $config = tc_config(); + if ($config === null) { + $notices[] = 'Not installed.'; + } else { + $data = tc_read_json(tc_users_file($config['store'])); + $names = ($data && !empty($data['users'])) ? array_keys((array)$data['users']) : []; + $notices[] = 'Store: ' . $config['store']; + $notices[] = 'Accounts: ' . ($names ? implode(', ', $names) : '(none)'); + $tokens = glob(tc_tokens_dir($config['store']) . '/*.dat.php'); + $notices[] = 'Live tokens: ' . ($tokens ? count($tokens) : 0); + } + } else { + $errors[] = 'Unknown action.'; + } + + // Close the window as soon as anything was actually changed. + if ($done) { + if (@unlink(TC_ENABLE_FILE)) { + $notices[] = 'setup.enable has been deleted - this page is closed again.'; + } else { + $errors[] = 'IMPORTANT: setup.enable could NOT be deleted. Remove it by FTP now, ' + . 'otherwise anyone with the passphrase can keep using this page.'; + } + } + } +} + +header('Content-Type: text/html; charset=utf-8'); +header('X-Robots-Tag: noindex, nofollow'); +?> + + +TimeControl sync - setup + +

TimeControl sync – setup

+
+
+ +

Every action needs the passphrase from setup.enable. That file is +deleted as soon as something is changed – upload it again for the next action.

+ +
+
1. Install +

Creates the store and proves it cannot be read over the web.

+ + +
+
+ +
+
2. Create an account + + + + +
+
+ +
+
Delete an account +

Irreversible. Removes the account, its whole operation + log and every token it holds.

+ + + +
+
+ +
+
Status +

Read-only – does not consume setup.enable.

+ + +
+
+ diff --git a/php-server/tcprobe/.htaccess b/php-server/tcprobe/.htaccess new file mode 100644 index 0000000..3d019e0 --- /dev/null +++ b/php-server/tcprobe/.htaccess @@ -0,0 +1,26 @@ +# Shields this directory from an application installed further up. +# +# A framework's .htaccess in the web root typically rewrites every request to +# its own front controller. Those rules apply to subdirectories as well, so a +# plain file dropped underneath is never reached - the framework answers +# instead. Switching the rewrite engine off here ends that for this directory +# only; nothing above is affected. + + + RewriteEngine Off + + +# Some setups pull a bootstrap file into every PHP request. Where PHP runs as +# an Apache module this turns it off; under PHP-FPM the directive is ignored, +# which is harmless - it is wrapped so an unknown directive cannot produce a +# 500 and hide the real problem. + + php_value auto_prepend_file none + php_value auto_append_file none + + + php_value auto_prepend_file none + php_value auto_append_file none + + +Options -Indexes diff --git a/php-server/tcprobe/tcprobe.php b/php-server/tcprobe/tcprobe.php new file mode 100644 index 0000000..2954fec --- /dev/null +++ b/php-server/tcprobe/tcprobe.php @@ -0,0 +1,327 @@ +/tcprobe.php?key= + * 4. Send the output back. + * 5. DELETE THE FILE. It reports paths and configuration that are useful + * to an attacker; it is meant to exist for minutes, not to stay. + * + * It creates a few files while running and removes them again. The only one + * that may survive is the reachability canary, and only when PHP cannot make + * outbound requests to fetch it itself - the page says so explicitly and + * tells you what to do. + * + * Nothing here writes anything a later install depends on. + */ + +const PROBE_KEY = '12345678901234567890'; +const PROBE_BUILD = 2; + +// --------------------------------------------------------------------------- + +// The gate is the LENGTH of the key, not a comparison against the shipped +// default. That is deliberate. Replacing every occurrence of the default +// string is the obvious way to configure a file like this, and a sentinel +// comparison written as PROBE_KEY === 'CHANGE-ME' gets rewritten along with +// it - leaving a probe that refuses every call, for a reason nothing on the +// page explains and that looks exactly like the file not being there. +// A length check has nothing for a search-and-replace to break. +if (strlen(PROBE_KEY) < 12) { + header('Content-Type: text/plain; charset=utf-8'); + echo "TimeControl host probe (build " . PROBE_BUILD . ")\n\n"; + echo "The file is uploaded and PHP is running it. It refuses to go further\n"; + echo "because PROBE_KEY is only " . strlen(PROBE_KEY) . " characters long.\n\n"; + echo "Edit the line near the top of this file:\n\n"; + echo " const PROBE_KEY = '" . PROBE_KEY . "';\n\n"; + echo "Put at least 12 characters between the quotes - change ONLY this one\n"; + echo "line - upload it again, and call this URL with ?key=.\n\n"; + echo "The report reveals paths and configuration, so the key is what keeps\n"; + echo "it from being readable by anyone who finds the URL.\n"; + exit; +} + +// Once a real key is set, a wrong or missing one gets nothing: an armed probe +// should not confirm its own existence to someone guessing at URLs. +if (!isset($_GET['key']) || !hash_equals(PROBE_KEY, (string)$_GET['key'])) { + http_response_code(404); + exit; +} + +header('Content-Type: text/plain; charset=utf-8'); +header('X-Robots-Tag: noindex, nofollow'); + +$results = []; +$cleanup = []; + +function say($section) { + echo "\n" . str_repeat('=', 66) . "\n" . $section . "\n" . str_repeat('=', 66) . "\n"; +} + +function item($label, $value, $verdict = null) { + $line = sprintf(' %-34s %s', $label . ':', $value); + if ($verdict !== null) { + $line .= ' [' . $verdict . ']'; + } + echo $line . "\n"; +} + +echo "TimeControl host probe\n"; +echo 'run at ' . date('c') . "\n"; + +// --------------------------------------------------------------------------- +say('1. PHP'); + +$phpOk = PHP_VERSION_ID >= 70400; +item('version', PHP_VERSION, $phpOk ? 'OK' : 'TOO OLD - need 7.4+'); +item('SAPI', PHP_SAPI); +item('max_execution_time', ini_get('max_execution_time') . ' s'); +item('memory_limit', ini_get('memory_limit')); +item('open_basedir', ini_get('open_basedir') ?: '(not set)'); + +$needed = ['random_bytes', 'password_hash', 'password_verify', 'hash_equals', + 'json_encode', 'json_decode', 'flock', 'rename', 'file_put_contents']; +$missing = array_values(array_filter($needed, function ($f) { return !function_exists($f); })); +item('required functions', $missing ? 'MISSING: ' . implode(', ', $missing) : 'all present', + $missing ? 'PROBLEM' : 'OK'); + +// bcrypt cost 12 is the intended setting; measure what it actually costs here, +// because a slow shared CPU turns the login endpoint into its own bottleneck. +if (function_exists('password_hash')) { + $t0 = microtime(true); + password_hash('probe-timing-only', PASSWORD_BCRYPT, ['cost' => 12]); + $ms = (microtime(true) - $t0) * 1000; + item('bcrypt cost 12', sprintf('%.0f ms', $ms), + $ms < 1500 ? 'OK' : 'SLOW - consider cost 11'); +} + +// --------------------------------------------------------------------------- +say('2. Identity - who does PHP run as?'); + +$uid = function_exists('posix_geteuid') ? posix_geteuid() : null; +if ($uid !== null && function_exists('posix_getpwuid')) { + $pw = posix_getpwuid($uid); + item('effective user', ($pw['name'] ?? '?') . ' (uid ' . $uid . ')'); + item('home directory', $pw['dir'] ?? '(unknown)'); +} else { + // ext-posix is commonly disabled on shared hosting. Fall back to asking + // the filesystem: create a file, see who ends up owning it. + item('ext-posix', 'not available - deriving from a created file'); +} +item('get_current_user()', function_exists('get_current_user') ? get_current_user() : '?'); + +$probeDir = __DIR__ . '/tcprobe_tmp_' . bin2hex(random_bytes(4)); +$dirMade = @mkdir($probeDir, 0700); +if ($dirMade) { + $cleanup[] = $probeDir; + $f = $probeDir . '/owner_test'; + @file_put_contents($f, 'x'); + if (is_file($f)) { + $cleanup[] = $f; + $st = @stat($f); + item('files are owned by uid', $st ? (string)$st['uid'] : '?'); + item('files are owned by gid', $st ? (string)$st['gid'] : '?'); + item('umask / resulting mode', sprintf('%04o', @fileperms($f) & 0777), + (@fileperms($f) & 0077) ? 'GROUP/WORLD READABLE' : 'OK - owner only'); + item('directory mode', sprintf('%04o', @fileperms($probeDir) & 0777)); + } +} else { + item('mkdir in web directory', 'FAILED', 'PROBLEM'); +} + +echo "\n NOTE: a uid shared with other customers is the go/no-go. If PHP here\n"; +echo " runs as a generic account (www-data, apache, wwwrun) AND open_basedir\n"; +echo " is not set, then 0600 protects nothing from a co-tenant's script.\n"; + +// --------------------------------------------------------------------------- +say('3. Can the store live above the document root?'); + +$docRoot = $_SERVER['DOCUMENT_ROOT'] ?? ''; +item('DOCUMENT_ROOT', $docRoot ?: '(unknown)'); +item('script directory', __DIR__); + +$above = dirname($docRoot ?: __DIR__); +$aboveTest = $above . '/tcprobe_above_' . bin2hex(random_bytes(4)); +if ($docRoot && @mkdir($aboveTest, 0700)) { + $cleanup[] = $aboveTest; + item('write above docroot', 'YES - ' . $above, 'PREFERRED LAYOUT AVAILABLE'); +} else { + item('write above docroot', 'no (' . $above . ')', + 'FALLBACK LAYOUT - store goes inside the web directory'); +} + +// --------------------------------------------------------------------------- +say('4. Locking and atomic writes'); + +if ($dirMade) { + $lockFile = $probeDir . '/lock_test'; + $cleanup[] = $lockFile; + $fh = @fopen($lockFile, 'c'); + if ($fh) { + $got = @flock($fh, LOCK_EX | LOCK_NB); + item('flock LOCK_EX|LOCK_NB', $got ? 'acquired' : 'FAILED', $got ? 'OK' : 'PROBLEM'); + if ($got) { @flock($fh, LOCK_UN); } + @fclose($fh); + echo "\n CAVEAT: flock() succeeding here does NOT prove it works. On some NFS-\n"; + echo " backed hosting it reports success while locking nothing. It cannot be\n"; + echo " tested from a single request - the design must not depend on locking\n"; + echo " alone for anything that would corrupt data if it silently failed.\n\n"; + } + + $src = $probeDir . '/rename_src'; + $dst = $probeDir . '/rename_dst'; + @file_put_contents($src, 'payload'); + @file_put_contents($dst, 'old'); + $renamed = @rename($src, $dst); + $cleanup[] = $dst; + item('rename() over existing file', + $renamed && @file_get_contents($dst) === 'payload' ? 'works' : 'FAILED', + $renamed ? 'OK' : 'PROBLEM'); +} + +// --------------------------------------------------------------------------- +say('5. Outbound HTTP - can the server verify itself?'); + +$hasCurl = function_exists('curl_init'); +$hasFopen = filter_var(ini_get('allow_url_fopen'), FILTER_VALIDATE_BOOLEAN); +item('ext-curl', $hasCurl ? 'available' : 'not available'); +item('allow_url_fopen', $hasFopen ? 'on' : 'off'); +item('outbound HTTP possible', ($hasCurl || $hasFopen) ? 'YES' : 'NO', + ($hasCurl || $hasFopen) ? 'OK' : 'MANUAL CHECK NEEDED'); + +// --------------------------------------------------------------------------- +say('6. Is a store directory reachable over the web?'); + +$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; +$host = $_SERVER['HTTP_HOST'] ?? 'localhost'; +$baseUrl = $scheme . '://' . $host . rtrim(dirname($_SERVER['SCRIPT_NAME'] ?? '/'), '/'); +item('request scheme', $scheme, $scheme === 'https' ? 'OK' : 'NOT HTTPS - see below'); +item('base URL', $baseUrl); + +$canaryToken = bin2hex(random_bytes(16)); +$canaryUrl = null; +if ($dirMade) { + // .htaccess first, then the file it is supposed to be hiding. + @file_put_contents($probeDir . '/.htaccess', + "Options -Indexes\n" . + "\n Require all denied\n\n" . + "\n Order allow,deny\n Deny from all\n\n"); + $cleanup[] = $probeDir . '/.htaccess'; + + @file_put_contents($probeDir . '/canary.json', json_encode(['marker' => $canaryToken])); + $cleanup[] = $probeDir . '/canary.json'; + + $canaryUrl = $baseUrl . '/' . basename($probeDir) . '/canary.json'; + + // Three outcomes, never two. A request that did not complete says nothing + // about whether the file is reachable - reporting that as "protected" + // would be asserting the very thing this check exists to demonstrate, + // and would wave through a store that is in fact served to the world. + $verdict = 'unknown'; + $detail = null; + + if ($hasCurl) { + $ch = curl_init($canaryUrl); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 5, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_FOLLOWLOCATION => false, + ]); + $body = curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $err = curl_error($ch); + // No curl_close(): it has had no effect since PHP 8.0 and is + // deprecated as of 8.5, where calling it prints a warning into the + // middle of this report. + if ($body === false) { + $detail = 'request failed - ' . $err; + } else { + $detail = 'HTTP ' . $code; + $verdict = (strpos((string)$body, $canaryToken) !== false) ? 'leaked' : 'protected'; + } + } elseif ($hasFopen) { + $body = @file_get_contents($canaryUrl); + if ($body === false) { + $detail = 'request failed'; + } else { + $detail = 'fetched'; + $verdict = (strpos((string)$body, $canaryToken) !== false) ? 'leaked' : 'protected'; + } + } + + if ($verdict === 'leaked') { + item('canary fetch result', $detail); + item('.htaccess protection', 'INEFFECTIVE', 'STORE MUST NOT LIVE IN THE WEB DIRECTORY'); + } elseif ($verdict === 'protected') { + item('canary fetch result', $detail); + item('.htaccess protection', 'effective', 'OK'); + } else { + if ($detail !== null) { + item('canary fetch result', $detail); + item('.htaccess protection', 'UNKNOWN', 'MUST BE CHECKED BY HAND'); + } + echo "\n The self-check could not complete, so this must be checked by hand.\n"; + echo " Open this URL in a browser:\n\n " . $canaryUrl . "\n\n"; + echo " Expected: 403 Forbidden (or 404).\n"; + echo " If you instead see a JSON document, .htaccess is being ignored on\n"; + echo " this host and the store must NOT be placed inside the web directory.\n"; + echo " This directory is left in place so you can test it - delete\n"; + echo " '" . basename($probeDir) . "' by FTP afterwards.\n\n"; + // Keep the directory so the manual check is possible. + $cleanup = array_values(array_filter($cleanup, function ($p) use ($probeDir) { + return strpos($p, $probeDir) !== 0; + })); + } +} + +if ($scheme !== 'https') { + echo "\n This request arrived over plain HTTP. A bearer token sent this way is\n"; + echo " readable by anyone on the path. TLS is not optional for the sync server -\n"; + echo " check whether the hosting package includes a certificate.\n"; +} + +// --------------------------------------------------------------------------- +say('7. Proxy headers (do NOT trust these blindly)'); + +foreach (['REMOTE_ADDR', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'HTTP_CF_CONNECTING_IP'] as $h) { + item($h, isset($_SERVER[$h]) ? (string)$_SERVER[$h] : '(absent)'); +} +echo "\n If REMOTE_ADDR is a fixed internal address, the TLS terminator sits in\n"; +echo " front and per-IP rate limiting would lump every client together.\n"; + +// --------------------------------------------------------------------------- +// Clean up everything created, deepest path first. +usort($cleanup, function ($a, $b) { return strlen($b) - strlen($a); }); +$left = []; +foreach ($cleanup as $path) { + if (is_dir($path)) { + if (!@rmdir($path)) { $left[] = $path; } + } elseif (is_file($path)) { + if (!@unlink($path)) { $left[] = $path; } + } +} + +say('Done'); +if ($left) { + echo " Could not remove:\n"; + foreach ($left as $p) { echo ' ' . $p . "\n"; } + echo " Delete these by FTP.\n"; +} else { + echo " All temporary files removed.\n"; +} +echo "\n NOW DELETE tcprobe.php.\n"; diff --git a/sl/SL_Menu.py b/sl/SL_Menu.py index b842e93..d7edc23 100644 --- a/sl/SL_Menu.py +++ b/sl/SL_Menu.py @@ -20,6 +20,22 @@ except (ImportError, ModuleNotFoundError): UPDATE_MODULE_AVAILABLE = False +try: + from tt import sync_client, sync_engine + from tt.sync_outbox import default_outbox_if_enabled + SYNC_AVAILABLE = True +except (ImportError, ModuleNotFoundError): + # Only reachable if requests is missing, which requirements.txt rules + # out - but the settings screen should degrade to an explanation rather + # than taking the whole app down with it. + SYNC_AVAILABLE = False + +# How many failed cycles in a row before a merely-flaky connection is worth +# saying out loud. Below this the header stays clean, because a lost network +# fixes itself; above it, the silence has lasted long enough to be the more +# misleading of the two. +SYNC_QUIET_FAILURES = 5 + try: # Internal (not officially public) API, but it's the only way to tell a # fragment's own run_every tick apart from an ordinary rerun - see the @@ -328,13 +344,72 @@ def render_icon_button_css(): # runs on every auto-refresh tick too, used to reset the visible view # back to the main menu once the user reloaded after such a crash. try: - st.session_state.tracker.data = st.session_state.tracker._load_data() + st.session_state.tracker.reload_data() except (json.JSONDecodeError, OSError): pass if 'menu' not in st.session_state: st.session_state.menu = 'today_view' +# --- Synchronisation --- +# +# Three things happen here, in this order, and all of them are cheap: the +# network half runs in a worker thread and nothing below waits on it. +# +# The binding first, because the tracker is built once per browser session +# and reads config.json at that moment. Without this, switching +# synchronisation on in Settings would leave the running session recording +# nothing until the app was restarted - and the settings screen never said so. +# +# Then applying, on this thread, into the document this thread has just +# reloaded and is about to draw. That is deliberate: the interface keeps its +# document in memory between redraws, so a background thread writing the file +# would be silently overwritten by the next thing the user did. +if SYNC_AVAILABLE: + try: + st.session_state.tracker.op_outbox = default_outbox_if_enabled(config) + # Outside the check below on purpose: this call is what stops the + # worker as well as what starts it. Switching synchronisation off + # while the app is open has to actually end it, or the thread keeps + # talking to the server with the stored token and filing operations + # nothing will ever read. + sync_engine.ensure_started(config) + if st.session_state.tracker.op_outbox is not None: + # The notice from a previous run has been drawn by now, so it + # can go. Cleared here rather than where it is shown, because a + # run that draws it can still be abandoned by an st.rerun() + # further down the page, and a message consumed by such a run + # would have been seen by nobody. + if st.session_state.pop('sync_discarded_shown', False): + st.session_state.pop('sync_discarded', None) + sync_engine.offer_document(st.session_state.tracker) + try: + _sync_summary = sync_engine.apply_pending(st.session_state.tracker) + except OSError as exc: + # The document could not be written - a full disk, or a data + # file on a share that has gone read-only. Nothing was + # consumed, so this will be retried; but staying silent would + # show the incoming changes on screen as though they had been + # saved, and they would vanish at the next restart. + _sync_summary = None + st.session_state.sync_apply_error = str(exc) + else: + st.session_state.pop('sync_apply_error', None) + if _sync_summary and _sync_summary['discarded_time']: + st.session_state['sync_discarded'] = ( + st.session_state.get('sync_discarded', 0) + + _sync_summary['discarded_time']) + # A change of view is the moment the user is most likely to want + # current figures, so ask for a cycle then. It only wakes the + # worker - nothing here blocks on the answer. + if st.session_state.get('_synced_for_menu') != st.session_state.menu: + st.session_state._synced_for_menu = st.session_state.menu + sync_engine.nudge() + except Exception: + # Synchronisation is an optional extra. Nothing about it may stop the + # application from starting or a view from drawing. + pass + # Re-check for a new release whenever the view changes (navigating to a # different menu than the one this ran for last time) - not on every rerun # of the *same* view, e.g. not on every keystroke or the 5s auto-refresh @@ -479,6 +554,7 @@ def render_header(title, subtitle=None): if st.button("⟳", help=_("Restart and install the update"), key="update_restart_btn"): with st.spinner(_("Downloading and installing update...")): apply_update(update_check['url']) + render_sync_notice() st.title(title) if subtitle: st.caption(subtitle) @@ -489,6 +565,54 @@ def render_header(title, subtitle=None): elif f['type'] == 'error': st.error(f['message']) st.session_state.feedback = None # Clear after showing + +def render_sync_notice(): + """ + Says something about synchronisation only when there is something to say. + + Working synchronisation is meant to be invisible; a permanent "last + synced at" line on every screen would be noise that stops being read, and + would then be no use on the day it matters. So this shows two things and + nothing else: tracked time that was discarded, because that is a loss the + user did not ask for on this machine and would otherwise never learn + about, and a failure that only they can clear. + """ + if not SYNC_AVAILABLE or st.session_state.tracker.op_outbox is None: + return + + discarded = st.session_state.get('sync_discarded') or 0 + if discarded: + # Marked as shown rather than removed. A run can be abandoned partway + # by st.rerun() - a button handler further down the page, say - and a + # message consumed by such a run would be seen by nobody. The mark is + # cleared at the top of the next run, which by then has drawn it. + st.session_state.sync_discarded_shown = True + st.warning(_("{count} time entries were discarded because the task they " + "belonged to had been deleted on another machine.").format( + count=discarded)) + + failed_to_save = st.session_state.get('sync_apply_error') + if failed_to_save: + st.error(_sync_error_message('local_io')) + + try: + snapshot = sync_engine.snapshot() + except Exception: + return + if snapshot['state'] != 'failing': + return + + # An error the user has to clear is shown at once. A dropped connection + # usually clears itself, so it is left alone at first - but not for ever: + # a sync that has been failing all week while the app looks perfectly + # normal is how two machines quietly become two different documents. + actionable = snapshot['error'] in ('not_signed_in', 'invalid_token', 'https_required', + 'not_installed', 'bad_response', 'local_io') + if not actionable and int(snapshot.get('failures', 0)) < SYNC_QUIET_FAILURES: + return + st.warning(_("Synchronisation is paused: {reason}").format( + reason=_sync_error_message(snapshot['error']))) + # --- Views --- def render_toolbar(return_to): @@ -746,7 +870,6 @@ def view_task_planning(): task['main_project_name'], task['task_name'], today=not task.get('today', False), - due_date=task.get('due_date'), recurring=task.get('recurring'), frequency=task.get('frequency'), userdefined_days=task.get('userdefined_days'), @@ -759,7 +882,6 @@ def view_task_planning(): task['main_project_name'], task['task_name'], status='done', - due_date=task.get('due_date'), recurring=task.get('recurring'), frequency=task.get('frequency'), userdefined_days=task.get('userdefined_days'), @@ -832,7 +954,6 @@ def view_task_planning(): task['main_project_name'], task['task_name'], today=not task.get('today', False), - due_date=task.get('due_date'), recurring=task.get('recurring'), frequency=task.get('frequency'), userdefined_days=task.get('userdefined_days'), @@ -845,7 +966,6 @@ def view_task_planning(): task['main_project_name'], task['task_name'], status='done', - due_date=task.get('due_date'), recurring=task.get('recurring'), frequency=task.get('frequency'), userdefined_days=task.get('userdefined_days'), @@ -897,7 +1017,6 @@ def view_today_tasks(): current_work['main_project_name'], current_work['task_name'], status='done', - due_date=task_details.get('due_date'), recurring=task_details.get('recurring'), frequency=task_details.get('frequency'), userdefined_days=task_details.get('userdefined_days'), @@ -1021,7 +1140,6 @@ def view_today_tasks(): st.session_state.tracker.update_task( task['main_project_name'], task['task_name'], - due_date=task.get('due_date'), recurring=task.get('recurring'), frequency=task.get('frequency'), userdefined_days=task.get('userdefined_days'), @@ -1046,7 +1164,6 @@ def view_today_tasks(): task['main_project_name'], task['task_name'], status='done', - due_date=task.get('due_date'), recurring=task.get('recurring'), frequency=task.get('frequency'), userdefined_days=task.get('userdefined_days'), @@ -1203,7 +1320,12 @@ def view_email_assignment(): due_date=final_due_date, today=new_today_flag, note=new_note, - task_id=task['id'] + task_id=task['id'], + # This form always shows the whole task, so an empty + # date field means the user cleared it - not that they + # left the current one alone (which is what an omitted + # due_date means to update_task). + clear_due_date=final_due_date is None, ): set_feedback(_("Task details updated successfully.")) # Clear session state for this task's date input to ensure fresh load next time @@ -1355,6 +1477,36 @@ def view_reporting(): if st.button(_("Back"), use_container_width=True): navigate_to('today_view') +def _sync_error_message(code): + """ + Turns a sync-client error code into something worth reading. + + The distinction that matters is between "you typed something wrong" and + "the connection failed" - those call for completely different reactions, + and a single "sign-in failed" would leave the user guessing which one + they are looking at. + """ + messages = { + 'no_server': _("No server address is set. Enter one above and save it first."), + 'https_required': _("The address must start with https:// - a token sent over " + "plain HTTP could be read by anyone on the way."), + 'missing_credentials': _("Please enter both a username and a password."), + 'invalid_credentials': _("Wrong username or password."), + 'too_many_attempts': _("Too many sign-in attempts on the server. Try again in a minute."), + 'tls_failed': _("The server's certificate could not be verified."), + 'timeout': _("The server did not answer in time."), + 'unreachable': _("The server could not be reached. Check the address and your connection."), + 'bad_response': _("The address answered, but not like a TimeControl sync server. " + "Check that it points at the right directory."), + 'not_installed': _("The server is reachable but has not been set up yet."), + # Reachable from the background sync rather than the sign-in form. + 'not_signed_in': _("This device is not signed in to the server."), + 'invalid_token': _("This device is no longer signed in. Please sign in again."), + 'local_io': _("The synchronisation files on this computer could not be written."), + } + return messages.get(code, _("Sign-in failed ({code}).").format(code=code or '?')) + + def view_settings(): """ Renders every application setting as a collapsible section in one view @@ -1620,6 +1772,146 @@ def view_settings(): set_feedback(_("MCP server settings saved. Please restart the application for the changes to take effect.")) st.rerun() + with _settings_section("sync", _("Sync Server Settings")): + if not SYNC_AVAILABLE: + st.error(_("The sync client is unavailable because the 'requests' package is missing.")) + else: + sync_cfg = config.get('sync', {}) if isinstance(config.get('sync'), dict) else {} + + # The address and the on/off switch are ordinary settings and + # belong in config.json - copying that file to a second machine + # to give it the same server is exactly the right thing to do. + # The token is not here: see tt/sync_client.py for why it must + # never travel with this file. + with st.form("sync_server_form"): + server_url = st.text_input( + _("Server address"), + value=sync_cfg.get('base_url', ''), + placeholder="https://example.com/tc/", + ) + sync_enabled = st.checkbox( + _("Enable synchronisation"), + value=bool(sync_cfg.get('enabled', False)), + help=_("Without this, TimeControl works entirely locally, exactly as before."), + ) + sync_interval = st.number_input( + _("Sync every (minutes)"), + min_value=1, max_value=120, + value=int(sync_cfg.get('interval_minutes', 5) or 5), + step=1, + help=_("Synchronisation also runs whenever you switch to a different view."), + ) + if st.form_submit_button(_("Save"), use_container_width=True): + # Updated key by key rather than replaced wholesale, so a + # setting this form does not show is not silently dropped + # by saving the ones it does. + saved = dict(sync_cfg) + saved.update({ + 'enabled': bool(sync_enabled), + 'base_url': server_url.strip(), + 'interval_minutes': int(sync_interval), + }) + config['sync'] = saved + save_config(config) + set_feedback(_("Sync server settings saved.")) + st.rerun() + + st.divider() + + # Read from what the background worker last recorded rather than + # asking the server. This runs on every redraw of the settings + # screen - a collapsed section still executes - so a request here + # would put a network round trip behind every keystroke in every + # other form on this page. + creds = sync_client.load_credentials() + snapshot = sync_engine.snapshot() + + # A stored credential is not the same as a working one. The token + # expires after ninety days and the server replaces it when this + # account signs in on a third machine, and in both cases the file + # is still sitting here saying "signed in". Trusting it alone left + # the screen showing "Signed in as ..." with no way to sign in + # again - the one thing the user needs at that moment. + rejected = snapshot['error'] in ('invalid_token', 'not_signed_in') + state = ({'state': 'rejected'} if creds and rejected + else {'state': 'ok', 'username': creds.get('username'), + 'expires_at': creds.get('expires_at')} if creds + else {'state': 'not_configured'}) + + if snapshot['last_ok']: + st.caption(_("Last synchronised at {time}.").format( + time=datetime.fromtimestamp(int(snapshot['last_ok'])).strftime('%Y-%m-%d %H:%M'))) + elif sync_cfg.get('enabled'): + st.caption(_("Not synchronised yet.")) + if snapshot['pending']: + st.caption(_("{count} changes are waiting to be sent.").format( + count=snapshot['pending'])) + if snapshot['error'] and not rejected: + st.warning(_sync_error_message(snapshot['error'])) + + if state['state'] == 'ok': + st.success(_("Signed in as {user}.").format(user=state.get('username'))) + if state.get('expires_at'): + st.caption(_("Access expires on {date}.").format( + date=datetime.fromtimestamp(int(state['expires_at'])).strftime('%Y-%m-%d'))) + col_check, col_out = st.columns(2) + with col_check: + if st.button(_("Check connection"), use_container_width=True, + key="sync_check_btn"): + with st.spinner(_("Contacting the server...")): + checked = sync_client.status() + # Reported once, through the ordinary feedback slot, + # rather than stored. A pinned result goes stale: one + # failed check on a train would keep saying the server + # is unreachable long after it came back, next to a + # "last synchronised" line proving otherwise. + if checked['state'] == 'ok': + set_feedback(_("The server answered.")) + else: + set_feedback(_sync_error_message( + checked.get('error') or checked['state']), 'error') + # Asked for explicitly, so this also lifts any pause + # a run of failures has put the worker into. + sync_engine.nudge(force=True) + st.rerun() + with col_out: + if st.button(_("Sign out"), use_container_width=True, key="sync_logout_btn"): + with st.spinner(_("Contacting the server...")): + sync_client.logout() + set_feedback(_("Signed out on this device.")) + st.rerun() + else: + if state['state'] == 'rejected': + # Expired, revoked elsewhere, or the account was switched + # off. The user's next move is the same in every case. + st.warning(_("This device is no longer signed in. Please sign in again.")) + elif state['state'] == 'unreachable': + st.error(_("The server could not be reached ({reason}).").format( + reason=state.get('error', 'unreachable'))) + + with st.form("sync_login_form"): + st.caption(_("Signing in stores an access token for this device only. " + "It is kept outside the project directory and is never " + "written to config.json.")) + sync_user = st.text_input(_("Username"), key="sync_login_user") + sync_pass = st.text_input(_("Password"), type="password", key="sync_login_pass") + if st.form_submit_button(_("Sign in"), use_container_width=True): + with st.spinner(_("Contacting the server...")): + result = sync_client.login(sync_cfg.get('base_url', ''), + sync_user, sync_pass) + if result.get('ok'): + # force, because the reason the worker had given + # up - no credential - is exactly what just changed. + sync_engine.nudge(force=True) + set_feedback(_("Signed in successfully.")) + else: + set_feedback(_sync_error_message(result.get('error')), 'error') + st.rerun() + + identity = sync_client.device_identity() + st.caption(_("This device: {name} ({uid})").format( + name=identity['device_name'], uid=identity['device_uid'])) + st.divider() if st.button(_("Back"), use_container_width=True): @@ -2580,7 +2872,11 @@ def view_edit_task_form(): frequency=final_freq, userdefined_days=ud_days, priority=priority, - task_id=task_id + task_id=task_id, + # The edit form submits every field at once, so an empty + # date field is the user removing the due date rather than + # declining to change it. + clear_due_date=final_due is None, ): set_feedback(_("Task updated successfully.")) if 'edit_due_date' in st.session_state: del st.session_state.edit_due_date diff --git a/tests/test_TimeTracker.py b/tests/test_TimeTracker.py index 41c800a..333a807 100644 --- a/tests/test_TimeTracker.py +++ b/tests/test_TimeTracker.py @@ -70,7 +70,10 @@ def test_get_task_helper(self): def test_load_data_initial_empty(self): """Tests if _load_data returns an empty dictionary when no file exists.""" - self.assertEqual(self.tracker.data, {"projects": [], "next_id": 1}) + self.assertEqual( + self.tracker.data, + {"projects": [], "next_id": 1, "_deleted": [], "schema_version": 2}, + ) def test_save_and_load_data(self): """Tests the interaction of _save_data and _load_data.""" @@ -116,6 +119,224 @@ def test_migrate_data_structure_adds_new_fields(self): # predates the field entirely. self.assertEqual(task.get("priority"), 0) + def test_migration_assigns_uids_to_everything(self): + """A schema-1 file gains a uid on every project, task and time entry.""" + old_data = { + "projects": [{ + "main_project_name": "Old Project", + "tasks": [{ + "task_name": "Old Task", + "time_entries": [ + {"start_time": "2026-01-02T09:00:00", "end_time": "2026-01-02T10:00:00"}, + {"start_time": "2026-01-03T09:00:00"}, + ] + }] + }] + } + with open(TEST_FILE_PATH, 'w') as f: + json.dump(old_data, f) + + tracker = TimeTracker(file_path=TEST_FILE_PATH) + + self.assertEqual(tracker.data["schema_version"], TimeTracker.SCHEMA_VERSION) + self.assertEqual(tracker.data["_deleted"], []) + + project = tracker.data["projects"][0] + task = project["tasks"][0] + uids = [project["uid"], task["uid"]] + [e["uid"] for e in task["time_entries"]] + + for uid in uids: + self.assertIsInstance(uid, str) + self.assertEqual(len(uid), 16) + # Every entity must get its OWN identity - a shared one would make + # them indistinguishable to anything addressing them by uid. + self.assertEqual(len(set(uids)), len(uids)) + + # last_started is seeded from the newest entry so the existing + # most-recently-used ordering survives the later switch away from + # array position. + self.assertEqual(task["last_started"], "2026-01-03T09:00:00") + self.assertEqual(project["last_started"], "2026-01-03T09:00:00") + + def test_migration_is_idempotent(self): + """Running the migration again must not re-issue uids or bump next_id.""" + self.tracker.add_main_project("P") + self.tracker.add_task("P", "T") + self.tracker.start_work("P", "T") + + before = json.dumps(self.tracker.data, sort_keys=True) + + reloaded = TimeTracker(file_path=TEST_FILE_PATH) + self.assertFalse(reloaded._migrate_data_structure()) + self.assertEqual(json.dumps(reloaded.data, sort_keys=True), before) + + def test_new_entities_get_a_uid_without_a_restart(self): + """Projects, tasks and time entries are born with a uid, not given one later.""" + self.tracker.add_main_project("Fresh Project") + project = self.tracker.data["projects"][0] + self.assertTrue(project.get("uid")) + + self.tracker.add_task("Fresh Project", "Fresh Task") + task = project["tasks"][0] + self.assertTrue(task.get("uid")) + self.assertNotEqual(task["uid"], project["uid"]) + + self.tracker.start_work("Fresh Project", "Fresh Task") + entry = task["time_entries"][0] + self.assertTrue(entry.get("uid")) + self.assertNotEqual(entry["uid"], task["uid"]) + + def test_promote_and_demote_store_complete_objects(self): + """ + Both used to persist half-built objects that only the next start + completed. A uid has to be assigned exactly once, at creation, so + they must now be stored whole. + """ + self.tracker.add_main_project("Source") + self.tracker.add_task("Source", "Rising Task") + self.tracker.start_work("Source", "Rising Task") + self.tracker.stop_work() + moved_entry_uid = self.tracker._get_task("Source", "Rising Task")["time_entries"][0]["uid"] + + ok, _msg = self.tracker.promote_task_to_project("Source", "Rising Task") + self.assertTrue(ok) + + promoted = self.tracker._get_project("Rising Task") + self.assertTrue(promoted.get("uid")) + self.assertEqual(promoted.get("status"), "open") + general = promoted["tasks"][0] + self.assertTrue(general.get("uid")) + self.assertIsInstance(general.get("id"), int) + self.assertEqual(general.get("priority"), 0) + # The entry moved with the task - it is the same entry, so it keeps + # the identity it already had rather than becoming a new one. + self.assertEqual(general["time_entries"][0]["uid"], moved_entry_uid) + + ok, _msg = self.tracker.demote_main_project("Rising Task", "Source") + self.assertTrue(ok) + + demoted = self.tracker._get_task("Source", "Rising Task") + self.assertTrue(demoted.get("uid")) + self.assertIsInstance(demoted.get("id"), int) + self.assertEqual(demoted.get("status"), "open") + self.assertEqual(demoted["time_entries"][0]["uid"], moved_entry_uid) + + def test_next_id_is_lifted_above_a_stale_counter(self): + """ + next_id used to be seeded only when absent and never re-checked, so a + file arriving from elsewhere could leave it at or below a live id - + and the next add_task() would then mint a duplicate. + """ + stale = { + "next_id": 2, + "projects": [{ + "main_project_name": "P", + "tasks": [ + {"task_name": "A", "id": 7, "time_entries": []}, + {"task_name": "B", "id": 9, "time_entries": []}, + ] + }] + } + with open(TEST_FILE_PATH, 'w') as f: + json.dump(stale, f) + + tracker = TimeTracker(file_path=TEST_FILE_PATH) + self.assertEqual(tracker.data["next_id"], 10) + + tracker.add_task("P", "C") + ids = [t["id"] for t in tracker.data["projects"][0]["tasks"]] + self.assertEqual(len(set(ids)), len(ids), "add_task reused an id already in use") + + def test_get_task_prefers_the_id_over_an_earlier_name_match(self): + """ + Callers routinely pass an id and a name together. The id names exactly + one task; the name may name several, since duplicates are creatable. + Both checks used to run in one pass, so the first name match won. + """ + self.tracker.add_main_project("P") + self.tracker.add_task("P", "Same Name") + self.tracker.add_task("P", "Same Name") + wanted = self.tracker.data["projects"][0]["tasks"][1] + + found = self.tracker._get_task("P", "Same Name", task_id=wanted["id"]) + + self.assertEqual(found["uid"], wanted["uid"]) + + def test_start_work_starts_the_task_carrying_the_given_id(self): + """The same precedence, on the path that used to carry its own copy.""" + self.tracker.add_main_project("P") + self.tracker.add_task("P", "Same Name") + self.tracker.add_task("P", "Same Name") + wanted = self.tracker.data["projects"][0]["tasks"][1] + other = self.tracker.data["projects"][0]["tasks"][0] + + self.assertTrue(self.tracker.start_work("P", "Same Name", task_id=wanted["id"])) + + self.assertEqual(len(wanted["time_entries"]), 1) + self.assertEqual(other["time_entries"], []) + + def test_stop_work_never_ends_an_entry_before_it_began(self): + """ + Durations are these two timestamps subtracted, so a negative one does + not announce itself - it just makes every report containing it wrong. + """ + self.tracker.add_main_project("P") + self.tracker.add_task("P", "T") + self.tracker.start_work("P", "T") + + # Stand in for a clock corrected backwards between start and stop. + entry = self.tracker._get_task("P", "T")["time_entries"][-1] + future_start = (datetime.now() + timedelta(hours=2)).isoformat() + entry["start_time"] = future_start + + self.assertTrue(self.tracker.stop_work()) + + entry = self.tracker._get_task("P", "T")["time_entries"][-1] + self.assertEqual(entry["end_time"], future_start) + self.assertGreaterEqual(entry["end_time"], entry["start_time"]) + + def test_reload_data_migrates_what_it_reads(self): + """ + Picking up another process's changes has to normalise too - the file + can come from an older version or another machine, and add_task reads + next_id with no guard for its absence. + """ + self.tracker.add_main_project("Placeholder") + + legacy = { + "projects": [{ + "main_project_name": "From Elsewhere", + "sub_projects": [{"task_name": "Legacy", "time_entries": []}] + }] + } + with open(TEST_FILE_PATH, 'w') as f: + json.dump(legacy, f) + + self.tracker.reload_data() + + self.assertEqual(self.tracker.data["schema_version"], TimeTracker.SCHEMA_VERSION) + self.assertIn("next_id", self.tracker.data) + task = self.tracker.data["projects"][0]["tasks"][0] + self.assertTrue(task.get("uid")) + self.assertEqual(task.get("priority"), 0) + + # The point of all of it: the class can work with what it just read. + self.assertTrue(self.tracker.add_task("From Elsewhere", "Fresh")) + + def test_reload_data_keeps_previous_data_when_the_file_is_unreadable(self): + """A transient read failure must not leave the tracker holding nothing.""" + self.tracker.add_main_project("Keep Me") + + with open(TEST_FILE_PATH, 'w') as f: + f.write("{ this is not json") + + with self.assertRaises(json.JSONDecodeError): + self.tracker.reload_data() + + self.assertEqual( + [p["main_project_name"] for p in self.tracker.data["projects"]], ["Keep Me"] + ) + def test_format_duration(self): """Tests the _format_duration helper method.""" # Test case 1: 8 hours -> 0,200 DLP @@ -274,6 +495,140 @@ def test_delete_main_project_not_found(self): self.assertFalse(success) self.assertEqual(len(self.tracker.data["projects"]), 1) + # --- Tombstones ------------------------------------------------------- + # A deleted object leaves no trace in the data that is left behind, so + # "the other copy has something we do not" cannot be told apart from "we + # deleted it" without one of these notes. + + def _tombstones(self, kind=None): + """Returns the recorded tombstones, optionally filtered by kind.""" + notes = self.tracker.data.get("_deleted", []) + return [n for n in notes if kind is None or n["kind"] == kind] + + def test_delete_project_records_project_and_its_tasks(self): + """Deleting a project notes the project and every task it took down.""" + self.tracker.add_main_project("Doomed") + self.tracker.add_task("Doomed", "T1") + self.tracker.add_task("Doomed", "T2") + self.tracker.start_work("Doomed", "T1") + self.tracker.stop_work() + + project = self.tracker._get_project("Doomed") + project_uid = project["uid"] + task_uids = {t["uid"] for t in project["tasks"]} + + self.assertTrue(self.tracker.delete_main_project("Doomed")) + + self.assertEqual([n["uid"] for n in self._tombstones("project")], [project_uid]) + self.assertEqual({n["uid"] for n in self._tombstones("task")}, task_uids) + # Time entries cannot be deleted on their own anywhere in this class, + # so they are covered by their task's note and get none themselves. + self.assertEqual(self._tombstones("entry"), []) + + def test_delete_task_records_a_tombstone(self): + """Deleting a single task notes exactly that task.""" + self.tracker.add_main_project("P") + self.tracker.add_task("P", "Keep") + self.tracker.add_task("P", "Drop") + dropped_uid = self.tracker._get_task("P", "Drop")["uid"] + + self.assertTrue(self.tracker.delete_task("P", "Drop")) + + self.assertEqual([n["uid"] for n in self._tombstones()], [dropped_uid]) + + def test_delete_all_closed_tasks_records_each_one(self): + """The bulk delete notes every task it removes, not just one.""" + self.tracker.add_main_project("P") + for name in ("A", "B", "C"): + self.tracker.add_task("P", name) + self.tracker.close_task("P", "A") + self.tracker.close_task("P", "C") + closed_uids = { + self.tracker._get_task("P", n, )["uid"] for n in ("A", "C") + } + + self.assertEqual(self.tracker.delete_all_closed_tasks(), 2) + self.assertEqual({n["uid"] for n in self._tombstones("task")}, closed_uids) + + def test_moving_a_task_records_nothing(self): + """ + move_task takes a task out of one project to put it in another. The + task lives on, so noting it as deleted would destroy it on every other + copy - the note has to follow intent, not the list operation. + """ + self.tracker.add_main_project("From") + self.tracker.add_main_project("To") + self.tracker.add_task("From", "Traveller") + uid_before = self.tracker._get_task("From", "Traveller")["uid"] + + self.assertTrue(self.tracker.move_task("From", "Traveller", "To")) + + self.assertEqual(self._tombstones(), []) + # ...and it is still the same task, not a copy. + self.assertEqual(self.tracker._get_task("To", "Traveller")["uid"], uid_before) + + def test_promote_records_the_task_but_not_its_entries(self): + """Promoting destroys the task object; its time entries are re-homed.""" + self.tracker.add_main_project("P") + self.tracker.add_task("P", "Rising") + self.tracker.start_work("P", "Rising") + self.tracker.stop_work() + task = self.tracker._get_task("P", "Rising") + task_uid, entry_uid = task["uid"], task["time_entries"][0]["uid"] + + ok, _msg = self.tracker.promote_task_to_project("P", "Rising") + self.assertTrue(ok) + + self.assertEqual([n["uid"] for n in self._tombstones()], [task_uid]) + surviving = self.tracker._get_project("Rising")["tasks"][0]["time_entries"][0] + self.assertEqual(surviving["uid"], entry_uid) + + def test_demote_records_the_project_and_its_tasks(self): + """Demoting destroys the project and its tasks; the entries survive.""" + self.tracker.add_main_project("Parent") + self.tracker.add_main_project("Sinking") + self.tracker.add_task("Sinking", "Inner") + self.tracker.start_work("Sinking", "Inner") + self.tracker.stop_work() + + sinking = self.tracker._get_project("Sinking") + project_uid = sinking["uid"] + inner_uid = sinking["tasks"][0]["uid"] + entry_uid = sinking["tasks"][0]["time_entries"][0]["uid"] + + ok, _msg = self.tracker.demote_main_project("Sinking", "Parent") + self.assertTrue(ok) + + self.assertEqual([n["uid"] for n in self._tombstones("project")], [project_uid]) + self.assertEqual([n["uid"] for n in self._tombstones("task")], [inner_uid]) + merged = self.tracker._get_task("Parent", "Sinking") + self.assertEqual(merged["time_entries"][0]["uid"], entry_uid) + + def test_expired_tombstones_are_swept_but_recent_ones_kept(self): + """ + Tombstones expire, or the document would grow for ever - but only well + past the point any copy could still be carrying the deleted object. + """ + old = (datetime.now() - timedelta(days=TimeTracker.TOMBSTONE_RETENTION_DAYS + 1)).isoformat() + recent = (datetime.now() - timedelta(days=1)).isoformat() + data = { + "schema_version": 2, + "next_id": 1, + "projects": [], + "_deleted": [ + {"uid": "expired000000000", "kind": "task", "at": old}, + {"uid": "recent0000000000", "kind": "task", "at": recent}, + ], + } + with open(TEST_FILE_PATH, 'w') as f: + json.dump(data, f) + + tracker = TimeTracker(file_path=TEST_FILE_PATH) + + self.assertEqual( + [n["uid"] for n in tracker.data["_deleted"]], ["recent0000000000"] + ) + def test_rename_main_project_success(self): """Tests the successful renaming of a main project.""" self.tracker.add_main_project("Old Project Name") @@ -384,6 +739,58 @@ def test_update_task_priority_unchanged_when_omitted(self): sub = self.tracker.list_tasks("Main")[0] self.assertEqual(sub["priority"], 7) + def test_update_task_due_date_unchanged_when_omitted(self): + """ + Regression test: due_date used to be the one parameter that was + written unconditionally, so every caller that omitted it - a REST + PATCH of just the priority, a GUI button toggling 'today' - erased + the task's due date as a side effect of the change it did ask for. + """ + self.tracker.add_main_project("Main") + self.tracker.add_task("Main", "Task", due_date="2026-08-09", priority=1) + + self.tracker.update_task("Main", "Task", priority=4) + + sub = self.tracker.list_tasks("Main")[0] + self.assertEqual(sub["due_date"], "2026-08-09") + self.assertEqual(sub["priority"], 4) + + def test_update_task_clear_due_date(self): + """Removing a due date is possible, but has to be asked for.""" + self.tracker.add_main_project("Main") + self.tracker.add_task("Main", "Task", due_date="2026-08-09") + + self.tracker.update_task("Main", "Task", clear_due_date=True) + + self.assertIsNone(self.tracker.list_tasks("Main")[0]["due_date"]) + + def test_update_task_clear_due_date_beats_a_passed_due_date(self): + """clear_due_date wins over due_date, as its docstring promises.""" + self.tracker.add_main_project("Main") + self.tracker.add_task("Main", "Task", due_date="2026-08-09") + + self.tracker.update_task("Main", "Task", due_date="2026-09-01", clear_due_date=True) + + self.assertIsNone(self.tracker.list_tasks("Main")[0]["due_date"]) + + def test_update_task_recurring_instance_still_follows_the_old_due_date(self): + """ + Completing a recurring task without restating its due date has to keep + scheduling the next instance from the current one, and leave the + completed task's own due date intact. + """ + self.tracker.add_main_project("Recurring Due Test") + self.tracker.add_task("Recurring Due Test", "Daily Task", + due_date="2026-08-09", recurring=True, frequency="daily") + + self.tracker.update_task("Recurring Due Test", "Daily Task", status="done") + + tasks = self.tracker.list_tasks("Recurring Due Test", status_filter='all') + done_task = next(t for t in tasks if t["status"] == "done") + open_task = next(t for t in tasks if t["status"] == "open") + self.assertEqual(done_task["due_date"], "2026-08-09") + self.assertEqual(open_task["due_date"], "2026-08-10") + def test_recurring_task_new_instance_today_flag(self): """Tests that a new instance of a recurring task is created with today=False.""" self.tracker.add_main_project("Recurring Test") @@ -931,6 +1338,72 @@ def test_start_work_reorders_projects(self): self.tracker.start_work("P2", "S3") self.assertEqual([p['main_project_name'] for p in self.tracker.data['projects']], ["P2", "P1"]) + def test_start_work_records_last_started(self): + """Starting work stamps the task and its project, not just their position.""" + self.tracker.add_main_project("P1") + self.tracker.add_task("P1", "T1") + self.tracker.start_work("P1", "T1") + + task = self.tracker._get_task("P1", "T1") + entry_start = task["time_entries"][-1]["start_time"] + + self.assertEqual(task["last_started"], entry_start) + self.assertEqual(self.tracker._get_project("P1")["last_started"], entry_start) + + def test_ordering_is_derivable_from_last_started(self): + """ + The property a future sync rests on: the most-recently-used order is a + function of last_started, not of where an item happens to sit in the + array. Two machines that agree on last_started therefore arrive at the + same order without ever exchanging positions - which is what makes the + ordering reconcilable at all. + """ + self.tracker.add_main_project("P1") + self.tracker.add_task("P1", "T1") + self.tracker.add_task("P1", "T2") + self.tracker.add_main_project("P2") + self.tracker.add_task("P2", "T3") + + self.tracker.start_work("P1", "T2") + self.tracker.start_work("P2", "T3") + self.tracker.start_work("P1", "T1") + + expected_projects = [p["main_project_name"] for p in self.tracker.data["projects"]] + expected_tasks = [t["task_name"] for t in self.tracker._get_project("P1")["tasks"]] + + # Scramble the stored order, then rebuild it from last_started alone. + self.tracker.data["projects"].reverse() + self.tracker._get_project("P1")["tasks"].reverse() + self.tracker._sort_by_last_started(self.tracker.data["projects"]) + self.tracker._sort_by_last_started(self.tracker._get_project("P1")["tasks"]) + + self.assertEqual( + [p["main_project_name"] for p in self.tracker.data["projects"]], + expected_projects, + ) + self.assertEqual( + [t["task_name"] for t in self.tracker._get_project("P1")["tasks"]], + expected_tasks, + ) + + def test_never_started_items_sort_last_in_creation_order(self): + """ + A project nobody has worked on has no last_started to sort by. It goes + to the end, and among such projects creation order survives - which is + where an unstarted project sat before the switch too. + """ + self.tracker.add_main_project("Never A") + self.tracker.add_main_project("Worked") + self.tracker.add_task("Worked", "T") + self.tracker.add_main_project("Never B") + + self.tracker.start_work("Worked", "T") + + self.assertEqual( + [p["main_project_name"] for p in self.tracker.data["projects"]], + ["Worked", "Never A", "Never B"], + ) + def test_stop_work_success(self): """Tests the successful stopping of work.""" self._create_mock_project_with_task("P1", "T1") diff --git a/tests/test_TimeTrackerMCP_Server.py b/tests/test_TimeTrackerMCP_Server.py index 833cb5d..e823f2b 100644 --- a/tests/test_TimeTrackerMCP_Server.py +++ b/tests/test_TimeTrackerMCP_Server.py @@ -270,7 +270,7 @@ def test_set_today_flag_for_due_tasks_changed(self): result = self.mcp_server.set_today_flag_for_due_tasks() self.assertIn("Marked", result) - # --- update_task (and its due-date preservation safeguard) --- + # --- update_task (and its due-date handling) --- def test_update_task_not_found(self): self.mock_tracker.list_tasks.return_value = [] @@ -278,12 +278,13 @@ def test_update_task_not_found(self): self.mock_tracker.update_task.assert_not_called() self.assertIn("not found", result) - def test_update_task_preserves_due_date_when_unspecified(self): + def test_update_task_passes_an_omitted_due_date_straight_through(self): """ - Regression-style test for the due-date footgun: TimeTracker.update_task - always overwrites due_date with whatever is passed (defaulting to - None), so omitting it here must resolve to the task's *current* - due date instead of silently clearing it. + Keeping an unspecified due date is TimeTracker.update_task's own job + now, so an omission is forwarded as None ("leave it alone") rather + than resolved to the task's current value here - this server used to + have to look the current value up and re-send it, because an omitted + due_date used to clear the date instead of preserving it. """ self.mock_tracker.list_tasks.return_value = [ {"id": 1, "task_name": "Write docs", "due_date": "2026-02-01"} @@ -294,9 +295,10 @@ def test_update_task_preserves_due_date_when_unspecified(self): self.mock_tracker.update_task.assert_called_once_with( "Acme", "Write docs", - new_task_name=None, due_date="2026-02-01", today=None, + new_task_name=None, due_date=None, today=None, note="Updated note", status=None, recurring=None, frequency=None, userdefined_days=None, priority=None, task_id=1, + clear_due_date=False, ) self.assertIn("updated", result) @@ -328,7 +330,7 @@ def test_update_task_clear_due_date(self): self.mcp_server.update_task("Acme", "Write docs", clear_due_date=True) _args, kwargs = self.mock_tracker.update_task.call_args - self.assertIsNone(kwargs["due_date"]) + self.assertTrue(kwargs["clear_due_date"]) def test_update_task_explicit_due_date_overrides_current(self): self.mock_tracker.list_tasks.return_value = [ diff --git a/tests/test_TimeTrackerREST_Server.py b/tests/test_TimeTrackerREST_Server.py index a4eb4d9..63577d0 100644 --- a/tests/test_TimeTrackerREST_Server.py +++ b/tests/test_TimeTrackerREST_Server.py @@ -240,7 +240,8 @@ def test_update_task(self): }) self.assertEqual(r.status_code, 200) self.mock_tracker.update_task.assert_called_once_with( - "Main", "Old", "New", "2025-01-01", True, "Note", "done", None, None, None, None, task_id=None + "Main", "Old", "New", "2025-01-01", True, "Note", "done", None, None, None, None, + task_id=None, clear_due_date=False ) self.assertEqual(r.json(), {"success": True}) @@ -249,7 +250,8 @@ def test_update_task_priority(self): r = self.client.patch("/projects/Main/tasks/Old", json={"priority": 3}) self.assertEqual(r.status_code, 200) self.mock_tracker.update_task.assert_called_once_with( - "Main", "Old", None, None, None, None, None, None, None, None, 3, task_id=None + "Main", "Old", None, None, None, None, None, None, None, None, 3, + task_id=None, clear_due_date=False ) def test_update_task_priority_out_of_range_rejected(self): @@ -257,6 +259,26 @@ def test_update_task_priority_out_of_range_rejected(self): self.assertEqual(r.status_code, 422) self.mock_tracker.update_task.assert_not_called() + def test_update_task_omitting_due_date_does_not_clear_it(self): + """ + A PATCH says nothing about the fields it leaves out, so a body without + a due_date must not ask for the due date to be removed - it used to, + which erased the date whenever anything else was patched. + """ + self.mock_tracker.update_task.return_value = True + r = self.client.patch("/projects/Main/tasks/Old", json={"note": "Note"}) + self.assertEqual(r.status_code, 200) + _args, kwargs = self.mock_tracker.update_task.call_args + self.assertFalse(kwargs["clear_due_date"]) + + def test_update_task_clear_due_date(self): + """Removing a due date over REST is an explicit request.""" + self.mock_tracker.update_task.return_value = True + r = self.client.patch("/projects/Main/tasks/Old", json={"clear_due_date": True}) + self.assertEqual(r.status_code, 200) + _args, kwargs = self.mock_tracker.update_task.call_args + self.assertTrue(kwargs["clear_due_date"]) + def test_move_task(self): self.mock_tracker.move_task.return_value = (True, "Moved successfully") r = self.client.post("/projects/Main/tasks/Sub/move", json={"new_main_project_name": "Other"}) diff --git a/tests/test_TimeTrackerSOAP_Server.py b/tests/test_TimeTrackerSOAP_Server.py index 663700a..34c5ab2 100644 --- a/tests/test_TimeTrackerSOAP_Server.py +++ b/tests/test_TimeTrackerSOAP_Server.py @@ -128,7 +128,8 @@ def test_update_task(self): self.ctx, "Main", "Old", "New", "2025-01-01", True, "Note", "done" ) self.mock_tracker.update_task.assert_called_with( - "Main", "Old", "New", "2025-01-01", True, "Note", "done", None, None, None, priority=None + "Main", "Old", "New", "2025-01-01", True, "Note", "done", None, None, None, + priority=None, clear_due_date=False ) self.assertTrue(result) @@ -144,10 +145,25 @@ def test_update_task_with_priority(self): self.ctx, "Main", "Old", "New", "2025-01-01", True, "Note", "done", None, None, None, 7, 4 ) self.mock_tracker.update_task.assert_called_with( - "Main", "Old", "New", "2025-01-01", True, "Note", "done", None, None, None, priority=4, task_id=7 + "Main", "Old", "New", "2025-01-01", True, "Note", "done", None, None, None, + priority=4, task_id=7, clear_due_date=False ) self.assertTrue(result) + def test_update_task_clear_due_date(self): + """ + clear_due_date is appended last in the @rpc signature, for the same + positional-dispatch reason as priority. A caller that omits it gets + None from spyne, which must not read as a request to clear. + """ + self.mock_tracker.update_task.return_value = True + result = self.soap_server.TimeControlService.update_task( + self.ctx, "Main", "Old", None, None, None, None, None, None, None, None, None, None, True + ) + _args, kwargs = self.mock_tracker.update_task.call_args + self.assertTrue(kwargs["clear_due_date"]) + self.assertTrue(result) + def test_start_work(self): self.mock_tracker.start_work.return_value = True self.assertTrue(self.soap_server.TimeControlService.start_work(self.ctx, "Main", "Sub")) diff --git a/tests/test_repo_hygiene.py b/tests/test_repo_hygiene.py new file mode 100644 index 0000000..e98af87 --- /dev/null +++ b/tests/test_repo_hygiene.py @@ -0,0 +1,96 @@ +import json +import os +import subprocess +import unittest + +# Add parent directory to path to import modules from root +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) + +# Fields of config.json's "email" block that must never reach the repository +# with a value in them. imap_server is included alongside the obvious two: it +# names infrastructure, and it has no business in a shipped default config +# either. +EMAIL_FIELDS_THAT_MUST_BE_BLANK = ("imap_server", "user", "password") + + +def _committed(path): + """ + Returns the contents of a path as it exists in HEAD, or None. + + Deliberately reads the committed blob rather than the working tree: the + point is to catch what would be published, and checking the working copy + would instead fail throughout any legitimate local test with real + settings entered - which is exactly when a noisy test suite is least + useful. + + :param path: Repository-relative path to read. + :return: The file contents as text, or None if unavailable. + """ + try: + result = subprocess.run( + ["git", "show", "HEAD:%s" % path], + cwd=REPO_ROOT, + capture_output=True, + text=True, + timeout=15, + ) + except (OSError, subprocess.SubprocessError): + return None + if result.returncode != 0: + return None + return result.stdout + + +class TestRepoHygiene(unittest.TestCase): + """ + Guards against secrets reaching the repository. + + config.json is tracked on purpose - it is the default configuration a + fresh install starts from, and update.py lists it as a protected file so + an update never overwrites a user's own. That also means the live file + and the shipped file are one and the same, so anything typed into the + Settings screen is one routine 'git add -A' away from being published. + The repository is public, so that would be permanent and would reach + every clone and fork. + + Note that .gitignore is no help here: it only applies to files that are + not yet tracked, and both config.json and data.json already are. + """ + + def test_committed_config_has_no_email_credentials(self): + raw = _committed("config.json") + if raw is None: + self.skipTest("config.json is not retrievable from HEAD (no git checkout?)") + + email = json.loads(raw).get("email", {}) + populated = [f for f in EMAIL_FIELDS_THAT_MUST_BE_BLANK if email.get(f)] + + self.assertEqual( + populated, + [], + "config.json in HEAD carries real email settings in %s.\n" + "The repository is public, so committing this publishes it " + "permanently.\n" + "Blank the fields in the Settings screen (or edit config.json), " + "then amend the commit - and remember that removing it in a later " + "commit does NOT remove it from the history." + % ", ".join(populated), + ) + + def test_committed_config_email_stays_disabled(self): + """A shipped default must not have email import switched on.""" + raw = _committed("config.json") + if raw is None: + self.skipTest("config.json is not retrievable from HEAD (no git checkout?)") + + email = json.loads(raw).get("email", {}) + self.assertFalse( + email.get("enabled", False), + "config.json in HEAD has email import enabled. A fresh install " + "would start trying to fetch mail from settings that are not its " + "own.", + ) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_sync_apply.py b/tests/test_sync_apply.py new file mode 100644 index 0000000..73a10a7 --- /dev/null +++ b/tests/test_sync_apply.py @@ -0,0 +1,871 @@ +import copy +import os +import sys +import unittest + +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +from tt.sync_apply import apply_ops + + +def document(*projects): + return {"projects": list(projects), "next_id": 1, "_deleted": [], "schema_version": 2} + + +def project(uid, name="P", tasks=None, status="open"): + return {"uid": uid, "main_project_name": name, "tasks": tasks or [], + "status": status, "last_started": None} + + +def task(uid, name="T", entries=None, tid=1, **extra): + base = {"uid": uid, "id": tid, "task_name": name, "time_entries": entries or [], + "status": "open", "due_date": None, "today": False, "note": "", + "recurring": False, "frequency": "daily", "userdefined_days": 1, + "priority": 0, "last_started": None} + base.update(extra) + return base + + +def entry(uid, start="2026-08-10 09:00:00", end=None): + e = {"uid": uid, "start_time": start} + if end: + e["end_time"] = end + return e + + +def find_task(doc, uid): + for p in doc["projects"]: + for t in p.get("tasks", []): + if t["uid"] == uid: + return t + return None + + +def find_entry(doc, uid): + for p in doc["projects"]: + for t in p.get("tasks", []): + for e in t.get("time_entries", []): + if e["uid"] == uid: + return e, t + return None, None + + +def uids(collection): + return [x["uid"] for x in collection] + + +P1, P2 = "p" * 16, "q" * 16 +T1, T2 = "t" * 16, "u" * 16 +E1, E2 = "e" * 16, "f" * 16 + + +class TestProjects(unittest.TestCase): + + def test_a_project_arrives(self): + doc = document() + apply_ops(doc, [{"s": 1, "op": "project.create", "uid": P1, + "f": {"name": "Website", "status": "open"}}]) + self.assertEqual(doc["projects"][0]["main_project_name"], "Website") + self.assertEqual(doc["projects"][0]["uid"], P1) + self.assertEqual(doc["projects"][0]["tasks"], []) + + def test_the_same_creation_twice_makes_one_project(self): + """ + A push whose response was lost is sent again, and both machines may + pull the same range twice. Applying an operation a second time has to + be harmless or every dropped connection would duplicate data. + """ + doc = document() + op = {"s": 1, "op": "project.create", "uid": P1, "f": {"name": "Website"}} + apply_ops(doc, [op]) + apply_ops(doc, [op]) + self.assertEqual(len(doc["projects"]), 1) + + def test_a_rename_changes_the_project_it_names(self): + doc = document(project(P1, "Old")) + apply_ops(doc, [{"s": 1, "op": "project.set", "uid": P1, "f": {"name": "New"}}]) + self.assertEqual(doc["projects"][0]["main_project_name"], "New") + + def test_deleting_a_project_takes_its_tasks_with_it(self): + doc = document(project(P1, tasks=[task(T1), task(T2, tid=2)])) + apply_ops(doc, [{"s": 1, "op": "project.delete", "uid": P1, "ts": "2026-08-10 12:00:00"}]) + self.assertEqual(doc["projects"], []) + self.assertEqual({t["uid"] for t in doc["_deleted"]}, {P1, T1, T2}) + + +class TestTasks(unittest.TestCase): + + def test_a_task_arrives_with_a_local_id_of_its_own(self): + """ + The integer id is a per-machine handle and is never sent. A task + arriving here has to be given one from this machine's counter. + """ + doc = document(project(P1)) + doc["next_id"] = 42 + apply_ops(doc, [{"s": 1, "op": "task.create", "uid": T1, "project": P1, + "f": {"task_name": "Write", "priority": 5}}]) + arrived = find_task(doc, T1) + self.assertEqual(arrived["task_name"], "Write") + self.assertEqual(arrived["priority"], 5) + self.assertEqual(arrived["id"], 42) + self.assertEqual(doc["next_id"], 43) + + def test_an_arriving_task_gets_every_field_the_app_expects(self): + """ + The operation carries only what was set. Everything the rest of the + app reads has to be present, or a view crashes on a task that came + from the other machine but not on one made here. + """ + doc = document(project(P1)) + apply_ops(doc, [{"s": 1, "op": "task.create", "uid": T1, "project": P1, + "f": {"task_name": "Sparse"}}]) + arrived = find_task(doc, T1) + for field in ("status", "due_date", "today", "note", "recurring", + "frequency", "userdefined_days", "priority", "last_started"): + self.assertIn(field, arrived) + + def test_two_machines_editing_different_fields_both_win(self): + """ + The point of sending changed fields rather than whole objects. A + priority set here and a due date set there must compose; sending + whole tasks, the later one would silently undo the earlier. + """ + doc = document(project(P1, tasks=[task(T1)])) + apply_ops(doc, [ + {"s": 1, "op": "task.set", "uid": T1, "f": {"priority": 7}}, + {"s": 2, "op": "task.set", "uid": T1, "f": {"due_date": "2026-09-01"}}, + ]) + arrived = find_task(doc, T1) + self.assertEqual(arrived["priority"], 7) + self.assertEqual(arrived["due_date"], "2026-09-01") + + def test_the_later_sequence_number_wins_the_same_field(self): + doc = document(project(P1, tasks=[task(T1)])) + apply_ops(doc, [ + {"s": 2, "op": "task.set", "uid": T1, "f": {"priority": 9}}, + {"s": 1, "op": "task.set", "uid": T1, "f": {"priority": 1}}, + ]) + self.assertEqual(find_task(doc, T1)["priority"], 9) + + def test_order_comes_from_the_sequence_number_not_the_list(self): + """ + Pushed and pulled operations are merged into one list, and nothing + guarantees the caller interleaved them correctly. The server's + numbering is the only thing that decides. + """ + doc = document() + apply_ops(doc, [ + {"s": 3, "op": "task.set", "uid": T1, "f": {"task_name": "third"}}, + {"s": 1, "op": "project.create", "uid": P1, "f": {"name": "P"}}, + {"s": 2, "op": "task.create", "uid": T1, "project": P1, "f": {"task_name": "second"}}, + ]) + self.assertEqual(find_task(doc, T1)["task_name"], "third") + + def test_a_moved_task_keeps_its_time(self): + doc = document(project(P1), project(P2, "Other", + tasks=[task(T1, entries=[entry(E1)])])) + apply_ops(doc, [{"s": 1, "op": "task.move", "uid": T1, "project": P1}]) + self.assertEqual(uids(doc["projects"][0]["tasks"]), [T1]) + self.assertEqual(doc["projects"][1]["tasks"], []) + self.assertEqual(uids(find_task(doc, T1)["time_entries"]), [E1]) + + def test_an_unknown_field_is_not_written_into_the_task(self): + """ + The server stores operations without understanding them, so this is + the only place a malformed or hostile field is stopped. + """ + doc = document(project(P1, tasks=[task(T1)])) + apply_ops(doc, [{"s": 1, "op": "task.set", "uid": T1, + "f": {"priority": 3, "id": 999, "uid": "hijacked", "__proto__": 1}}]) + arrived = find_task(doc, T1) + self.assertEqual(arrived["priority"], 3) + self.assertEqual(arrived["id"], 1) + self.assertEqual(arrived["uid"], T1) + self.assertNotIn("__proto__", arrived) + + +class TestDeletionWins(unittest.TestCase): + + def test_an_edit_cannot_bring_back_something_deleted(self): + """ + Without this, a deletion here plus any edit there resurrects the + object - and does so again on every reconcile, for ever. + """ + doc = document(project(P1)) + doc["_deleted"] = [{"uid": T1, "kind": "task", "at": "2026-08-10 10:00:00"}] + report = apply_ops(doc, [ + {"s": 1, "op": "task.create", "uid": T1, "project": P1, "f": {"task_name": "Zombie"}}, + {"s": 2, "op": "task.set", "uid": T1, "f": {"priority": 5}}, + ]) + self.assertIsNone(find_task(doc, T1)) + self.assertEqual(report.ignored, 2) + + def test_a_deletion_arriving_later_still_wins(self): + doc = document(project(P1, tasks=[task(T1)])) + apply_ops(doc, [ + {"s": 1, "op": "task.set", "uid": T1, "f": {"priority": 5}}, + {"s": 2, "op": "task.delete", "uid": T1, "ts": "2026-08-10 11:00:00"}, + {"s": 3, "op": "task.set", "uid": T1, "f": {"priority": 9}}, + ]) + self.assertIsNone(find_task(doc, T1)) + self.assertEqual(uids(doc["_deleted"]), [T1]) + + def test_a_deletion_is_remembered_so_it_can_be_passed_on(self): + """ + The tombstone is what a third machine, or this one after a restore, + learns the deletion from. Removing the object without recording why + would let the next sync recreate it. + """ + doc = document(project(P1, tasks=[task(T1)])) + apply_ops(doc, [{"s": 1, "op": "task.delete", "uid": T1, "ts": "2026-08-10 11:00:00"}]) + self.assertEqual(doc["_deleted"], + [{"uid": T1, "kind": "task", "at": "2026-08-10 11:00:00"}]) + + def test_the_same_deletion_twice_leaves_one_tombstone(self): + doc = document(project(P1, tasks=[task(T1)])) + op = {"s": 1, "op": "task.delete", "uid": T1, "ts": "2026-08-10 11:00:00"} + apply_ops(doc, [op]) + apply_ops(doc, [op]) + self.assertEqual(len(doc["_deleted"]), 1) + + +class TestTimeEntries(unittest.TestCase): + + def test_a_session_arrives_and_is_closed(self): + doc = document(project(P1, tasks=[task(T1)])) + apply_ops(doc, [ + {"s": 1, "op": "entry.add", "uid": E1, "task": T1, "start": "2026-08-10 09:00:00"}, + {"s": 2, "op": "entry.close", "uid": E1, "end": "2026-08-10 10:30:00"}, + ]) + found, parent = find_entry(doc, E1) + self.assertEqual(parent["uid"], T1) + self.assertEqual(found["end_time"], "2026-08-10 10:30:00") + + def test_an_entry_cannot_end_before_it_began(self): + """ + Duration is these two subtracted. The machines' clocks are allowed to + disagree by minutes, so a close really can arrive dated earlier than + the start - and a negative duration does not announce itself, it just + makes every report wrong. + """ + doc = document(project(P1, tasks=[task(T1, entries=[entry(E1, "2026-08-10 09:00:00")])])) + apply_ops(doc, [{"s": 1, "op": "entry.close", "uid": E1, "end": "2026-08-10 08:58:00"}]) + found, _ = find_entry(doc, E1) + self.assertEqual(found["end_time"], "2026-08-10 09:00:00") + + def test_a_corrected_entry_cannot_end_before_it_began_either(self): + doc = document(project(P1, tasks=[task(T1, entries=[ + entry(E1, "2026-08-10 09:00:00", "2026-08-10 10:00:00")])])) + apply_ops(doc, [{"s": 1, "op": "entry.set", "uid": E1, + "f": {"start_time": "2026-08-10 11:00:00"}}]) + found, _ = find_entry(doc, E1) + self.assertEqual(found["end_time"], "2026-08-10 11:00:00") + + def test_a_correction_that_makes_sense_is_left_as_it_is(self): + """ + The clamp exists for an end that precedes its start. It must not fire + on an ordinary correction, or every edited entry would be flattened + to nothing. + """ + doc = document(project(P1, tasks=[task(T1, entries=[ + entry(E1, "2026-08-10 09:00:00", "2026-08-10 10:00:00")])])) + apply_ops(doc, [{"s": 1, "op": "entry.set", "uid": E1, + "f": {"end_time": "2026-08-10 12:00:00"}}]) + found, _ = find_entry(doc, E1) + self.assertEqual(found["start_time"], "2026-08-10 09:00:00") + self.assertEqual(found["end_time"], "2026-08-10 12:00:00") + + def test_a_session_closed_at_a_sensible_time_keeps_it(self): + doc = document(project(P1, tasks=[task(T1, entries=[entry(E1, "2026-08-10 09:00:00")])])) + apply_ops(doc, [{"s": 1, "op": "entry.close", "uid": E1, + "end": "2026-08-10 17:30:00"}]) + found, _ = find_entry(doc, E1) + self.assertEqual(found["end_time"], "2026-08-10 17:30:00") + + def test_an_entry_moves_without_being_copied(self): + doc = document(project(P1, tasks=[task(T1, entries=[entry(E1)]), task(T2, tid=2)])) + apply_ops(doc, [{"s": 1, "op": "entry.move", "uid": E1, "task": T2}]) + _, parent = find_entry(doc, E1) + self.assertEqual(parent["uid"], T2) + self.assertEqual(find_task(doc, T1)["time_entries"], []) + + def test_deleting_an_entry_leaves_the_task(self): + doc = document(project(P1, tasks=[task(T1, entries=[entry(E1), entry(E2)])])) + apply_ops(doc, [{"s": 1, "op": "entry.delete", "uid": E1}]) + self.assertEqual(uids(find_task(doc, T1)["time_entries"]), [E2]) + + +class TestTimeGoesWithTheTaskItBelongedTo(unittest.TestCase): + """ + Deleting a task has always discarded its hours, so a machine receiving + that deletion has to discard them too. Keeping them safe somewhere was + tried and rejected: it left the machine that did the deleting with + nothing and the machine that received it with the hours, permanently. + Divergence that reports nothing is worse than the loss. + """ + + def test_time_booked_against_a_deleted_task_goes_with_it(self): + doc = document(project(P1)) + doc["_deleted"] = [{"uid": T1, "kind": "task", "at": "2026-08-10 08:00:00"}] + + report = apply_ops(doc, [{"s": 1, "op": "entry.add", "uid": E1, "task": T1, + "start": "2026-08-10 09:00:00"}]) + + found, _parent = find_entry(doc, E1) + self.assertIsNone(found) + self.assertEqual(report.discarded_time, 1) + self.assertEqual(uids(doc["projects"]), [P1], + "a container was invented to hold it after all") + + def test_time_moved_onto_a_deleted_task_goes_with_it(self): + doc = document(project(P1, tasks=[task(T2, entries=[entry(E1)], tid=2)])) + doc["_deleted"] = [{"uid": T1, "kind": "task", "at": "2026-08-10 08:00:00"}] + + report = apply_ops(doc, [{"s": 1, "op": "entry.move", "uid": E1, "task": T1, + "ts": "2026-08-10 09:00:00"}]) + + found, _parent = find_entry(doc, E1) + self.assertIsNone(found, "the entry stayed where it was instead of going") + self.assertEqual(report.discarded_time, 1) + + def test_a_deleted_project_takes_time_booked_to_it_afterwards(self): + doc = document(project(P1, tasks=[task(T1)])) + report = apply_ops(doc, [ + {"s": 1, "op": "project.delete", "uid": P1, "ts": "2026-08-10 08:00:00"}, + {"s": 2, "op": "entry.add", "uid": E1, "task": T1, "start": "2026-08-10 09:00:00"}, + ]) + found, _parent = find_entry(doc, E1) + self.assertIsNone(found) + self.assertEqual(report.discarded_time, 1) + + def test_both_machines_end_up_with_the_same_document(self): + """ + The whole reason for discarding. One machine deletes the task; the + other has already booked time to it. Replaying the same log, they must + agree - whichever order each of them happened to learn things in. + """ + deleted_first = document(project(P1)) + deleted_first["_deleted"] = [{"uid": T1, "kind": "task", + "at": "2026-08-10 08:00:00"}] + apply_ops(deleted_first, [ + {"s": 1, "op": "entry.add", "uid": E1, "task": T1, "start": "2026-08-10 09:00:00"}, + {"s": 2, "op": "task.delete", "uid": T1, "ts": "2026-08-10 08:00:00"}, + ]) + + heard_later = document(project(P1, tasks=[task(T1)])) + apply_ops(heard_later, [ + {"s": 1, "op": "entry.add", "uid": E1, "task": T1, "start": "2026-08-10 09:00:00"}, + {"s": 2, "op": "task.delete", "uid": T1, "ts": "2026-08-10 08:00:00"}, + ]) + + self.assertEqual(deleted_first["projects"], heard_later["projects"]) + self.assertIsNone(find_entry(deleted_first, E1)[0]) + self.assertIsNone(find_entry(heard_later, E1)[0]) + + def test_time_for_a_task_that_is_still_there_is_untouched(self): + doc = document(project(P1, tasks=[task(T1)])) + report = apply_ops(doc, [{"s": 1, "op": "entry.add", "uid": E1, "task": T1, + "start": "2026-08-10 09:00:00"}]) + self.assertIsNotNone(find_entry(doc, E1)[0]) + self.assertEqual(report.discarded_time, 0) + + +class TestOneRunningSessionAtATime(unittest.TestCase): + """ + What the user asked for directly: starting a task on the second machine + should end the one still running on the first. + """ + + def test_starting_work_elsewhere_ends_the_session_left_running_here(self): + doc = document(project(P1, tasks=[ + task(T1, "Here", entries=[entry(E1, "2026-08-10 09:00:00")]), + task(T2, "There", tid=2)])) + + report = apply_ops(doc, [{"s": 1, "op": "entry.add", "uid": E2, "task": T2, + "start": "2026-08-10 10:00:00"}]) + + here, _ = find_entry(doc, E1) + there, _ = find_entry(doc, E2) + self.assertEqual(here["end_time"], "2026-08-10 10:00:00") + self.assertNotIn("end_time", there) + self.assertEqual(report.auto_closed, [(E1, "2026-08-10 10:00:00")]) + + def test_no_stretch_of_time_is_counted_twice(self): + """ + The earlier session ends exactly where the later one begins, so the + two do not overlap and the day's total stays honest. + """ + doc = document(project(P1, tasks=[task(T1, entries=[entry(E1, "2026-08-10 09:00:00")])])) + apply_ops(doc, [{"s": 1, "op": "entry.add", "uid": E2, "task": T1, + "start": "2026-08-10 09:30:00"}]) + first, _ = find_entry(doc, E1) + second, _ = find_entry(doc, E2) + self.assertEqual(first["end_time"], second["start_time"]) + + def test_a_running_session_stays_last_in_its_task(self): + """ + Three places recognise the running session as the final entry rather + than by searching for one. An entry finished elsewhere arrives after + it and is appended, pushing it out of last place - and the session + can then no longer be stopped, shown, or counted. + """ + doc = document(project(P1, tasks=[task(T1, entries=[entry(E1, "2026-08-10 09:00:00")])])) + apply_ops(doc, [ + {"s": 1, "op": "entry.add", "uid": E2, "task": T1, "start": "2026-08-10 07:00:00"}, + {"s": 2, "op": "entry.close", "uid": E2, "end": "2026-08-10 08:00:00"}, + ]) + entries = find_task(doc, T1)["time_entries"] + self.assertNotIn("end_time", entries[-1], + "the running session is no longer last and cannot be stopped") + self.assertEqual(entries[-1]["uid"], E1) + + def test_the_later_session_survives_even_if_it_is_listed_first(self): + """ + Which of the two is closed is decided by when they began, not by + where they happen to sit in the file. Operations arrive in the + server's order, not in time order, so the two do come apart. + """ + doc = document(project(P1, tasks=[task(T1, entries=[ + entry(E2, "2026-08-10 11:00:00"), # later, but listed first + entry(E1, "2026-08-10 09:00:00"), + ])])) + report = apply_ops(doc, []) + + first, _ = find_entry(doc, E1) + second, _ = find_entry(doc, E2) + self.assertEqual(first["end_time"], "2026-08-10 11:00:00", + "the earlier session should have been the one closed") + self.assertNotIn("end_time", second) + self.assertEqual(report.auto_closed, [(E1, "2026-08-10 11:00:00")]) + + def test_a_single_running_session_is_left_alone(self): + doc = document(project(P1, tasks=[task(T1, entries=[entry(E1)])])) + report = apply_ops(doc, []) + found, _ = find_entry(doc, E1) + self.assertNotIn("end_time", found) + self.assertEqual(report.auto_closed, []) + + +class TestNothingIsQuietlyBroken(unittest.TestCase): + + def test_the_id_counter_stays_ahead_of_every_task(self): + """ + Left behind, the next task created here reuses an id - and nothing + anywhere checks for that. + """ + doc = document(project(P1, tasks=[task(T1, tid=99)])) + doc["next_id"] = 2 + apply_ops(doc, []) + self.assertEqual(doc["next_id"], 100) + + def test_the_id_counter_is_raised_even_when_it_only_just_collides(self): + """ + The boundary, and the one that bites: next_id equal to an id already + in use. Left alone, the very next task created here is handed a + number another task already has - and nothing anywhere checks. + """ + doc = document(project(P1, tasks=[task(T1, tid=7)])) + doc["next_id"] = 7 + apply_ops(doc, []) + self.assertEqual(doc["next_id"], 8) + + def test_a_counter_already_ahead_is_left_alone(self): + doc = document(project(P1, tasks=[task(T1, tid=7)])) + doc["next_id"] = 20 + apply_ops(doc, []) + self.assertEqual(doc["next_id"], 20) + + def test_an_operation_for_something_unknown_is_counted_not_applied(self): + doc = document() + report = apply_ops(doc, [{"s": 1, "op": "task.set", "uid": T1, "f": {"priority": 1}}]) + self.assertEqual(report.applied, 0) + self.assertEqual(report.ignored, 1) + + def test_an_operation_this_version_does_not_know_is_skipped(self): + """ + A newer version on the other machine may send verbs this one has + never heard of. Skipping one is a missing change; failing on it would + block every later operation for good. + """ + doc = document(project(P1)) + report = apply_ops(doc, [ + {"s": 1, "op": "task.invented", "uid": T1}, + {"s": 2, "op": "project.set", "uid": P1, "f": {"name": "Applied"}}, + ]) + self.assertEqual(doc["projects"][0]["main_project_name"], "Applied") + self.assertEqual(report.applied, 1) + self.assertEqual(report.ignored, 1) + + def test_the_highest_sequence_number_is_reported(self): + """The caller stores it and asks for everything after it next time.""" + doc = document(project(P1)) + report = apply_ops(doc, [ + {"s": 7, "op": "project.set", "uid": P1, "f": {"name": "A"}}, + {"s": 12, "op": "project.set", "uid": P1, "f": {"name": "B"}}, + ]) + self.assertEqual(report.highest_seq, 12) + + def test_applying_nothing_to_a_sound_document_changes_nothing(self): + doc = document(project(P1, tasks=[task(T1, entries=[entry(E1, end="2026-08-10 10:00:00")])])) + doc["next_id"] = 2 + before = copy.deepcopy(doc) + apply_ops(doc, []) + self.assertEqual(doc, before) + + +class TestTheResultIsUsable(unittest.TestCase): + """ + Applying operations produces the file the whole application reads. A + document that is internally consistent but that TimeTracker cannot open + would be a bug found only in front of the user. + """ + + PATH = 'test_apply_data.json' + + def tearDown(self): + if os.path.exists(self.PATH): + os.remove(self.PATH) + + def test_a_document_built_only_from_operations_opens_and_works(self): + import json + from tt.TimeTracker import TimeTracker + + doc = document() + apply_ops(doc, [ + {"s": 1, "op": "project.create", "uid": P1, "f": {"name": "Website"}}, + {"s": 2, "op": "task.create", "uid": T1, "project": P1, + "f": {"task_name": "Relaunch", "priority": 4}}, + {"s": 3, "op": "entry.add", "uid": E1, "task": T1, "start": "2026-08-10 09:00:00"}, + {"s": 4, "op": "entry.close", "uid": E1, "end": "2026-08-10 10:00:00"}, + ]) + with open(self.PATH, 'w', encoding='utf-8') as f: + json.dump(doc, f) + + tracker = TimeTracker(file_path=self.PATH) + tasks = tracker.list_tasks("Website") + self.assertEqual([t["task_name"] for t in tasks], ["Relaunch"]) + self.assertIn("1:00:00", tracker.generate_task_report("Website", "Relaunch")) + + # And it remains a document this machine can go on adding to. + self.assertTrue(tracker.add_task("Website", "Next")) + self.assertIsNotNone(tracker._get_task("Website", "Next")) + + +class TestWhatTheCallerIsToldAsItHappens(unittest.TestCase): + """ + Two things happen during a merge that the user did not ask for on this + machine. Both are reported through the same callback, so the interface + can say something rather than letting them pass unnoticed. + """ + + def test_discarded_time_is_announced(self): + seen = [] + doc = document(project(P1)) + doc["_deleted"] = [{"uid": T1, "kind": "task", "at": "2026-08-10 08:00:00"}] + + apply_ops(doc, [{"s": 1, "op": "entry.add", "uid": E1, "task": T1, + "start": "2026-08-10 09:00:00"}], + on_conflict=lambda kind, detail: seen.append((kind, detail))) + + self.assertEqual([k for k, _ in seen], ['discarded_time']) + self.assertEqual(seen[0][1]['entry'], E1) + self.assertEqual(seen[0][1]['task'], T1) + + def test_an_auto_closed_session_is_announced(self): + seen = [] + doc = document(project(P1, tasks=[ + task(T1, entries=[entry(E1, "2026-08-10 09:00:00")]), + task(T2, tid=2)])) + + apply_ops(doc, [{"s": 1, "op": "entry.add", "uid": E2, "task": T2, + "start": "2026-08-10 10:00:00"}], + on_conflict=lambda kind, detail: seen.append((kind, detail))) + + self.assertEqual([k for k, _ in seen], ['auto_closed']) + self.assertEqual(seen[0][1], {'entry': E1, 'end': "2026-08-10 10:00:00"}) + + def test_nothing_is_announced_when_nothing_had_to_be_decided(self): + seen = [] + doc = document(project(P1, tasks=[task(T1)])) + apply_ops(doc, [{"s": 1, "op": "task.set", "uid": T1, "f": {"priority": 3}}], + on_conflict=lambda kind, detail: seen.append(kind)) + self.assertEqual(seen, []) + + +class TestOperationsThatFindNothingToActOn(unittest.TestCase): + """ + Each of these names something this machine does not have. None may raise, + and none may invent the missing object - a later operation would then be + applied to something the other machine does not have. + """ + + def test_every_verb_survives_a_missing_target(self): + doc = document() + report = apply_ops(doc, [ + {"s": 1, "op": "project.set", "uid": P1, "f": {"name": "x"}}, + {"s": 2, "op": "task.set", "uid": T1, "f": {"priority": 1}}, + {"s": 3, "op": "task.move", "uid": T1, "project": P1}, + {"s": 4, "op": "task.create", "uid": T1, "project": P1, "f": {}}, + {"s": 5, "op": "entry.close", "uid": E1, "end": "2026-08-10 10:00:00"}, + {"s": 6, "op": "entry.set", "uid": E1, "f": {"start_time": "x"}}, + {"s": 7, "op": "entry.move", "uid": E1, "task": T1}, + {"s": 8, "op": "entry.delete", "uid": E1}, + {"s": 9, "op": "project.delete", "uid": P1}, + {"s": 10, "op": "task.delete", "uid": T1}, + ]) + self.assertEqual(doc["projects"], []) + self.assertGreaterEqual(report.ignored, 7) + + def test_an_entry_arriving_already_finished_keeps_its_end(self): + """ + How a rebuild from the log delivers past work: the add carries the + end, rather than a close following it. + """ + doc = document(project(P1, tasks=[task(T1)])) + apply_ops(doc, [{"s": 1, "op": "entry.add", "uid": E1, "task": T1, + "start": "2026-08-10 09:00:00", "end": "2026-08-10 10:00:00"}]) + found, _ = find_entry(doc, E1) + self.assertEqual(found["end_time"], "2026-08-10 10:00:00") + + def test_an_entry_that_arrives_twice_is_not_duplicated(self): + doc = document(project(P1, tasks=[task(T1), task(T2, tid=2)])) + op = {"s": 1, "op": "entry.add", "uid": E1, "task": T1, + "start": "2026-08-10 09:00:00"} + apply_ops(doc, [op]) + apply_ops(doc, [dict(op, s=2, task=T2)]) + + self.assertEqual(find_task(doc, T1)["time_entries"], []) + self.assertEqual(uids(find_task(doc, T2)["time_entries"]), [E1]) + + def test_a_move_to_a_project_this_machine_lacks_is_dropped(self): + """ + Only half of what the move names is missing, which is the case a test + with both missing never reaches: acting on it would hand the mover a + project that is not there. + """ + doc = document(project(P2, "Other", tasks=[task(T1)])) + report = apply_ops(doc, [{"s": 1, "op": "task.move", "uid": T1, "project": P1}]) + + self.assertEqual(uids(doc["projects"][0]["tasks"]), [T1], + "the task was moved somewhere that does not exist") + self.assertEqual(report.ignored, 1) + + def test_time_for_a_task_that_was_never_created_here_is_dropped(self): + """ + Not deleted - simply absent, because the operation that would have + created it was itself dropped. Nothing may be invented to hold it. + """ + doc = document(project(P1)) + report = apply_ops(doc, [{"s": 1, "op": "entry.add", "uid": E1, "task": T1, + "start": "2026-08-10 09:00:00"}]) + + self.assertIsNone(find_entry(doc, E1)[0]) + self.assertEqual(uids(doc["projects"]), [P1]) + self.assertEqual(report.discarded_time, 1) + + def test_moving_an_entry_this_machine_does_not_have_is_dropped(self): + """ + The task is here, the entry is not - it was deleted here, or its + creation never arrived. Nothing may be conjured up to move. + """ + doc = document(project(P1, tasks=[task(T1)])) + report = apply_ops(doc, [{"s": 1, "op": "entry.move", "uid": E1, "task": T1}]) + self.assertEqual(find_task(doc, T1)["time_entries"], []) + self.assertEqual(report.ignored, 1) + self.assertEqual(report.discarded_time, 0, + "counted as lost time when there was no time to lose") + + def test_a_task_with_a_missing_project_is_not_given_one(self): + doc = document() + report = apply_ops(doc, [{"s": 1, "op": "task.create", "uid": T1, + "project": P1, "f": {"task_name": "T"}}]) + self.assertEqual(doc["projects"], []) + self.assertEqual(report.ignored, 1) + + +class TestReconcile(unittest.TestCase): + """ + Incoming work first, then this machine's own unsent work on top - because + that is the order the server will put them in. + """ + + def test_an_unsent_local_change_survives_an_incoming_one(self): + doc = document(project(P1, tasks=[task(T1, priority=5)])) + from tt.sync_apply import reconcile + + reconcile(doc, + incoming=[{"s": 10, "op": "task.set", "uid": T1, "f": {"priority": 9}}], + local=[{"lc": 1, "op": "task.set", "uid": T1, "f": {"priority": 5}}]) + + self.assertEqual(find_task(doc, T1)["priority"], 5) + + def test_an_incoming_change_to_another_field_is_kept(self): + doc = document(project(P1, tasks=[task(T1, priority=5)])) + from tt.sync_apply import reconcile + + reconcile(doc, + incoming=[{"s": 10, "op": "task.set", "uid": T1, "f": {"due_date": "2026-09-01"}}], + local=[{"lc": 1, "op": "task.set", "uid": T1, "f": {"priority": 5}}]) + + arrived = find_task(doc, T1) + self.assertEqual(arrived["priority"], 5) + self.assertEqual(arrived["due_date"], "2026-09-01") + + def test_unsent_work_is_replayed_in_the_order_it_was_made(self): + doc = document(project(P1)) + from tt.sync_apply import reconcile + + reconcile(doc, incoming=[], local=[ + {"lc": 3, "op": "task.set", "uid": T1, "f": {"task_name": "last"}}, + {"lc": 1, "op": "project.create", "uid": P1, "f": {"name": "P"}}, + {"lc": 2, "op": "task.create", "uid": T1, "project": P1, "f": {"task_name": "first"}}, + ]) + + self.assertEqual(find_task(doc, T1)["task_name"], "last") + + def test_unsent_time_goes_too_when_the_task_was_deleted_elsewhere(self): + """ + The hard edge of the rule, and the reason it is written down: work + booked here and not yet sent is discarded if the task it belongs to + was deleted on the other machine. The alternative keeps the hours here + and nowhere else, which is the divergence this design exists to avoid. + """ + doc = document(project(P1, tasks=[task(T1)])) + from tt.sync_apply import reconcile + + report = reconcile( + doc, + incoming=[{"s": 10, "op": "task.delete", "uid": T1, "ts": "2026-08-10 08:00:00"}], + local=[{"lc": 1, "op": "entry.add", "uid": E1, "task": T1, + "start": "2026-08-10 09:00:00"}]) + + self.assertIsNone(find_entry(doc, E1)[0]) + self.assertEqual(report.discarded_time, 1, + "it was dropped without the user being told") + + def test_two_machines_reach_the_same_document(self): + """ + The whole point, end to end. Two machines edit the same task while out + of contact; one reaches the server first. Both must finish identical - + two files that quietly stopped matching is the failure nothing reports. + """ + from tt.sync_apply import reconcile + + start = document(project(P1, tasks=[task(T1, "Shared", priority=1)])) + start["next_id"] = 2 + here, there = copy.deepcopy(start), copy.deepcopy(start) + + # Each machine makes its change locally, before either has synced. + first = [{"lc": 1, "op": "task.set", "uid": T1, "f": {"priority": 8}}] + second = [{"lc": 1, "op": "task.set", "uid": T1, "f": {"due_date": "2026-09-01"}}, + {"lc": 2, "op": "task.set", "uid": T1, "f": {"priority": 3}}] + find_task(here, T1)["priority"] = 8 + find_task(there, T1).update({"due_date": "2026-09-01", "priority": 3}) + + # This machine gets there first: nothing waiting, its own work numbered 1. + reconcile(here, incoming=[], local=first) + # The other pushes next. It sees the first machine's work, and the + # server puts its own after it. + reconcile(there, + incoming=[dict(first[0], s=1)], + local=second) + # And the first machine catches up, now with an empty queue. + reconcile(here, + incoming=[dict(second[0], s=2), dict(second[1], s=3)], + local=[]) + + self.assertEqual(here, there) + self.assertEqual(find_task(here, T1)["priority"], 3) + self.assertEqual(find_task(here, T1)["due_date"], "2026-09-01") + + +class TestSeeding(unittest.TestCase): + """ + The one time a whole document is sent: the first machine to reach an empty + server. It goes as operations, not as a file, so the server never has to + understand the format. + """ + + def test_a_document_is_described_as_the_operations_that_would_build_it(self): + from tt.sync_apply import seed_operations + + doc = document(project(P1, "Website", tasks=[ + task(T1, "Relaunch", entries=[entry(E1, "2026-08-10 09:00:00", "2026-08-10 10:00:00")])])) + ops = seed_operations(doc) + + self.assertEqual([o["op"] for o in ops], + ["project.create", "task.create", "entry.add", "entry.close"]) + self.assertEqual(ops[1]["project"], P1) + self.assertNotIn("id", ops[1]["f"]) + self.assertNotIn("time_entries", ops[1]["f"]) + + def test_a_seeded_document_rebuilds_exactly(self): + """ + Replayed on an empty machine the result has to be the same document, + or the second machine starts out already disagreeing with the first. + """ + from tt.sync_apply import seed_operations + + original = document( + project(P1, "Website", tasks=[ + task(T1, "Relaunch", tid=1, priority=4, due_date="2026-09-01", + entries=[entry(E1, "2026-08-10 09:00:00", "2026-08-10 10:00:00"), + entry(E2, "2026-08-10 11:00:00")])]), + project(P2, "Admin", tasks=[task(T2, "Invoices", tid=2)])) + original["next_id"] = 3 + + ops = seed_operations(original) + rebuilt = document() + apply_ops(rebuilt, [dict(op, s=i) for i, op in enumerate(ops, 1)]) + + self.assertEqual(rebuilt["projects"], original["projects"]) + self.assertEqual(rebuilt["next_id"], original["next_id"]) + + def test_an_entry_with_nothing_to_identify_it_is_left_out(self): + """ + The server checks every uid against a 16-character pattern and + rejects the whole batch if one fails. A single malformed entry - + from a hand-edited file, or an interrupted write - would therefore + stop this machine's document being offered at all. + """ + from tt.sync_apply import seed_operations + + doc = document(project(P1, tasks=[task(T1, entries=[ + {"uid": E1, "start_time": "2026-08-10 09:00:00"}, + {"start_time": "2026-08-10 11:00:00"}, # no uid + {"uid": E2}, # no start + ])])) + ops = seed_operations(doc) + adds = [o for o in ops if o["op"] == "entry.add"] + self.assertEqual([o["uid"] for o in adds], [E1]) + + def test_a_tombstone_that_makes_no_sense_is_left_out(self): + from tt.sync_apply import seed_operations + + doc = document(project(P1)) + doc["_deleted"] = [ + {"uid": T1, "kind": "task", "at": "2026-08-01 09:00:00"}, + {"uid": T2, "kind": "sideways", "at": "2026-08-01 09:00:00"}, + {"kind": "task", "at": "2026-08-01 09:00:00"}, + ] + ops = seed_operations(doc) + deletes = [o for o in ops if o["op"].endswith(".delete")] + self.assertEqual(deletes, [{"op": "task.delete", "uid": T1, + "ts": "2026-08-01 09:00:00"}]) + + def test_deletions_are_seeded_too(self): + """ + A machine seeding from a document that still carries tombstones has to + pass them on, or the others are given no way to know those objects are + meant to stay gone. + """ + from tt.sync_apply import seed_operations + + doc = document(project(P1)) + doc["_deleted"] = [{"uid": T1, "kind": "task", "at": "2026-08-01 09:00:00"}] + ops = seed_operations(doc) + self.assertIn({"op": "task.delete", "uid": T1, "ts": "2026-08-01 09:00:00"}, ops) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_sync_client.py b/tests/test_sync_client.py new file mode 100644 index 0000000..862115f --- /dev/null +++ b/tests/test_sync_client.py @@ -0,0 +1,375 @@ +import json +import os +import shutil +import sys +import tempfile +import unittest +from unittest.mock import patch + +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +from tt import sync_client + + +class _Response: + """Stands in for a requests response.""" + + def __init__(self, payload, status=200): + self._payload = payload + self.status_code = status + + def json(self): + if self._payload is None: + raise ValueError("not json") + return self._payload + + +class TestSyncClientPaths(unittest.TestCase): + """ + Where the credential lives is a correctness question, not a detail: a + frozen build changes the working directory to wherever the .exe sits, so + anything resolved relatively would land next to the program - unwritable + under Program Files, and shared by every account on the machine. + """ + + def test_posix_uses_xdg_config_home(self): + with patch.object(os, 'name', 'posix'), \ + patch.dict(os.environ, {'XDG_CONFIG_HOME': '/tmp/xdg'}, clear=False): + self.assertEqual(sync_client.config_dir(), os.path.join('/tmp/xdg', 'TimeControl')) + + def test_posix_falls_back_to_dot_config(self): + env = {k: v for k, v in os.environ.items() if k != 'XDG_CONFIG_HOME'} + with patch.object(os, 'name', 'posix'), \ + patch.dict(os.environ, env, clear=True): + expected = os.path.join(os.path.expanduser('~'), '.config', 'TimeControl') + self.assertEqual(sync_client.config_dir(), expected) + + def test_windows_uses_appdata(self): + with patch.object(os, 'name', 'nt'), \ + patch.dict(os.environ, {'APPDATA': r'C:\Users\frank\AppData\Roaming'}, clear=False): + self.assertEqual( + sync_client.config_dir(), + os.path.join(r'C:\Users\frank\AppData\Roaming', 'TimeControl'), + ) + + def test_path_is_absolute_and_outside_the_project(self): + """It must never resolve against the working directory.""" + self.assertTrue(os.path.isabs(sync_client.config_dir())) + project = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) + self.assertFalse(sync_client.config_dir().startswith(project + os.sep)) + + def test_endpoint_accepts_the_forms_people_actually_type(self): + for given in ("https://x.de/tc", "https://x.de/tc/", + "https://x.de/tc/index.php", " https://x.de/tc// "): + self.assertEqual(sync_client._endpoint(given), "https://x.de/tc/index.php", given) + + +class TestSyncClientCredentials(unittest.TestCase): + + def setUp(self): + # Redirect the whole credential directory into a temporary one, so no + # test can touch the real ~/.config/TimeControl. + self.tmp = tempfile.mkdtemp() + self._env = patch.dict(os.environ, {'XDG_CONFIG_HOME': self.tmp}, clear=False) + self._env.start() + self._posix = patch.object(os, 'name', 'posix') + self._posix.start() + + def tearDown(self): + self._posix.stop() + self._env.stop() + shutil.rmtree(self.tmp, ignore_errors=True) + + def test_device_identity_is_created_once_and_reused(self): + first = sync_client.device_identity() + self.assertRegex(first['device_uid'], r'^[a-f0-9]{16}$') + self.assertEqual(sync_client.device_identity(), first) + + def test_device_identity_survives_signing_out(self): + """ + Otherwise every sign-in would look like a new machine to the server, + pile up device entries and defeat the idempotency that makes a + repeated sign-in harmless. + """ + identity = sync_client.device_identity() + with patch('tt.sync_client.requests.post', return_value=_Response({'ok': True, 'token': 't'})): + sync_client.login('https://x.de/tc', 'frank', 'pw') + sync_client.logout() + + self.assertIsNone(sync_client.load_credentials()) + self.assertEqual(sync_client.device_identity()['device_uid'], identity['device_uid']) + + def test_successful_login_stores_the_token(self): + reply = {'ok': True, 'token': 'tc1.aa.bb', 'expires_at': 1794000000, 'username': 'frank'} + with patch('tt.sync_client.requests.post', return_value=_Response(reply)) as post: + result = sync_client.login('https://x.de/tc', 'frank', 'passwort') + + self.assertTrue(result['ok']) + stored = sync_client.load_credentials() + self.assertEqual(stored['token'], 'tc1.aa.bb') + self.assertEqual(stored['base_url'], 'https://x.de/tc/index.php') + self.assertEqual(stored['username'], 'frank') + + # The device id sent must be the persisted one, not a fresh one. + sent = json.loads(post.call_args.kwargs['data']) + self.assertEqual(sent['device_uid'], sync_client.device_identity()['device_uid']) + + def test_failed_login_stores_nothing(self): + with patch('tt.sync_client.requests.post', + return_value=_Response({'ok': False, 'error': 'invalid_credentials'})): + result = sync_client.login('https://x.de/tc', 'frank', 'falsch') + self.assertFalse(result['ok']) + self.assertIsNone(sync_client.load_credentials()) + + def test_plain_http_is_refused_before_the_password_is_sent(self): + with patch('tt.sync_client.requests.post') as post: + result = sync_client.login('http://x.de/tc', 'frank', 'passwort') + self.assertEqual(result['error'], 'https_required') + post.assert_not_called() + + def test_credential_file_is_owner_only_on_posix(self): + with patch('tt.sync_client.requests.post', return_value=_Response({'ok': True, 'token': 't'})): + sync_client.login('https://x.de/tc', 'frank', 'pw') + path = sync_client._credentials_path() + self.assertEqual(os.stat(path).st_mode & 0o077, 0, + "the credential is readable by someone other than its owner") + + def test_transport_failures_get_their_own_codes(self): + """ + "Wrong password" and "no network" need different reactions from the + user, so they must not collapse into one error. + """ + import requests as real_requests + cases = [ + (real_requests.exceptions.SSLError, 'tls_failed'), + (real_requests.exceptions.Timeout, 'timeout'), + (real_requests.exceptions.ConnectionError, 'unreachable'), + ] + for exc, expected in cases: + with patch('tt.sync_client.requests.post', side_effect=exc()): + result = sync_client.login('https://x.de/tc', 'frank', 'pw') + self.assertEqual(result['error'], expected) + + def test_non_json_answer_is_reported_as_such(self): + """Another application answering on that path, or an HTML error page.""" + with patch('tt.sync_client.requests.post', return_value=_Response(None, status=500)): + result = sync_client.login('https://x.de/tc', 'frank', 'pw') + self.assertEqual(result['error'], 'bad_response') + + def test_status_without_a_credential(self): + self.assertEqual(sync_client.status()['state'], 'not_configured') + + def test_status_reports_a_rejected_token(self): + with patch('tt.sync_client.requests.post', return_value=_Response({'ok': True, 'token': 't'})): + sync_client.login('https://x.de/tc', 'frank', 'pw') + with patch('tt.sync_client.requests.get', + return_value=_Response({'ok': False, 'error': 'invalid_token'})): + self.assertEqual(sync_client.status()['state'], 'rejected') + + def test_status_separates_unreachable_from_rejected(self): + with patch('tt.sync_client.requests.post', return_value=_Response({'ok': True, 'token': 't'})): + sync_client.login('https://x.de/tc', 'frank', 'pw') + import requests as real_requests + with patch('tt.sync_client.requests.get', side_effect=real_requests.exceptions.Timeout()): + state = sync_client.status() + self.assertEqual(state['state'], 'unreachable') + self.assertEqual(state['error'], 'timeout') + + def test_signing_out_forgets_the_token_even_if_the_server_is_down(self): + with patch('tt.sync_client.requests.post', return_value=_Response({'ok': True, 'token': 't'})): + sync_client.login('https://x.de/tc', 'frank', 'pw') + import requests as real_requests + with patch('tt.sync_client.requests.get', side_effect=real_requests.exceptions.ConnectionError()): + sync_client.logout() + self.assertIsNone(sync_client.load_credentials()) + + def test_signing_out_when_never_signed_in(self): + self.assertEqual(sync_client.logout(), {'ok': True, 'revoked': False}) + + def test_login_says_which_field_is_missing(self): + """ + Two different mistakes with two different remedies, so they must not + collapse into one message - and neither should reach the network. + """ + with patch('tt.sync_client.requests.post') as post: + self.assertEqual(sync_client.login('', 'frank', 'pw')['error'], 'no_server') + self.assertEqual(sync_client.login('https://x.de/tc', '', 'pw')['error'], + 'missing_credentials') + self.assertEqual(sync_client.login('https://x.de/tc', 'frank', '')['error'], + 'missing_credentials') + post.assert_not_called() + + def test_a_success_without_a_token_is_not_treated_as_one(self): + """ + Some other application answering on that path can easily produce a + body with ok: true in it. Storing that would leave a credential file + with no token in it, and the failure would surface much later. + """ + with patch('tt.sync_client.requests.post', + return_value=_Response({'ok': True, 'message': 'hello'})): + result = sync_client.login('https://x.de/tc', 'frank', 'pw') + self.assertFalse(result['ok']) + self.assertEqual(result['error'], 'bad_response') + self.assertIsNone(sync_client.load_credentials()) + + def test_status_reports_a_working_token(self): + with patch('tt.sync_client.requests.post', + return_value=_Response({'ok': True, 'token': 't', 'expires_at': 1794000000})): + sync_client.login('https://x.de/tc', 'frank', 'pw') + with patch('tt.sync_client.requests.get', + return_value=_Response({'ok': True, 'device_uid': 'abc', 'expires_at': 1800000000})): + state = sync_client.status() + + self.assertEqual(state['state'], 'ok') + self.assertEqual(state['username'], 'frank') + self.assertEqual(state['base_url'], 'https://x.de/tc/index.php') + self.assertEqual(state['device_uid'], 'abc') + self.assertEqual(state['expires_at'], 1800000000, + "the server's answer should win over the stored copy") + + +class TestTheRequestsTheLogEndpointsBuild(unittest.TestCase): + """ + The seam between this application and the server. + + Every test of the sync cycle replaces head/push/pull with a stand-in, so + without these the functions that actually assemble the request are never + run at all - and a wrong key or a parameter in the body instead of the + query string would only show up against the live server. + """ + + def setUp(self): + self.tmp = tempfile.mkdtemp() + self._real = sync_client.config_dir + sync_client.config_dir = lambda: self.tmp + with patch('tt.sync_client.requests.post', + return_value=_Response({'ok': True, 'token': 'tok'})): + sync_client.login('https://x.de/tc', 'frank', 'pw') + + def tearDown(self): + sync_client.config_dir = self._real + shutil.rmtree(self.tmp, ignore_errors=True) + + def test_none_of_them_work_without_a_credential(self): + sync_client.clear_credentials() + with patch('tt.sync_client.requests.get') as get, \ + patch('tt.sync_client.requests.post') as post: + for call in (lambda: sync_client.head(), + lambda: sync_client.push(0, []), + lambda: sync_client.pull(0)): + self.assertEqual(call()['error'], 'not_signed_in') + get.assert_not_called() + post.assert_not_called() + + def test_head_is_a_get_carrying_the_token(self): + with patch('tt.sync_client.requests.get', + return_value=_Response({'ok': True, 'head': 7})) as get: + self.assertEqual(sync_client.head()['head'], 7) + + self.assertEqual(get.call_args.args[0], 'https://x.de/tc/index.php') + self.assertEqual(get.call_args.kwargs['params'], {'a': 'head'}) + self.assertEqual(get.call_args.kwargs['headers']['X-TC-Token'], 'tok') + + def test_push_sends_the_batch_in_the_body(self): + ops = [{'op': 'task.set', 'lc': 1, 'uid': 'a' * 16, 'f': {'priority': 3}}] + with patch('tt.sync_client.requests.post', + return_value=_Response({'ok': True, 'head': 1, 'assigned': [[1, 1]]})) as post: + sync_client.push(12, ops) + + self.assertEqual(post.call_args.kwargs['params'], {'a': 'push'}) + body = json.loads(post.call_args.kwargs['data']) + self.assertEqual(body['base_seq'], 12) + self.assertEqual(body['ops'], ops) + self.assertEqual(post.call_args.kwargs['headers']['X-TC-Token'], 'tok') + + def test_pull_puts_since_and_limit_in_the_query_string(self): + """ + The server reads both from the query string. Sent in the body they + would be ignored, since would stay at nought, and every cycle would + fetch the whole log from the beginning. + """ + with patch('tt.sync_client.requests.get', + return_value=_Response({'ok': True, 'head': 9, 'ops': []})) as get: + sync_client.pull(40, limit=25) + + self.assertEqual(get.call_args.kwargs['params'], + {'a': 'pull', 'since': 40, 'limit': 25}) + + def test_pull_asks_for_no_more_than_the_server_will_give(self): + with patch('tt.sync_client.requests.get', + return_value=_Response({'ok': True, 'head': 0, 'ops': []})) as get: + sync_client.pull(0) + self.assertEqual(get.call_args.kwargs['params']['limit'], + sync_client.MAX_OPS_PER_CALL) + + def test_the_numbers_are_sent_as_numbers(self): + """A string reaching the server would be compared as one.""" + with patch('tt.sync_client.requests.get', + return_value=_Response({'ok': True, 'head': 0, 'ops': []})) as get: + sync_client.pull('40') + self.assertEqual(get.call_args.kwargs['params']['since'], 40) + + with patch('tt.sync_client.requests.post', + return_value=_Response({'ok': True, 'head': 0})) as post: + sync_client.push('3', []) + self.assertEqual(json.loads(post.call_args.kwargs['data'])['base_seq'], 3) + + def test_a_transport_failure_reaches_the_caller_as_a_code(self): + import requests as real_requests + with patch('tt.sync_client.requests.post', + side_effect=real_requests.exceptions.SSLError()): + self.assertEqual(sync_client.push(0, [])['error'], 'tls_failed') + + def test_a_rejected_token_is_passed_through_untouched(self): + """ + The engine keys its backoff on this code, so it has to survive the + trip rather than being folded into a generic failure. + """ + with patch('tt.sync_client.requests.get', + return_value=_Response({'ok': False, 'error': 'invalid_token'}, status=401)): + self.assertEqual(sync_client.pull(0)['error'], 'invalid_token') + + +class TestTheCallCannotHangForEver(unittest.TestCase): + """ + requests' own timeout starts once the address has been resolved, so it + does not bound the DNS lookup. That is the hang issue #539 was about, and + a sync wedged inside it would stop syncing with nothing on screen to say + so. + """ + + def setUp(self): + # login() reaches for the device identity, which is written to disk on + # first use. Without this the suite creates ~/.config/TimeControl on + # the machine running it. + self.tmp = tempfile.mkdtemp() + self._real = sync_client.config_dir + sync_client.config_dir = lambda: self.tmp + + def tearDown(self): + sync_client.config_dir = self._real + shutil.rmtree(self.tmp, ignore_errors=True) + + def test_a_call_that_never_returns_is_abandoned(self): + import threading + original = sync_client.DEADLINE + sync_client.DEADLINE = 0.3 + try: + with patch('tt.sync_client.requests.post', + side_effect=lambda *a, **k: threading.Event().wait()): + result = sync_client.login('https://x.de/tc', 'frank', 'pw') + finally: + sync_client.DEADLINE = original + self.assertEqual(result['error'], 'timeout') + + def test_the_deadline_is_above_the_request_s_own_worst_case(self): + """ + Below it, the deadline would fire on ordinary slowness and report a + hang where there was none. timeout applies to connect and read + separately, hence twice. + """ + self.assertGreater(sync_client.DEADLINE, 2 * sync_client.TIMEOUT) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_sync_emit.py b/tests/test_sync_emit.py new file mode 100644 index 0000000..46aecc7 --- /dev/null +++ b/tests/test_sync_emit.py @@ -0,0 +1,325 @@ +import os +import sys +import unittest + +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +from tt.TimeTracker import TimeTracker + +TEST_FILE_PATH = 'test_emit_data.json' + + +class RecordingOutbox: + """ + Stands in for the real queue, so these tests say nothing about files and + everything about which intentions each operation reports. + """ + + def __init__(self): + self.ops = [] + + def append(self, op, **fields): + self.ops.append(dict(op=op, **fields)) + return len(self.ops) + + # -- helpers the tests read with ------------------------------------ + + def names(self): + return [o['op'] for o in self.ops] + + def of(self, op): + return [o for o in self.ops if o['op'] == op] + + def reset(self): + self.ops.clear() + + +class TestOperationsAreReported(unittest.TestCase): + + def setUp(self): + if os.path.exists(TEST_FILE_PATH): + os.remove(TEST_FILE_PATH) + self.outbox = RecordingOutbox() + self.tracker = TimeTracker(file_path=TEST_FILE_PATH, op_outbox=self.outbox) + + def tearDown(self): + if os.path.exists(TEST_FILE_PATH): + os.remove(TEST_FILE_PATH) + + def _project_uid(self, name): + return self.tracker._get_project(name)['uid'] + + def _task_uid(self, project, task): + return self.tracker._get_task(project, task)['uid'] + + # -- projects -------------------------------------------------------- + + def test_adding_a_project(self): + self.tracker.add_main_project("P") + op = self.outbox.of('project.create')[0] + self.assertEqual(op['uid'], self._project_uid("P")) + self.assertEqual(op['f']['name'], "P") + + def test_renaming_reports_a_change_not_a_replacement(self): + """ + The uid stays the same, so the other machine renames the project it + already has. Reporting this as delete-plus-create would take the + project's tasks and tracked time down with it. + """ + self.tracker.add_main_project("Old") + uid = self._project_uid("Old") + self.outbox.reset() + + self.tracker.rename_main_project("Old", "New") + + self.assertEqual(self.outbox.names(), ['project.set']) + self.assertEqual(self.outbox.ops[0]['uid'], uid) + self.assertEqual(self.outbox.ops[0]['f'], {"name": "New"}) + + def test_closing_and_reopening_a_project(self): + self.tracker.add_main_project("P") + self.outbox.reset() + self.tracker.close_main_project("P") + self.tracker.reopen_main_project("P") + self.assertEqual([o['f']['status'] for o in self.outbox.of('project.set')], + ['closed', 'open']) + + def test_deleting_a_project_reports_its_tasks_too(self): + self.tracker.add_main_project("P") + self.tracker.add_task("P", "A") + self.tracker.add_task("P", "B") + project_uid = self._project_uid("P") + task_uids = {self._task_uid("P", "A"), self._task_uid("P", "B")} + self.outbox.reset() + + self.tracker.delete_main_project("P") + + self.assertEqual([o['uid'] for o in self.outbox.of('project.delete')], [project_uid]) + self.assertEqual({o['uid'] for o in self.outbox.of('task.delete')}, task_uids) + + # -- tasks ----------------------------------------------------------- + + def test_adding_a_task_names_its_project(self): + self.tracker.add_main_project("P") + self.outbox.reset() + self.tracker.add_task("P", "T", priority=5) + + op = self.outbox.of('task.create')[0] + self.assertEqual(op['project'], self._project_uid("P")) + self.assertEqual(op['f']['task_name'], "T") + self.assertEqual(op['f']['priority'], 5) + # Local-only bookkeeping must not travel: the integer id is a + # per-machine counter and the entries have operations of their own. + self.assertNotIn('id', op['f']) + self.assertNotIn('time_entries', op['f']) + self.assertNotIn('uid', op['f']) + + def test_update_reports_only_what_changed(self): + self.tracker.add_main_project("P") + self.tracker.add_task("P", "T", priority=1, note="x") + self.outbox.reset() + + self.tracker.update_task("P", "T", priority=7) + + self.assertEqual(self.outbox.names(), ['task.set']) + self.assertEqual(self.outbox.ops[0]['f'], {"priority": 7}) + + def test_an_untouched_due_date_is_not_reported_as_removed(self): + """ + An omitted due_date used to be written as None, so changing one + unrelated field reported the due date as cleared too - and the other + machine faithfully cleared it. + """ + self.tracker.add_main_project("P") + self.tracker.add_task("P", "T", due_date="2026-08-09", priority=1) + self.outbox.reset() + + self.tracker.update_task("P", "T", priority=7) + + self.assertEqual(self.outbox.ops[0]['f'], {"priority": 7}) + + def test_clearing_a_due_date_is_reported(self): + self.tracker.add_main_project("P") + self.tracker.add_task("P", "T", due_date="2026-08-09") + self.outbox.reset() + + self.tracker.update_task("P", "T", clear_due_date=True) + + self.assertEqual(self.outbox.names(), ['task.set']) + self.assertEqual(self.outbox.ops[0]['f'], {"due_date": None}) + + def test_a_save_that_changes_nothing_reports_nothing(self): + self.tracker.add_main_project("P") + self.tracker.add_task("P", "T", due_date="2026-08-09", priority=3) + self.outbox.reset() + + self.tracker.update_task("P", "T", due_date="2026-08-09", priority=3) + + self.assertEqual(self.outbox.ops, []) + + def test_moving_a_task_keeps_its_identity(self): + """ + move_task takes the task out of one list and puts it in another. It is + the same task, so it must be reported as moved - reported as a + deletion it would be destroyed on the other machine. + """ + self.tracker.add_main_project("From") + self.tracker.add_main_project("To") + self.tracker.add_task("From", "T") + uid = self._task_uid("From", "T") + self.outbox.reset() + + self.tracker.move_task("From", "T", "To") + + self.assertEqual(self.outbox.names(), ['task.move']) + self.assertEqual(self.outbox.ops[0]['uid'], uid) + self.assertEqual(self.outbox.ops[0]['project'], self._project_uid("To")) + self.assertEqual(self.outbox.of('task.delete'), []) + + # -- time ------------------------------------------------------------ + + def test_starting_and_stopping_work(self): + self.tracker.add_main_project("P") + self.tracker.add_task("P", "T") + self.outbox.reset() + + self.tracker.start_work("P", "T") + entry_uid = self.tracker._get_task("P", "T")['time_entries'][0]['uid'] + add = self.outbox.of('entry.add')[0] + self.assertEqual(add['uid'], entry_uid) + self.assertEqual(add['task'], self._task_uid("P", "T")) + # The ordering is sent, not left to be inferred from the entry. + self.assertIn('last_started', self.outbox.of('task.set')[0]['f']) + self.assertIn('last_started', self.outbox.of('project.set')[0]['f']) + + self.outbox.reset() + self.tracker.stop_work() + close = self.outbox.of('entry.close')[0] + self.assertEqual(close['uid'], entry_uid) + self.assertTrue(close['end']) + + # -- restructuring --------------------------------------------------- + + def test_promoting_moves_the_entries_before_the_task_is_deleted(self): + """ + Reported as parts the other machine already understands. Crucially + the entries are re-parented rather than recreated, so no tracked time + is duplicated - and their move is reported before the old task's + deletion, so the deletion cannot sweep them up. + """ + self.tracker.add_main_project("P") + self.tracker.add_task("P", "Rising") + self.tracker.start_work("P", "Rising") + self.tracker.stop_work() + task = self.tracker._get_task("P", "Rising") + old_task_uid, entry_uid = task['uid'], task['time_entries'][0]['uid'] + self.outbox.reset() + + ok, _msg = self.tracker.promote_task_to_project("P", "Rising") + self.assertTrue(ok) + + names = self.outbox.names() + self.assertIn('project.create', names) + self.assertIn('task.create', names) + + moved = self.outbox.of('entry.move')[0] + self.assertEqual(moved['uid'], entry_uid) + # Same entry, not a new one. + self.assertEqual(self.outbox.of('entry.add'), []) + + deleted = self.outbox.of('task.delete')[0] + self.assertEqual(deleted['uid'], old_task_uid) + self.assertLess(names.index('entry.move'), names.index('task.delete')) + + def test_demoting_reports_the_same_shape(self): + self.tracker.add_main_project("Parent") + self.tracker.add_main_project("Sinking") + self.tracker.add_task("Sinking", "Inner") + self.tracker.start_work("Sinking", "Inner") + self.tracker.stop_work() + entry_uid = self.tracker._get_task("Sinking", "Inner")['time_entries'][0]['uid'] + project_uid = self._project_uid("Sinking") + self.outbox.reset() + + ok, _msg = self.tracker.demote_main_project("Sinking", "Parent") + self.assertTrue(ok) + + names = self.outbox.names() + self.assertEqual(self.outbox.of('entry.move')[0]['uid'], entry_uid) + self.assertEqual(self.outbox.of('project.delete')[0]['uid'], project_uid) + self.assertLess(names.index('entry.move'), names.index('project.delete')) + + # -- deliberate silences --------------------------------------------- + + def test_the_daily_sweeps_report_nothing(self): + """ + Both machines run these from the same rule against the same due + dates, so each reaches the same result unaided. Sending them would be + traffic spent on something the other side already knows. + """ + self.tracker.add_main_project("P") + self.tracker.add_task("P", "Overdue", due_date="2020-01-01", today=True) + self.tracker.add_task("P", "DueToday", due_date=__import__('datetime').date.today().isoformat()) + self.outbox.reset() + + self.tracker.cleanup_overdue_today_tasks() + self.tracker.set_today_flag_for_due_tasks() + + self.assertEqual(self.outbox.ops, []) + + def test_migration_reports_nothing(self): + """ + Adding uids and defaults changes the shape of the document, not its + content, and the other machine performs the same migration itself. + """ + import json + legacy = {"projects": [{"main_project_name": "Old", + "sub_projects": [{"task_name": "T", "time_entries": []}]}]} + with open(TEST_FILE_PATH, 'w') as f: + json.dump(legacy, f) + + outbox = RecordingOutbox() + TimeTracker(file_path=TEST_FILE_PATH, op_outbox=outbox) + self.assertEqual(outbox.ops, []) + + +class TestSyncOffByDefault(unittest.TestCase): + + def setUp(self): + if os.path.exists(TEST_FILE_PATH): + os.remove(TEST_FILE_PATH) + + def tearDown(self): + if os.path.exists(TEST_FILE_PATH): + os.remove(TEST_FILE_PATH) + + def test_no_queue_means_no_recording_and_no_errors(self): + """ + Every installation without synchronisation configured - which is all + of them until somebody switches it on - must behave exactly as before. + """ + tracker = TimeTracker(file_path=TEST_FILE_PATH) + self.assertIsNone(tracker.op_outbox) + tracker.add_main_project("P") + tracker.add_task("P", "T") + tracker.start_work("P", "T") + tracker.stop_work() + self.assertTrue(tracker.delete_main_project("P")) + + def test_a_broken_queue_never_breaks_the_app(self): + """ + Recording a change must not be able to stop the user tracking time. + A queue that cannot be written costs a sync, not the application. + """ + class ExplodingOutbox: + def append(self, op, **fields): + raise OSError("disk full") + + tracker = TimeTracker(file_path=TEST_FILE_PATH, op_outbox=ExplodingOutbox()) + tracker.add_main_project("P") + tracker.add_task("P", "T") + self.assertIsNotNone(tracker._get_task("P", "T")) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_sync_engine.py b/tests/test_sync_engine.py new file mode 100644 index 0000000..8d3f465 --- /dev/null +++ b/tests/test_sync_engine.py @@ -0,0 +1,1224 @@ +import os +import shutil +import sys +import tempfile +import time +import unittest + +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +from tt import sync_client, sync_engine +from tt.sync_outbox import Outbox +from tt.TimeTracker import TimeTracker + + +class FakeServer: + """ + Stands in for the running server, copying the semantics that matter: + 'since' is exclusive, a push never echoes the caller's own operations + back, and a number at or below what this device has already sent is + reported as a repeat rather than recorded again. + """ + + def __init__(self, device='aaaaaaaaaaaaaaaa'): + self.log = [] + self.max_lc = 0 + self.device = device + self.page = 500 + self.fail_with = None + self.calls = [] + + # -- what the log holds ------------------------------------------------ + + def add_foreign(self, op, **fields): + """An operation from the other machine.""" + entry = {'s': len(self.log) + 1, 'op': op, 'dev': 'other'} + entry.update(fields) + self.log.append(entry) + return entry + + @property + def head(self): + return len(self.log) + + # -- the endpoints ----------------------------------------------------- + + def push(self, base_seq, ops): + self.calls.append(('push', base_seq, len(ops))) + if self.fail_with: + return {'ok': False, 'error': self.fail_with} + assigned, dups = [], [] + for op in ops: + lc = int(op['lc']) + if lc <= self.max_lc: + dups.append(lc) + continue + entry = dict(op, s=len(self.log) + 1, dev=self.device) + self.log.append(entry) + assigned.append([lc, entry['s']]) + self.max_lc = max(self.max_lc, lc) + visible = [e for e in self.log if e['s'] > base_seq and e['dev'] != self.device] + return {'ok': True, 'head': self.head, 'assigned': assigned, 'dups': dups, + 'ops': visible[:self.page], 'more': len(visible) > self.page} + + def pull(self, since, limit=500): + self.calls.append(('pull', since, limit)) + if self.fail_with: + return {'ok': False, 'error': self.fail_with} + visible = [e for e in self.log if e['s'] > since] + page = min(self.page, limit) + return {'ok': True, 'head': self.head, 'ops': visible[:page], + 'more': len(visible) > page} + + +class EngineTestCase(unittest.TestCase): + """Every test gets its own configuration directory and its own server.""" + + def setUp(self): + self.tmp = tempfile.mkdtemp() + self._real_config_dir = sync_client.config_dir + self._real_push = sync_client.push + self._real_pull = sync_client.pull + self._real_creds = sync_client.load_credentials + + sync_client.config_dir = lambda: self.tmp + self.server = FakeServer() + sync_client.push = self.server.push + sync_client.pull = self.server.pull + sync_client.load_credentials = lambda: {'token': 't', 'base_url': 'https://x/index.php'} + + self.outbox = Outbox() + + def tearDown(self): + sync_engine.stop() + sync_client.config_dir = self._real_config_dir + sync_client.push = self._real_push + sync_client.pull = self._real_pull + sync_client.load_credentials = self._real_creds + shutil.rmtree(self.tmp, ignore_errors=True) + + def queue(self, op, **fields): + return self.outbox.append(op, **fields) + + +P1, T1, E1 = 'p' * 16, 't' * 16, 'e' * 16 + + +class TestOneCycle(EngineTestCase): + + def test_what_is_queued_is_sent_and_then_forgotten(self): + self.queue('project.create', uid=P1, f={'name': 'P'}) + result = sync_engine.run_cycle(self.outbox) + + self.assertTrue(result['ok']) + self.assertEqual(len(self.server.log), 1) + self.assertEqual(self.outbox.pending(), [], "an acknowledged change stayed queued") + + def test_what_arrives_is_filed_rather_than_applied(self): + """ + The cycle runs off the interface's thread, so it must not touch the + document. What it fetches waits on disk until the thread that owns + the document picks it up. + """ + self.server.add_foreign('project.create', uid=P1, f={'name': 'Remote'}) + sync_engine.run_cycle(self.outbox) + + records = sync_engine.read_inbox() + self.assertEqual(len(records), 1) + self.assertEqual(records[0]['ops'][0]['uid'], P1) + self.assertEqual(sync_engine.read_state()['base_seq'], 0, + "the cursor moved before anything was applied") + + def test_our_own_operations_are_filed_with_the_place_they_were_given(self): + """ + A push is not told its own operations back, only where they landed. + They still have to reach the document in that order, or a change made + here would be overwritten by an older one from elsewhere. + """ + self.server.add_foreign('task.set', uid=T1, f={'priority': 1}) + self.queue('task.set', uid=T1, f={'priority': 9}) + sync_engine.run_cycle(self.outbox) + + ops = sync_engine.read_inbox()[0]['ops'] + by_seq = sorted(ops, key=lambda o: o['s']) + self.assertEqual([o['f']['priority'] for o in by_seq], [1, 9]) + + def test_a_second_cycle_asks_only_for_what_is_new(self): + self.server.add_foreign('project.create', uid=P1, f={'name': 'A'}) + sync_engine.run_cycle(self.outbox) + self.server.add_foreign('project.create', uid='b' * 16, f={'name': 'B'}) + sync_engine.run_cycle(self.outbox) + + second = sync_engine.read_inbox()[1] + self.assertEqual([o['uid'] for o in second['ops']], ['b' * 16]) + + def test_an_ordinary_cycle_does_not_ask_to_be_run_again(self): + """ + 'more' makes the worker come straight back instead of waiting out the + interval. Reported when there is nothing left, it becomes a loop that + talks to the server without pause. + """ + self.queue('project.create', uid=P1, f={'name': 'P'}) + self.server.add_foreign('project.create', uid='c' * 16, f={'name': 'C'}) + result = sync_engine.run_cycle(self.outbox) + self.assertTrue(result['ok']) + self.assertFalse(result['more']) + + def test_nothing_to_say_and_nothing_to_hear_files_nothing(self): + sync_engine.run_cycle(self.outbox) + self.assertEqual(sync_engine.read_inbox(), []) + self.assertIsNotNone(sync_engine.read_state()['last_ok']) + + +class TestRepeatedPush(EngineTestCase): + """ + The awkward case: the push landed, the answer did not. The operations are + in the log at positions this machine was never told. + """ + + def test_a_repeat_makes_the_cycle_ask_for_the_whole_order(self): + self.queue('task.set', uid=T1, f={'priority': 5}) + # The server records it, the client never hears back. + self.server.push(0, [dict(o) for o in self.outbox.pending()]) + self.server.add_foreign('task.set', uid=T1, f={'priority': 9}) + + sync_engine.run_cycle(self.outbox) + + self.assertIn('pull', [c[0] for c in self.server.calls], + "the cycle trusted a reply that cannot show its own place") + ops = sorted(sync_engine.read_inbox()[0]['ops'], key=lambda o: o['s']) + self.assertEqual([o['f']['priority'] for o in ops], [5, 9], + "the two machines would have disagreed about the order") + + def test_the_repeat_is_cleared_from_the_queue(self): + self.queue('task.set', uid=T1, f={'priority': 5}) + self.server.push(0, [dict(o) for o in self.outbox.pending()]) + sync_engine.run_cycle(self.outbox) + self.assertEqual(self.outbox.pending(), []) + + +class TestPartialAnswers(EngineTestCase): + """ + The server hands back at most one batch. Getting the cursor wrong here + skips operations permanently, because the log is only ever read forwards. + """ + + def test_a_long_backlog_is_collected_without_a_gap(self): + self.server.page = 3 + for i in range(10): + self.server.add_foreign('task.set', uid=T1, f={'priority': i}) + + result = sync_engine.run_cycle(self.outbox) + + self.assertTrue(result['ok']) + collected = [o for r in sync_engine.read_inbox() for o in r['ops']] + self.assertEqual([o['s'] for o in collected], list(range(1, 11)), + "operations were skipped between batches") + + def test_the_cursor_never_runs_ahead_of_what_was_received(self): + """ + Our own operations are numbered above everything already in the log. + Recording that number while the middle is still missing would step + over the gap for good. + """ + self.server.page = 2 + for i in range(6): + self.server.add_foreign('task.set', uid=T1, f={'priority': i}) + self.queue('task.set', uid=T1, f={'priority': 99}) + + sync_engine.run_cycle(self.outbox) + + collected = [o for r in sync_engine.read_inbox() for o in r['ops']] + self.assertEqual([o['s'] for o in collected], list(range(1, 8))) + + def test_more_than_one_batch_of_our_own_is_sent_over_several_cycles(self): + original = sync_client.MAX_OPS_PER_CALL + sync_client.MAX_OPS_PER_CALL = 3 + try: + for i in range(7): + self.queue('task.set', uid=T1, f={'priority': i}) + + first = sync_engine.run_cycle(self.outbox) + self.assertTrue(first['more'], "the cycle did not say there was more to send") + self.assertEqual(len(self.outbox.pending()), 4) + + sync_engine.run_cycle(self.outbox) + sync_engine.run_cycle(self.outbox) + self.assertEqual(self.outbox.pending(), [], "the backlog never cleared") + self.assertEqual(len(self.server.log), 7) + finally: + sync_client.MAX_OPS_PER_CALL = original + + def test_an_answer_that_never_ends_does_not_spin_for_ever(self): + """A server that always claims more must not trap the worker.""" + self.server.page = 1 + for i in range(5): + self.server.add_foreign('task.set', uid=T1, f={'priority': i}) + self.server.pull = lambda since, limit=500: { + 'ok': True, 'head': 99, 'ops': [{'s': since + 1, 'op': 'task.set', 'uid': T1}], + 'more': True} + sync_client.pull = self.server.pull + self.queue('task.set', uid=T1, f={'priority': 1}) + + result = sync_engine.run_cycle(self.outbox) + self.assertTrue(result['ok']) + self.assertTrue(result['more']) + + # And it must not pretend it caught up. The server says its head is + # 99; taking that while the pages in between were never delivered + # would step over them for good, since the log is only read forwards. + collected = [o for r in sync_engine.read_inbox() for o in r['ops']] + highest = max(int(o['s']) for o in collected) + self.assertEqual(sync_engine.read_inbox()[-1]['base_seq'], highest, + "the cursor ran ahead of what was actually received") + self.assertLess(highest, 99) + + +class TestFailures(EngineTestCase): + + def test_a_failed_cycle_leaves_the_queue_alone(self): + self.queue('project.create', uid=P1, f={'name': 'P'}) + self.server.fail_with = 'unreachable' + + result = sync_engine.run_cycle(self.outbox) + + self.assertFalse(result['ok']) + self.assertEqual(len(self.outbox.pending()), 1, + "a change was dropped although it never reached the server") + + def test_repeated_failures_back_off_instead_of_hammering(self): + self.server.fail_with = 'unreachable' + delays = [] + for _ in range(4): + sync_engine.run_cycle(self.outbox) + state = sync_engine.read_state() + delays.append(state['next_attempt'] - int(time.time())) + + self.assertTrue(all(b >= a for a, b in zip(delays, delays[1:])), + "the wait did not grow: %s" % delays) + self.assertLessEqual(max(delays), sync_engine.BACKOFF_MAX_SECONDS) + + def test_something_only_the_user_can_fix_is_not_retried_every_minute(self): + """ + A revoked token or an address that is not a sync server will answer + the same way for ever. Asking every minute achieves nothing. + """ + self.server.fail_with = 'invalid_token' + sync_engine.run_cycle(self.outbox) + waited = sync_engine.read_state()['next_attempt'] - int(time.time()) + self.assertGreaterEqual(waited, sync_engine.BACKOFF_MAX_SECONDS - 5) + + def test_a_success_clears_the_backoff(self): + self.server.fail_with = 'unreachable' + sync_engine.run_cycle(self.outbox) + self.server.fail_with = None + sync_engine.run_cycle(self.outbox) + + state = sync_engine.read_state() + self.assertIsNone(state['last_error']) + self.assertEqual(state['failures'], 0) + self.assertEqual(state['next_attempt'], 0) + + def test_a_failure_partway_through_keeps_what_did_arrive(self): + self.server.page = 2 + for i in range(6): + self.server.add_foreign('task.set', uid=T1, f={'priority': i}) + self.queue('task.set', uid=T1, f={'priority': 99}) + + calls = {'n': 0} + real_pull = self.server.pull + + def flaky(since, limit=500): + calls['n'] += 1 + if calls['n'] > 1: + # A distinct code, so that reporting every failure as a lost + # connection would show up here rather than passing for right. + return {'ok': False, 'error': 'tls_failed'} + return real_pull(since, limit) + + sync_client.pull = flaky + result = sync_engine.run_cycle(self.outbox) + + self.assertFalse(result['ok']) + self.assertEqual(result['error'], 'tls_failed') + self.assertEqual(sync_engine.read_state()['last_error'], 'tls_failed') + collected = [o for r in sync_engine.read_inbox() for o in r['ops']] + self.assertEqual([o['s'] for o in collected], [1, 2], + "the part that did arrive was thrown away") + self.assertEqual(len(self.outbox.pending()), 1, + "our own change was dropped before its place was known") + + +class TestOnlyOneCycleAtATime(EngineTestCase): + + def test_a_second_cycle_steps_aside_rather_than_interleaving(self): + from tt.filelock import locked + with locked(sync_engine._cycle_lock_path()): + result = sync_engine.run_cycle(self.outbox) + self.assertTrue(result['ok']) + self.assertEqual(result.get('skipped'), 'busy') + self.assertEqual(self.server.calls, [], "it talked to the server anyway") + + +class TestApplying(EngineTestCase): + """ + The fast half, on the thread that owns the document. + """ + + DATA = 'test_engine_data.json' + + def setUp(self): + super().setUp() + if os.path.exists(self.DATA): + os.remove(self.DATA) + self.tracker = TimeTracker(file_path=self.DATA, op_outbox=self.outbox) + + def tearDown(self): + if os.path.exists(self.DATA): + os.remove(self.DATA) + super().tearDown() + + def test_nothing_pending_does_nothing(self): + self.assertIsNone(sync_engine.apply_pending(self.tracker)) + + def test_what_arrived_reaches_the_document_and_the_file(self): + self.server.add_foreign('project.create', uid=P1, f={'name': 'Remote'}) + sync_engine.run_cycle(self.outbox) + + summary = sync_engine.apply_pending(self.tracker) + + self.assertEqual(summary['applied'], 1) + self.assertIsNotNone(self.tracker._get_project('Remote')) + + reopened = TimeTracker(file_path=self.DATA) + self.assertIsNotNone(reopened._get_project('Remote'), + "the change was applied but never saved") + + def test_the_cursor_moves_only_once_the_document_holds_it(self): + self.server.add_foreign('project.create', uid=P1, f={'name': 'Remote'}) + sync_engine.run_cycle(self.outbox) + self.assertEqual(sync_engine.read_state()['base_seq'], 0) + + sync_engine.apply_pending(self.tracker) + + self.assertEqual(sync_engine.read_state()['base_seq'], 1) + self.assertEqual(sync_engine.read_inbox(), []) + + def test_applying_twice_is_harmless(self): + self.server.add_foreign('project.create', uid=P1, f={'name': 'Remote'}) + sync_engine.run_cycle(self.outbox) + sync_engine.apply_pending(self.tracker) + sync_engine.apply_pending(self.tracker) + self.assertEqual(len(self.tracker.data['projects']), 1) + + def test_a_session_ended_because_work_began_elsewhere_is_reported_back(self): + """ + That closure is worked out here, from the order alone. Unless it is + sent, the other machines go on showing the session as running. + """ + self.tracker.add_main_project('P') + self.tracker.add_task('P', 'Here') + self.tracker.start_work('P', 'Here') + running = self.tracker._get_task('P', 'Here')['time_entries'][0]['uid'] + task_uid = self.tracker._get_task('P', 'Here')['uid'] + self.outbox.clear() + + self.server.add_foreign('entry.add', uid=E1, task=task_uid, + start='2030-01-01 10:00:00') + sync_engine.run_cycle(self.outbox) + summary = sync_engine.apply_pending(self.tracker) + + self.assertEqual(summary['auto_closed'], 1) + closes = [o for o in self.outbox.pending() + if o['op'] == 'entry.close' and o['uid'] == running] + self.assertEqual(len(closes), 1, "the other machine is never told it ended") + self.assertEqual(closes[0]['end'], '2030-01-01 10:00:00') + + def test_discarded_time_is_reported_so_the_user_can_be_told(self): + """ + The hours are gone, deliberately - but the user did not ask for that + on this machine, so it cannot happen in silence. + """ + self.tracker.add_main_project('P') + self.tracker.add_task('P', 'Doomed') + task_uid = self.tracker._get_task('P', 'Doomed')['uid'] + self.tracker.delete_task('P', 'Doomed') + self.outbox.clear() + + self.server.add_foreign('entry.add', uid=E1, task=task_uid, + start='2026-08-10 09:00:00') + sync_engine.run_cycle(self.outbox) + summary = sync_engine.apply_pending(self.tracker) + + self.assertEqual(summary['discarded_time'], 1) + self.assertIsNone(self.tracker._get_task('P', 'Doomed')) + + def test_an_unsent_change_still_wins_over_an_older_incoming_one(self): + self.tracker.add_main_project('P') + self.tracker.add_task('P', 'T') + task_uid = self.tracker._get_task('P', 'T')['uid'] + sync_engine.run_cycle(self.outbox) + sync_engine.apply_pending(self.tracker) + + # Elsewhere first, here second - but ours has not been sent yet. + self.server.add_foreign('task.set', uid=task_uid, f={'priority': 2}) + self.tracker.update_task('P', 'T', priority=8) + + # Fetch without sending, as a cycle interrupted before its push would. + fetched = sync_client.pull(sync_engine.read_state()['base_seq']) + sync_engine._append_inbox({'base_seq': fetched['head'], 'ops': fetched['ops']}) + sync_engine.apply_pending(self.tracker) + + self.assertEqual(self.tracker._get_task('P', 'T')['priority'], 8) + + +class TestOfferingTheExistingDocument(EngineTestCase): + + DATA = 'test_engine_seed.json' + + def setUp(self): + super().setUp() + if os.path.exists(self.DATA): + os.remove(self.DATA) + self.tracker = TimeTracker(file_path=self.DATA, op_outbox=self.outbox) + + def tearDown(self): + if os.path.exists(self.DATA): + os.remove(self.DATA) + super().tearDown() + + def test_an_existing_document_is_offered_the_first_time(self): + self.tracker.add_main_project('Existing') + self.tracker.add_task('Existing', 'Older work') + self.outbox.clear() + + queued = sync_engine.offer_document(self.tracker) + + self.assertGreater(queued, 0) + self.assertEqual([o['op'] for o in self.outbox.pending()], + ['project.create', 'task.create']) + + def test_it_is_offered_only_once(self): + self.tracker.add_main_project('Existing') + sync_engine.offer_document(self.tracker) + self.outbox.clear() + + self.assertEqual(sync_engine.offer_document(self.tracker), 0) + self.assertEqual(self.outbox.pending(), []) + + def test_nothing_is_offered_before_signing_in(self): + sync_client.load_credentials = lambda: None + self.tracker.add_main_project('Existing') + self.outbox.clear() + self.assertEqual(sync_engine.offer_document(self.tracker), 0) + + def test_a_document_offered_here_rebuilds_on_the_other_machine(self): + self.tracker.add_main_project('Website') + self.tracker.add_task('Website', 'Relaunch', priority=4) + self.tracker.start_work('Website', 'Relaunch') + self.tracker.stop_work() + self.outbox.clear() + + sync_engine.offer_document(self.tracker) + sync_engine.run_cycle(self.outbox) + + elsewhere = {'projects': [], 'next_id': 1, '_deleted': [], 'schema_version': 2} + from tt.sync_apply import apply_ops + apply_ops(elsewhere, self.server.log) + + self.assertEqual([p['main_project_name'] for p in elsewhere['projects']], ['Website']) + task = elsewhere['projects'][0]['tasks'][0] + self.assertEqual(task['task_name'], 'Relaunch') + self.assertEqual(task['priority'], 4) + self.assertEqual(len(task['time_entries']), 1) + self.assertIn('end_time', task['time_entries'][0]) + + +class TestTheWorker(EngineTestCase): + + def test_only_one_worker_exists_however_often_it_is_started(self): + """ + The interface re-runs its whole script on every redraw, so this is + called constantly. A thread per redraw would be a thread every few + seconds, all pushing the same queue. + """ + cfg = {'sync': {'enabled': True}} + for _ in range(5): + sync_engine.ensure_started(cfg) + alive = [t for t in __import__('threading').enumerate() if t.name == 'tc-sync'] + self.assertEqual(len(alive), 1) + + def test_it_does_not_start_when_synchronisation_is_off(self): + self.assertFalse(sync_engine.ensure_started({'sync': {'enabled': False}})) + self.assertFalse(sync_engine.ensure_started({})) + alive = [t for t in __import__('threading').enumerate() if t.name == 'tc-sync'] + self.assertEqual(alive, []) + + def test_a_nudge_makes_it_run_without_waiting_for_the_interval(self): + self.queue('project.create', uid=P1, f={'name': 'P'}) + sync_engine.ensure_started({'sync': {'enabled': True}}) + sync_engine.nudge() + + deadline = time.monotonic() + 10 + while time.monotonic() < deadline and not self.server.log: + time.sleep(0.05) + self.assertEqual(len(self.server.log), 1, "the nudge never produced a cycle") + + def test_a_nudge_does_not_get_round_the_wait_after_a_failure(self): + """ + A nudge comes from changing view, which happens constantly. If it + cancelled the backoff, a server that is down would be contacted on + every single navigation and the backoff would exist only on paper. + """ + self.server.fail_with = 'unreachable' + sync_engine.run_cycle(self.outbox) + before = len(self.server.calls) + + sync_engine.ensure_started({'sync': {'enabled': True}}) + for _ in range(3): + sync_engine.nudge() + time.sleep(0.2) + + self.assertEqual(len(self.server.calls), before, + "the wait was ignored and the server was asked again") + + def test_the_user_asking_directly_does_lift_the_wait(self): + self.server.fail_with = 'unreachable' + sync_engine.run_cycle(self.outbox) + self.server.fail_with = None + + sync_engine.ensure_started({'sync': {'enabled': True}}) + sync_engine.nudge(force=True) + + deadline = time.monotonic() + 10 + while time.monotonic() < deadline and not sync_engine.read_state()['last_ok']: + time.sleep(0.05) + self.assertIsNotNone(sync_engine.read_state()['last_ok']) + + def test_a_cycle_that_raises_does_not_kill_the_worker(self): + def explode(*_a, **_k): + raise RuntimeError("boom") + sync_client.push = explode + sync_engine.ensure_started({'sync': {'enabled': True}}) + sync_engine.nudge() + time.sleep(0.5) + + sync_client.push = self.server.push + sync_engine.nudge() + deadline = time.monotonic() + 10 + while time.monotonic() < deadline and not sync_engine.read_state()['last_ok']: + time.sleep(0.05) + self.assertIsNotNone(sync_engine.read_state()['last_ok'], + "the worker died on the first error and never came back") + + +class TestConsumingTheInbox(EngineTestCase): + """ + The worker files what it fetches; the drawing thread applies it and takes + it away. Getting the hand-over wrong loses operations with nothing to say + so, which is the one failure this whole design exists to avoid. + """ + + DATA = 'test_engine_inbox.json' + + def setUp(self): + super().setUp() + if os.path.exists(self.DATA): + os.remove(self.DATA) + self.tracker = TimeTracker(file_path=self.DATA, op_outbox=self.outbox) + + def tearDown(self): + if os.path.exists(self.DATA): + os.remove(self.DATA) + super().tearDown() + + def test_a_record_filed_while_applying_is_not_swept_away_with_the_rest(self): + """ + The worker appends between the drawing thread reading the inbox and + emptying it. Deleting the whole file would destroy that record while + the cursor moved past it, and the two machines would quietly stop + agreeing. + """ + self.server.add_foreign('project.create', uid=P1, f={'name': 'First'}) + sync_engine.run_cycle(self.outbox) + + with sync_engine.taken_inbox() as records: + self.assertEqual(len(records), 1) + # Straight to the file, as a worker holding no inbox lock would. + with open(sync_engine.inbox_path(), 'a', encoding='utf-8') as f: + f.write('{"base_seq": 9, "ops": [{"s": 9, "op": "project.create", ' + '"uid": "%s", "f": {"name": "Late"}}]}\n' % ('c' * 16)) + + left = sync_engine.read_inbox() + self.assertEqual(len(left), 1, "the late record was thrown away unapplied") + self.assertEqual(left[0]['base_seq'], 9) + + def test_a_document_that_cannot_be_saved_keeps_what_arrived(self): + """ + A full disk or a share gone read-only. Consuming the inbox anyway + would show the incoming changes on screen and lose them at the next + restart, with the cursor already past them. + """ + self.server.add_foreign('project.create', uid=P1, f={'name': 'Remote'}) + sync_engine.run_cycle(self.outbox) + + def refuse(): + raise OSError("read-only file system") + self.tracker._save_data = refuse + + with self.assertRaises(OSError): + sync_engine.apply_pending(self.tracker) + + self.assertEqual(len(sync_engine.read_inbox()), 1, + "the fetched operations were consumed but never saved") + self.assertEqual(sync_engine.read_state()['base_seq'], 0, + "the cursor moved past operations that were never stored") + + def test_the_end_of_a_session_is_queued_before_the_document_is_committed(self): + """ + A machine switched off between the two would hold a closure it can + never re-derive and never pass on, leaving the session open on the + other machine for good. + """ + self.tracker.add_main_project('P') + self.tracker.add_task('P', 'T') + self.tracker.start_work('P', 'T') + task_uid = self.tracker._get_task('P', 'T')['uid'] + self.outbox.clear() + + order = [] + real_save = self.tracker._save_data + self.tracker._save_data = lambda: (order.append('saved'), real_save())[1] + real_emit = self.tracker._emit + self.tracker._emit = lambda op, **f: (order.append(op), real_emit(op, **f))[1] + + self.server.add_foreign('entry.add', uid=E1, task=task_uid, + start='2030-01-01 10:00:00') + sync_engine.run_cycle(self.outbox) + sync_engine.apply_pending(self.tracker) + + self.assertIn('entry.close', order) + self.assertIn('saved', order) + self.assertLess(order.index('entry.close'), order.index('saved')) + + +class TestTheQueueAndTheLogDisagreeing(EngineTestCase): + """ + The queue and the log drift apart for several dull reasons: a push whose + reply was lost, a catch-up cut short before the queue drained, a machine + switched off between filing what arrived and clearing the queue. Whenever + they do, the same operation exists in both - and replaying it on top of + what has already been ordered inverts the server's order silently. + """ + + DATA = 'test_engine_placed.json' + + def setUp(self): + super().setUp() + if os.path.exists(self.DATA): + os.remove(self.DATA) + self.tracker = TimeTracker(file_path=self.DATA, op_outbox=self.outbox) + self.device = sync_client.device_identity()['device_uid'] + self.server.device = self.device + + def tearDown(self): + if os.path.exists(self.DATA): + os.remove(self.DATA) + super().tearDown() + + def test_an_operation_already_in_the_log_is_not_replayed_on_top(self): + """ + Ours landed at 100 and theirs at 101, so theirs wins. Lifting ours + back to the top because it is still queued would leave this machine + on our value and the other on theirs, for good, with nothing to say + the two had parted company. + """ + self.tracker.add_main_project('P') + self.tracker.add_task('P', 'T') + task_uid = self.tracker._get_task('P', 'T')['uid'] + sync_engine.run_cycle(self.outbox) + sync_engine.apply_pending(self.tracker) + self.outbox.drop([e['lc'] for e in self.outbox.pending()]) + + # Ours is recorded by the server, but the reply never arrives, so it + # stays queued. + self.tracker.update_task('P', 'T', priority=3) + self.server.push(0, [dict(o) for o in self.outbox.pending()]) + # Then the other machine changes the same field, after ours, and + # carries on working - enough that the catch-up needs several pages. + self.server.add_foreign('task.set', uid=task_uid, f={'priority': 7}) + for n in range(4): + self.server.add_foreign('project.create', uid='%016x' % (n + 20), + f={'name': 'Other %d' % n}) + + # The catch-up has to be cut short, which is what leaves the queue + # undrained while the record covering it has already been filed. + # Cutting it short is ordinary: the reply itself caps at 500 and the + # connection that just lost a push reply is the same one. + self.server.page = 2 + real_pull = self.server.pull + calls = {'n': 0} + + def flaky(since, limit=500): + calls['n'] += 1 + if calls['n'] > 1: + return {'ok': False, 'error': 'unreachable'} + return real_pull(since, limit) + sync_client.pull = flaky + + sync_engine.run_cycle(self.outbox) + self.assertEqual(len(self.outbox.pending()), 1, + "the setup did not reproduce an undrained queue") + sync_engine.apply_pending(self.tracker) + + self.assertEqual(self.tracker._get_task('P', 'T')['priority'], 7, + "our older change was replayed over a newer one - " + "this machine and the other now disagree for good") + + def test_an_operation_the_log_has_placed_leaves_the_queue(self): + self.tracker.add_main_project('P') + sync_engine.run_cycle(self.outbox) + sync_engine.apply_pending(self.tracker) + self.outbox.drop([e['lc'] for e in self.outbox.pending()]) + + self.tracker.add_main_project('Q') + self.server.push(0, [dict(o) for o in self.outbox.pending()]) + for n in range(4): + self.server.add_foreign('project.create', uid='%016x' % (n + 30), + f={'name': 'Other %d' % n}) + + self.server.page = 2 + real_pull = self.server.pull + calls = {'n': 0} + + def flaky(since, limit=500): + calls['n'] += 1 + if calls['n'] > 1: + return {'ok': False, 'error': 'unreachable'} + return real_pull(since, limit) + sync_client.pull = flaky + + sync_engine.run_cycle(self.outbox) + self.assertEqual(len(self.outbox.pending()), 1) + sync_engine.apply_pending(self.tracker) + self.assertEqual(self.outbox.pending(), [], + "an operation the log already holds stayed queued") + + def test_another_device_s_numbering_does_not_touch_ours(self): + """ + Every device numbers its own operations from one, so the other + machine's lc 1 and ours collide constantly. Matching on the number + alone would throw our unsent work out of the queue on the strength of + somebody else's - and it would never reach the server at all. + """ + self.tracker.add_main_project('P') + sync_engine.run_cycle(self.outbox) + sync_engine.apply_pending(self.tracker) + self.outbox.drop([e['lc'] for e in self.outbox.pending()]) + + self.tracker.add_main_project('Mine') + queued = [e['lc'] for e in self.outbox.pending()] + self.assertTrue(queued) + + # The other machine's operation carries the same number as ours. + foreign = self.server.add_foreign('project.create', uid='f' * 16, + f={'name': 'Theirs'}) + foreign['lc'] = queued[0] + sync_engine._append_inbox({'base_seq': foreign['s'], 'ops': [foreign]}) + + sync_engine.apply_pending(self.tracker) + + self.assertEqual([e['lc'] for e in self.outbox.pending()], queued, + "our unsent work was dropped on the strength of " + "another device's numbering") + self.assertIsNotNone(self.tracker._get_project('Mine')) + self.assertIsNotNone(self.tracker._get_project('Theirs')) + + def test_the_tracker_s_own_queue_is_the_one_that_is_used(self): + """ + Not a default one built on the spot. The MCP and REST servers can be + given a queue of their own, and draining the wrong one would leave + their changes queued for ever. + """ + private = Outbox(path=os.path.join(self.tmp, 'private.jsonl'), + lock_path=os.path.join(self.tmp, 'private.lock'), + highwater_path=os.path.join(self.tmp, 'private.hw')) + self.tracker.op_outbox = private + self.tracker.add_main_project('P') + queued = private.pending() + self.assertTrue(queued) + self.assertEqual(self.outbox.pending(), [], + "the change went into the default queue instead") + + # The log now shows that operation as ours, at a place of its own - + # so applying has to take it out of the tracker's queue, which is the + # private one. + mine = sync_client.device_identity()['device_uid'] + sync_engine._append_inbox({'base_seq': 1, 'ops': [ + dict(queued[0], s=1, dev=mine)]}) + sync_engine.apply_pending(self.tracker) + + self.assertEqual(private.pending(), [], + "the wrong queue was drained") + + def test_unsent_work_is_still_replayed_on_top(self): + """The guard must not swallow work the server has genuinely not seen.""" + self.tracker.add_main_project('P') + self.tracker.add_task('P', 'T') + task_uid = self.tracker._get_task('P', 'T')['uid'] + sync_engine.run_cycle(self.outbox) + sync_engine.apply_pending(self.tracker) + + self.server.add_foreign('task.set', uid=task_uid, f={'priority': 2}) + self.tracker.update_task('P', 'T', priority=9) + + fetched = sync_client.pull(sync_engine.read_state()['base_seq']) + sync_engine._append_inbox({'base_seq': fetched['head'], 'ops': fetched['ops']}) + sync_engine.apply_pending(self.tracker) + + self.assertEqual(self.tracker._get_task('P', 'T')['priority'], 9) + + def test_discarded_time_is_counted_once_however_many_batches_it_came_in(self): + """ + The queued work used to be replayed against every filed batch in turn, + so one lost entry was counted once per batch. The user reads that + count as hours. + """ + self.tracker.add_main_project('P') + self.tracker.add_task('P', 'Doomed') + task_uid = self.tracker._get_task('P', 'Doomed')['uid'] + self.tracker.delete_task('P', 'Doomed') + self.outbox.clear() + + self.outbox.append('entry.add', uid=E1, task=task_uid, + start='2026-08-10 09:00:00') + for seq in (1, 2, 3): + sync_engine._append_inbox({'base_seq': seq, 'ops': [ + {'s': seq, 'op': 'project.create', 'uid': '%016x' % seq, + 'dev': 'other', 'f': {'name': 'P%d' % seq}}]}) + + summary = sync_engine.apply_pending(self.tracker) + self.assertEqual(summary['discarded_time'], 1, str(summary)) + + def test_the_cursor_is_not_consumed_when_it_cannot_be_recorded(self): + """ + Consuming what arrived while failing to record how far it reached + leaves the cursor behind the document, and the same operations are + fetched again and replayed over newer work. + """ + self.server.add_foreign('project.create', uid=P1, f={'name': 'Remote'}) + sync_engine.run_cycle(self.outbox) + + real = sync_engine.write_state + + def refuse(changes, required=False): + if 'base_seq' in changes: + raise OSError("read-only file system") + return real(changes) + sync_engine.write_state = refuse + try: + with self.assertRaises(OSError): + sync_engine.apply_pending(self.tracker) + finally: + sync_engine.write_state = real + + self.assertEqual(len(sync_engine.read_inbox()), 1, + "the operations were consumed but the cursor was not moved") + + +class TestTheWorkerStops(EngineTestCase): + + def _alive(self): + import threading as _t + return [t for t in _t.enumerate() if t.name == 'tc-sync' and t.is_alive()] + + def test_switching_synchronisation_off_ends_the_worker(self): + """ + Otherwise it goes on talking to the server with the stored token for + as long as the app is open, filing operations nobody will read - and + the user who just switched it off has no way to tell. + """ + sync_engine.ensure_started({'sync': {'enabled': True}}) + self.assertEqual(len(self._alive()), 1) + + self.assertFalse(sync_engine.ensure_started({'sync': {'enabled': False}})) + + deadline = time.monotonic() + 5 + while time.monotonic() < deadline and self._alive(): + time.sleep(0.05) + self.assertEqual(self._alive(), [], "the worker outlived the setting") + + def test_switching_it_back_on_starts_a_new_one_and_only_one(self): + sync_engine.ensure_started({'sync': {'enabled': True}}) + sync_engine.ensure_started({'sync': {'enabled': False}}) + time.sleep(0.3) + sync_engine.ensure_started({'sync': {'enabled': True}}) + time.sleep(0.3) + self.assertEqual(len(self._alive()), 1, + "the stopped worker came back to life alongside the new one") + + +class TestOfferingIsDoneOnce(EngineTestCase): + + DATA = 'test_engine_offer.json' + + def setUp(self): + super().setUp() + if os.path.exists(self.DATA): + os.remove(self.DATA) + self.tracker = TimeTracker(file_path=self.DATA, op_outbox=self.outbox) + + def tearDown(self): + if os.path.exists(self.DATA): + os.remove(self.DATA) + super().tearDown() + + def test_the_whole_document_is_queued_in_one_go(self): + """ + One operation at a time meant taking the lock and re-reading the + entire queue for each - quadratic, on the thread that draws the + interface. Years of tracked time froze the app for minutes. + """ + self.tracker.add_main_project('P') + for n in range(60): + self.tracker.add_task('P', 'T%d' % n) + self.outbox.clear() + + calls = {'n': 0} + real_append = self.outbox.append + + def counted(op, **fields): + calls['n'] += 1 + return real_append(op, **fields) + self.outbox.append = counted + + queued = sync_engine.offer_document(self.tracker) + + self.assertEqual(queued, 61) + self.assertEqual(calls['n'], 0, "it still appends one at a time") + + # Numbered consecutively, and above the mark left by the changes that + # were made and then cleared - the server refuses anything at or + # below a number it has already seen from this device. + numbers = [e['lc'] for e in self.outbox.pending()] + self.assertEqual(len(numbers), 61) + self.assertEqual(numbers, list(range(numbers[0], numbers[0] + 61))) + self.assertGreater(numbers[0], 61) + + def test_two_tabs_offering_at_once_queue_one_copy(self): + """ + Both redraw independently, both see "not offered yet". Without the + lock each queues the whole document and the other machine has to chew + through two copies of everything. + """ + import threading as _t + self.tracker.add_main_project('P') + self.tracker.add_task('P', 'T') + self.outbox.clear() + + counts = [] + barrier = _t.Barrier(2) + + def offer(): + barrier.wait() + counts.append(sync_engine.offer_document(self.tracker)) + + threads = [_t.Thread(target=offer) for _ in range(2)] + for t in threads: + t.start() + for t in threads: + t.join(10) + + self.assertEqual(sorted(counts), [0, 2], str(counts)) + self.assertEqual(len(self.outbox.pending()), 2) + + +class TestWhenTheDiskItselfMisbehaves(EngineTestCase): + """ + Every one of these ends with the same requirement: nothing is consumed + that has not been safely recorded, and nothing crashes the application. + """ + + DATA = 'test_engine_disk.json' + + def setUp(self): + super().setUp() + if os.path.exists(self.DATA): + os.remove(self.DATA) + self.tracker = TimeTracker(file_path=self.DATA, op_outbox=self.outbox) + + def tearDown(self): + if os.path.exists(self.DATA): + os.remove(self.DATA) + super().tearDown() + + def test_state_that_cannot_be_written_is_shrugged_off_by_default(self): + """ + When the cycle ran and what went wrong are a convenience. Losing them + costs a line on the settings screen, so it must not cost the sync. + """ + os.chmod(self.tmp, 0o500) + try: + state = sync_engine.write_state({'last_error': 'unreachable'}) + finally: + os.chmod(self.tmp, 0o700) + self.assertIsInstance(state, dict) + + def test_but_the_cursor_refuses_to_fail_quietly(self): + os.chmod(self.tmp, 0o500) + try: + with self.assertRaises(OSError): + sync_engine.write_state({'base_seq': 5}, required=True) + finally: + os.chmod(self.tmp, 0o700) + + def test_a_cycle_that_cannot_write_locally_says_so(self): + self.queue('project.create', uid=P1, f={'name': 'P'}) + os.chmod(self.tmp, 0o500) + try: + result = sync_engine.run_cycle(self.outbox) + finally: + os.chmod(self.tmp, 0o700) + self.assertFalse(result['ok']) + self.assertIn(result['error'], ('local_io', 'unreachable')) + + def test_a_damaged_inbox_line_costs_that_line_and_no_more(self): + self.server.add_foreign('project.create', uid=P1, f={'name': 'Remote'}) + sync_engine.run_cycle(self.outbox) + with open(sync_engine.inbox_path(), 'a', encoding='utf-8') as f: + f.write('{"base_seq": 9, "ops": [\n') # cut short + self.assertEqual(len(sync_engine.read_inbox()), 1) + + def test_a_queue_that_will_not_take_the_document_costs_a_sync_not_the_app(self): + class Refusing: + def pending(self): + return [] + + def extend(self, operations, allow_overflow=False): + raise OSError("disk full") + + def append(self, op, **fields): + raise OSError("disk full") + + self.tracker.op_outbox = Refusing() + self.tracker.data['projects'] = [{'uid': P1, 'main_project_name': 'P', + 'status': 'open', 'last_started': None, + 'tasks': []}] + self.assertEqual(sync_engine.offer_document(self.tracker), 0) + self.assertFalse(sync_engine.read_state()['seeded'], + "it recorded the document as offered when it was not") + + def test_applying_steps_aside_when_the_inbox_is_held(self): + """ + Another process is mid-write. Waiting would stall the redraw, so this + leaves the records where they are and picks them up next time. + """ + from tt.filelock import locked + self.server.add_foreign('project.create', uid=P1, f={'name': 'Remote'}) + sync_engine.run_cycle(self.outbox) + + with locked(sync_engine._inbox_lock_path()): + self.assertIsNone(sync_engine.apply_pending(self.tracker)) + + self.assertEqual(len(sync_engine.read_inbox()), 1, + "the records were consumed without being applied") + self.assertIsNotNone(sync_engine.apply_pending(self.tracker)) + + def test_a_line_that_is_not_a_record_is_skipped(self): + """ + The inbox is appended to while the machine may be switched off, and + the file is read by a different thread than writes it. A line that + parses as JSON but is not a record must not reach reconcile, which + would then iterate over something that has no operations. + """ + self.server.add_foreign('project.create', uid=P1, f={'name': 'Remote'}) + sync_engine.run_cycle(self.outbox) + with open(sync_engine.inbox_path(), 'a', encoding='utf-8') as f: + f.write('[1, 2, 3]\n') # valid JSON, not a record + f.write('{"base_seq": 4}\n') # a record with no operations + f.write('"just a string"\n') + + records = sync_engine.read_inbox() + self.assertEqual(len(records), 1) + self.assertEqual(records[0]['ops'][0]['uid'], P1) + + def test_clearing_the_inbox_discards_what_was_waiting(self): + self.server.add_foreign('project.create', uid=P1, f={'name': 'Remote'}) + sync_engine.run_cycle(self.outbox) + self.assertTrue(sync_engine.read_inbox()) + sync_engine.clear_inbox() + self.assertEqual(sync_engine.read_inbox(), []) + + def test_the_interface_still_gets_an_answer_when_the_queue_is_unreadable(self): + original = sync_engine.Outbox + sync_engine.Outbox = lambda *a, **k: (_ for _ in ()).throw(OSError("gone")) + try: + snap = sync_engine.snapshot() + finally: + sync_engine.Outbox = original + self.assertEqual(snap['pending'], 0) + + +class TestTheInterval(EngineTestCase): + + def test_a_nonsense_interval_falls_back_instead_of_crashing(self): + sync_engine.ensure_started({'sync': {'enabled': True, 'interval_minutes': 'soon'}}) + self.assertEqual(sync_engine._interval_seconds(), + sync_engine.DEFAULT_INTERVAL_MINUTES * 60) + + def test_the_configured_interval_is_used(self): + sync_engine.ensure_started({'sync': {'enabled': True, 'interval_minutes': 12}}) + self.assertEqual(sync_engine._interval_seconds(), 12 * 60) + + def test_an_absurdly_short_interval_is_raised_to_a_minute(self): + """A cycle every few seconds would hammer the server for nothing.""" + sync_engine.ensure_started({'sync': {'enabled': True, 'interval_minutes': 0}}) + self.assertGreaterEqual(sync_engine._interval_seconds(), 60) + + def test_a_recent_success_holds_the_next_cycle_back(self): + sync_engine.run_cycle(self.outbox) + self.assertFalse(sync_engine._interval_elapsed(sync_engine.read_state())) + + +class TestWhatTheInterfaceIsTold(EngineTestCase): + + def test_before_anything_has_happened(self): + snap = sync_engine.snapshot() + self.assertEqual(snap['state'], 'never') + self.assertEqual(snap['pending'], 0) + + def test_after_a_good_cycle(self): + sync_engine.run_cycle(self.outbox) + snap = sync_engine.snapshot() + self.assertEqual(snap['state'], 'ok') + self.assertIsNotNone(snap['last_ok']) + self.assertIsNone(snap['error']) + + def test_after_a_bad_one(self): + self.server.fail_with = 'tls_failed' + sync_engine.run_cycle(self.outbox) + snap = sync_engine.snapshot() + self.assertEqual(snap['state'], 'failing') + self.assertEqual(snap['error'], 'tls_failed') + + def test_it_counts_what_is_waiting_in_both_directions(self): + self.queue('project.create', uid=P1, f={'name': 'P'}) + self.server.add_foreign('project.create', uid='c' * 16, f={'name': 'C'}) + snap = sync_engine.snapshot() + self.assertEqual(snap['pending'], 1) + + sync_engine.run_cycle(self.outbox) + snap = sync_engine.snapshot() + self.assertEqual(snap['pending'], 0) + self.assertEqual(snap['incoming'], 1) + + def test_it_asks_the_network_nothing(self): + def forbidden(*_a, **_k): + raise AssertionError("snapshot must never make a request") + sync_client.push = forbidden + sync_client.pull = forbidden + sync_engine.snapshot() + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_sync_outbox.py b/tests/test_sync_outbox.py new file mode 100644 index 0000000..09dab03 --- /dev/null +++ b/tests/test_sync_outbox.py @@ -0,0 +1,395 @@ +import json +import os +import shutil +import subprocess +import sys +import tempfile +import textwrap +import unittest + +REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) +sys.path.append(REPO) + +from tt.sync_outbox import Outbox, OutboxFull +from tt.filelock import locked, LockTimeout + + +class TestOutbox(unittest.TestCase): + + def setUp(self): + self.tmp = tempfile.mkdtemp() + self.box = Outbox(path=os.path.join(self.tmp, 'q.jsonl'), + lock_path=os.path.join(self.tmp, 'q.lock'), + highwater_path=os.path.join(self.tmp, 'q.hw')) + + def tearDown(self): + shutil.rmtree(self.tmp, ignore_errors=True) + + def test_empty_queue_reads_as_empty(self): + self.assertEqual(self.box.pending(), []) + self.assertEqual(self.box.count(), 0) + + def test_appending_numbers_operations_from_one_upwards(self): + self.assertEqual(self.box.append('task.set', uid='a' * 16, f={'p': 1}), 1) + self.assertEqual(self.box.append('task.set', uid='a' * 16, f={'p': 2}), 2) + self.assertEqual([e['lc'] for e in self.box.pending()], [1, 2]) + + def test_numbering_continues_after_a_restart(self): + """ + The counter cannot live in one process's memory: the number must never + repeat for this device, and the next change may well be made by a + different process - or after the machine was switched off. + """ + self.box.append('task.set', uid='a' * 16) + self.box.append('task.set', uid='a' * 16) + reopened = Outbox(path=self.box.path, lock_path=self.box.lock_path, + highwater_path=self.box.highwater_path) + self.assertEqual(reopened.append('task.set', uid='a' * 16), 3) + + def test_numbering_continues_after_the_middle_is_acknowledged(self): + """Dropping entry 2 must not let 3 be handed out twice.""" + for _ in range(3): + self.box.append('task.set', uid='a' * 16) + self.box.drop([2]) + self.assertEqual([e['lc'] for e in self.box.pending()], [1, 3]) + self.assertEqual(self.box.append('task.set', uid='a' * 16), 4) + + def test_numbering_continues_after_the_queue_has_been_emptied(self): + """ + The case that ends synchronisation altogether if the counter is read + from the queue alone. A successful sync drains it; if the next number + then started again at one, the server - which refuses anything at or + below what it has already seen from this device - would discard every + change from that point on, silently and for ever. + """ + for _ in range(3): + self.box.append('task.set', uid='a' * 16) + self.box.drop([1, 2, 3]) + self.assertEqual(self.box.pending(), []) + + self.assertEqual(self.box.append('task.set', uid='a' * 16), 4) + + def test_the_counter_survives_a_restart_with_an_empty_queue(self): + self.box.append('task.set', uid='a' * 16) + self.box.drop([1]) + reopened = Outbox(path=self.box.path, lock_path=self.box.lock_path, + highwater_path=self.box.highwater_path) + self.assertEqual(reopened.append('task.set', uid='a' * 16), 2) + + def test_clearing_does_not_reset_the_counter(self): + """ + clear() is for re-seeding a machine. The server still remembers the + numbers this device has used, so starting over would make everything + sent afterwards look like a repeat. + """ + self.box.append('task.set', uid='a' * 16) + self.box.append('task.set', uid='a' * 16) + self.box.clear() + self.assertEqual(self.box.append('task.set', uid='a' * 16), 3) + + def test_operations_are_read_back_in_numbered_order(self): + """ + The server stamps a batch in the order it receives it, then refuses + anything at or below the highest number it has seen. Sending 3 after + 5 would therefore lose 3. + """ + self.box.append('task.set', uid='a' * 16, f={'p': 1}) + self.box.append('task.set', uid='a' * 16, f={'p': 2}) + with open(self.box.path, 'r', encoding='utf-8') as f: + lines = f.readlines() + with open(self.box.path, 'w', encoding='utf-8') as f: + f.writelines(reversed(lines)) # as a concurrent append could + self.assertEqual([e['lc'] for e in self.box.pending()], [1, 2]) + + def test_none_valued_fields_are_left_out(self): + """Sending an absent value as null would overwrite a real one.""" + self.box.append('task.set', uid='a' * 16, project=None, f={'p': 1}) + entry = self.box.pending()[0] + self.assertNotIn('project', entry) + self.assertEqual(entry['f'], {'p': 1}) + + def test_dropping_removes_only_what_was_acknowledged(self): + for _ in range(4): + self.box.append('task.set', uid='a' * 16) + self.box.drop([1, 3]) + self.assertEqual([e['lc'] for e in self.box.pending()], [2, 4]) + + def test_clearing_empties_the_queue(self): + self.box.append('task.set', uid='a' * 16) + self.box.clear() + self.assertEqual(self.box.pending(), []) + + def test_a_damaged_line_costs_one_change_not_the_whole_queue(self): + """ + Several processes append here and a machine can be switched off + mid-write. One unreadable line must not make everything else + unsendable. + """ + self.box.append('task.set', uid='a' * 16, f={'p': 1}) + with open(self.box.path, 'a', encoding='utf-8') as f: + f.write('{"op": "task.set", "lc": 2, "f": {"p"\n') # cut short + self.box.append('task.set', uid='a' * 16, f={'p': 3}) + + surviving = self.box.pending() + self.assertEqual([e['f']['p'] for e in surviving], [1, 3]) + + def test_a_line_that_is_not_an_operation_is_skipped(self): + """ + Parses as JSON but is not one of ours - a stray line, or a file that + was something else. Without the number it cannot be sent or + acknowledged, so it must not reach the batch. + """ + self.box.append('task.set', uid='a' * 16, f={'p': 1}) + with open(self.box.path, 'a', encoding='utf-8') as f: + f.write('[1, 2, 3]\n') + f.write('{"op": "task.set"}\n') # no number + f.write('"a string"\n') + + surviving = self.box.pending() + self.assertEqual([e['lc'] for e in surviving], [1]) + + def test_a_batch_that_exactly_fills_the_queue_is_accepted(self): + """The boundary: at the limit is allowed, past it is not.""" + import tt.sync_outbox as mod + original = mod.MAX_PENDING + mod.MAX_PENDING = 3 + try: + self.assertEqual(len(self.box.extend([{'op': 'task.set'}] * 3)), 3) + finally: + mod.MAX_PENDING = original + + def test_the_queue_refuses_to_grow_without_limit(self): + """ + A queue that grew for ever would turn a long outage into a full disk, + and a batch that can never fit into one request. + """ + import tt.sync_outbox as mod + original = mod.MAX_PENDING + mod.MAX_PENDING = 3 + try: + for _ in range(3): + self.box.append('task.set', uid='a' * 16) + with self.assertRaises(OutboxFull): + self.box.append('task.set', uid='a' * 16) + finally: + mod.MAX_PENDING = original + + + def test_a_bulk_batch_refuses_to_overflow_the_queue_by_default(self): + import tt.sync_outbox as mod + original = mod.MAX_PENDING + mod.MAX_PENDING = 3 + try: + with self.assertRaises(OutboxFull): + self.box.extend([{'op': 'task.set', 'uid': 'a' * 16}] * 4) + self.assertEqual(self.box.pending(), [], + "a refused batch left part of itself behind") + finally: + mod.MAX_PENDING = original + + def test_but_an_existing_document_may_exceed_it(self): + """ + The limit is there to stop a queue growing without bound while syncing + is broken. Describing a document the server has never seen is a single + finite batch that then drains - and refusing it would mean that + machine is never offered at all, silently. + """ + import tt.sync_outbox as mod + original = mod.MAX_PENDING + mod.MAX_PENDING = 3 + try: + numbers = self.box.extend([{'op': 'task.set', 'uid': 'a' * 16}] * 5, + allow_overflow=True) + self.assertEqual(numbers, [1, 2, 3, 4, 5]) + finally: + mod.MAX_PENDING = original + + def test_an_empty_batch_does_nothing_at_all(self): + self.assertEqual(self.box.extend([]), []) + self.assertEqual(self.box.pending(), []) + + def test_bulk_and_single_appends_share_one_counter(self): + self.box.append('task.set', uid='a' * 16) + self.assertEqual(self.box.extend([{'op': 'task.set', 'uid': 'a' * 16}] * 2), [2, 3]) + self.assertEqual(self.box.append('task.set', uid='a' * 16), 4) + + +class TestTheQueueMakesItsOwnDirectory(unittest.TestCase): + """ + On a machine that has never synced there is no configuration directory + yet, and the first change recorded has to create it rather than fail. + """ + + def setUp(self): + self.tmp = tempfile.mkdtemp() + self.root = os.path.join(self.tmp, 'never', 'been', 'here') + + def tearDown(self): + shutil.rmtree(self.tmp, ignore_errors=True) + + def _box(self): + return Outbox(path=os.path.join(self.root, 'q.jsonl'), + lock_path=os.path.join(self.root, 'q.lock'), + highwater_path=os.path.join(self.root, 'q.hw')) + + def test_a_single_append_creates_it(self): + self.assertEqual(self._box().append('task.set', uid='a' * 16), 1) + self.assertTrue(os.path.isdir(self.root)) + + def test_a_bulk_append_creates_it(self): + self.assertEqual(self._box().extend([{'op': 'task.set'}]), [1]) + self.assertTrue(os.path.isdir(self.root)) + + +class TestWhetherTheQueueIsUsedAtAll(unittest.TestCase): + """ + Every installation without synchronisation configured - which is all of + them until somebody switches it on - must get no queue whatsoever. + """ + + def test_no_queue_unless_it_is_switched_on(self): + from tt.sync_outbox import default_outbox_if_enabled + for config in ({}, None, 'nonsense', {'sync': None}, {'sync': 'yes'}, + {'sync': {}}, {'sync': {'enabled': False}}): + self.assertIsNone(default_outbox_if_enabled(config), repr(config)) + + def test_a_queue_once_it_is(self): + from tt.sync_outbox import default_outbox_if_enabled, Outbox + self.assertIsInstance(default_outbox_if_enabled({'sync': {'enabled': True}}), Outbox) + + +class TestFileLock(unittest.TestCase): + + def setUp(self): + self.tmp = tempfile.mkdtemp() + self.lock = os.path.join(self.tmp, 'x.lock') + + def tearDown(self): + shutil.rmtree(self.tmp, ignore_errors=True) + + def test_the_lock_can_be_taken_and_released(self): + with locked(self.lock): + pass + with locked(self.lock): + pass + + def test_two_threads_of_one_process_exclude_each_other(self): + """ + Not the same guarantee as between processes, and the one the sync + worker actually depends on: it runs in a thread beside the one drawing + the interface, and both reach for the queue and the inbox. On POSIX + flock attaches to the open file description, and locked() opens a + fresh handle each time, so this holds - but it holds by accident of + that detail rather than by design, which is why it is pinned here. + """ + import threading + holding = threading.Event() + release = threading.Event() + refused = [] + + def hold(): + with locked(self.lock): + holding.set() + release.wait(5) + + keeper = threading.Thread(target=hold, daemon=True) + keeper.start() + self.assertTrue(holding.wait(5), "the first thread never took the lock") + try: + with locked(self.lock, timeout=0.3): + refused.append(False) + except LockTimeout: + refused.append(True) + finally: + release.set() + keeper.join(5) + + self.assertEqual(refused, [True], + "a second thread walked straight into a held lock") + + def test_the_lock_is_free_again_once_the_holder_lets_go(self): + with locked(self.lock): + pass + with locked(self.lock, timeout=0.3): + pass + + def test_a_lock_in_a_directory_that_does_not_exist_yet(self): + """The configuration directory on a machine that has never synced.""" + nested = os.path.join(self.tmp, 'not', 'yet', 'there.lock') + with locked(nested): + pass + self.assertTrue(os.path.exists(nested)) + + def test_waiting_gives_up_instead_of_hanging_for_ever(self): + """ + Blocking indefinitely on a lock some crashed process appears to hold + would freeze the interface this runs behind. Failing lets the caller + try again on the next cycle. + """ + helper = textwrap.dedent(""" + import sys, time + sys.path.insert(0, %r) + from tt.filelock import locked + with locked(%r): + print("held", flush=True) + time.sleep(5) + """) % (REPO, self.lock) + child = subprocess.Popen([sys.executable, '-c', helper], stdout=subprocess.PIPE, text=True) + try: + self.assertEqual(child.stdout.readline().strip(), "held") + with self.assertRaises(LockTimeout): + with locked(self.lock, timeout=0.5): + pass + finally: + child.kill() + child.wait() + child.stdout.close() + + +class TestConcurrentAppends(unittest.TestCase): + """ + The reason the lock exists. The GUI and the MCP, REST and SOAP servers can + all be recording changes at the same moment. Two of them handing the same + number to two different operations would make the server treat the second + as a repeat and discard it - a change lost without any error anywhere. + """ + + def setUp(self): + self.tmp = tempfile.mkdtemp() + self.path = os.path.join(self.tmp, 'q.jsonl') + self.lock = os.path.join(self.tmp, 'q.lock') + self.hw = os.path.join(self.tmp, 'q.hw') + + def tearDown(self): + shutil.rmtree(self.tmp, ignore_errors=True) + + def test_four_processes_never_reuse_a_number(self): + per_process = 25 + workers = 4 + helper = textwrap.dedent(""" + import sys + sys.path.insert(0, %r) + from tt.sync_outbox import Outbox + box = Outbox(path=%r, lock_path=%r, highwater_path=%r) + for i in range(%d): + box.append('task.set', uid='a'*16, f={'i': i}) + """) % (REPO, self.path, self.lock, self.hw, per_process) + + children = [subprocess.Popen([sys.executable, '-c', helper]) for _ in range(workers)] + for child in children: + self.assertEqual(child.wait(timeout=60), 0) + + entries = Outbox(path=self.path, lock_path=self.lock, + highwater_path=self.hw).pending() + numbers = [e['lc'] for e in entries] + + self.assertEqual(len(numbers), workers * per_process, + "an append was lost entirely") + self.assertEqual(len(set(numbers)), len(numbers), + "the same number was handed out twice - a change would be dropped") + self.assertEqual(sorted(numbers), list(range(1, workers * per_process + 1)), + "the numbering has gaps or does not start at one") + + +if __name__ == '__main__': + unittest.main() diff --git a/tt/TimeTracker.py b/tt/TimeTracker.py index 2352de3..11b271e 100644 --- a/tt/TimeTracker.py +++ b/tt/TimeTracker.py @@ -3,6 +3,7 @@ import tempfile import imaplib import re +import uuid import email from email.header import decode_header from i18n import _ @@ -32,17 +33,66 @@ parse_version = None +def _new_uid(): + """ + Returns a fresh identifier for a project, task or time entry. + + This is deliberately NOT the same thing as a task's integer 'id'. + That one is a purely local counter (see next_id) and may legitimately + differ between two machines holding the very same task - it exists so + the GUI and the MCP/REST/SOAP interfaces have a short handle to pass + around. This identifier, by contrast, is generated randomly, so two + machines editing offline never produce the same one for different + objects. It is what an entity can be addressed by across machines. + + 16 hex characters are 64 bits of randomness. For the few tens of + thousands of entities a personal time tracker accumulates over years, + the chance of a collision is negligible, while keeping every stored + record half the length a full uuid4 would add. + + :return: A 16-character hexadecimal identifier. + :rtype: str + """ + return uuid.uuid4().hex[:16] + + +# The task attributes that mean the same thing on every machine. Deliberately +# excluded: 'uid' (it is the address, not a field), 'id' (a local counter that +# is allowed to differ between machines) and 'time_entries' (carried by their +# own operations, so a task and its entries can be reconciled independently). +TASK_SYNC_FIELDS = ( + "task_name", "status", "due_date", "today", "note", + "recurring", "frequency", "userdefined_days", "priority", "last_started", +) + + +def _task_fields(task): + """Returns the syncable attributes of a task.""" + return {k: task.get(k) for k in TASK_SYNC_FIELDS if k in task} + + class TimeTracker: """ Manages time tracking for various main and sub-projects. The data is loaded from and saved to a JSON file. """ - VERSION = "3.32" + VERSION = "4.1" STATUS_OPEN = "open" STATUS_CLOSED = "closed" STATUS_DONE = "done" HIDDEN_PROJECT = "hide" + # Shape of data.json. 1 = the implicit, unstamped original layout; + # 2 added a 'uid' to every project/task/time entry, a 'last_started' + # timestamp, and the '_deleted' tombstone list. Stamped so a migration + # can be recognised as already done rather than re-derived field by + # field on every start. + SCHEMA_VERSION = 2 + # How long a tombstone is kept before it is swept up. This has to stay + # comfortably longer than the longest stretch a copy of the data might + # plausibly go without being reconciled: forget a deletion here while + # another copy still holds the object, and the object comes back. + TOMBSTONE_RETENTION_DAYS = 90 # Hard ceiling per package for the pip-install subprocess below. pip's own # request/retry timeouts only bound its HTTP phase, not the DNS lookup # that happens first - with no internet connection that lookup can hang @@ -52,7 +102,7 @@ class TimeTracker: # case, where subprocess's timeout= kills the pip child outright. PIP_INSTALL_TIMEOUT = 120 - def __init__(self, file_path=None): + def __init__(self, file_path=None, op_outbox=None): """ Initializes the TimeTracker, checks for dependencies, and loads data from the JSON file. @@ -60,22 +110,64 @@ def __init__(self, file_path=None): If None, the path is read from config.json (key 'data_file'). Defaults to 'data.json'. :type file_path: str - """ + :param op_outbox: Where changes are recorded for the sync server. Left + as None it is derived from config.json, which for + any installation without synchronisation switched on + - including every one that predates the feature - + means no queue and no recording at all. Passing one + explicitly is what the tests do. + """ + config = {} + if os.path.exists('config.json'): + try: + with open('config.json', 'r', encoding='utf-8') as f: + config = json.load(f) + except (IOError, json.JSONDecodeError): + config = {} + if file_path is None: - file_path = 'data.json' - if os.path.exists('config.json'): - try: - with open('config.json', 'r', encoding='utf-8') as f: - config = json.load(f) - file_path = config.get('data_file', 'data.json') - except (IOError, json.JSONDecodeError): - pass + file_path = config.get('data_file', 'data.json') if config else 'data.json' self.file_path = file_path + self.op_outbox = op_outbox + if self.op_outbox is None: + try: + from tt.sync_outbox import default_outbox_if_enabled + self.op_outbox = default_outbox_if_enabled(config) + except ImportError: + self.op_outbox = None + self.data = self._load_data() if self._migrate_data_structure(): + # Migration is not a user action - it changes the shape of the + # document, not its content - so it is deliberately not recorded + # as operations. The other machine performs the same migration on + # its own copy. self._save_data() + def _emit(self, op, **fields): + """ + Records one change for the sync server. + + Does nothing at all when synchronisation is off, which is the default + and the only state an installation without a configured server can be + in. + + Failures here are swallowed on purpose. This runs inside every + mutating operation, and a queue that cannot be written - a full disk, + a lock another process is sitting on - must not stop the user from + tracking their time. The cost is that the change is not synced; the + cost of the alternative is that the app stops working. + + :param op: One of the operation names the server accepts. + """ + if self.op_outbox is None: + return + try: + self.op_outbox.append(op, **fields) + except Exception: + pass + def initialize_dependencies(self): """ Public method to check and install dependencies. @@ -151,11 +243,40 @@ def _load_data(self): else: return {"projects": []} + def reload_data(self): + """ + Re-reads the data file from disk and brings it up to the current schema. + + Callers that only want to pick up changes another process made used to + reach for _load_data() directly, which hands back whatever the file + happens to hold. Migration runs in __init__ alone, so the refreshed + document skipped it - and the rest of this class then reads fields it + assumes are present (add_task takes self.data["next_id"] with no + guard). That holds together while every writer runs this same version. + It stops holding when the file arrives from somewhere else: a restored + backup, a copy written by an older version, and in future one + reconciled with another machine. + + If the file cannot be read the exception propagates and the previously + loaded data is left untouched, so a caller can treat a transient + failure as "keep what we have". + """ + self.data = self._load_data() + if self._migrate_data_structure(): + self._save_data() + def _migrate_data_structure(self): """ Ensures that the data structure is up to date. - Adds 'status': 'open' to sub-projects if missing. - + - Brings a schema-1 file up to schema 2: a 'uid' on every project, + task and time entry, a 'last_started' timestamp, and the + '_deleted' tombstone list (see SCHEMA_VERSION). + + Every step keys off the presence of the individual field rather + than off the version stamp alone, so the migration stays idempotent + and a file written by a mix of app versions still converges. + :return: True if data was changed, otherwise False. :rtype: bool """ @@ -165,17 +286,24 @@ def _migrate_data_structure(self): self.data["projects"] = [] data_changed = True # The data object itself was changed - # Initialize or update next_id - if "next_id" not in self.data: - max_id = 0 - for project in self.data.get("projects", []): - for task in project.get("tasks", []): - try: - # Try to read existing integer IDs - tid = int(task.get("id")) - if tid > max_id: max_id = tid - except (ValueError, TypeError, KeyError): - pass + # Initialize next_id - or lift it back above the highest id actually + # in use. This used to run only when the key was missing entirely and + # never re-validated afterwards, so a file that gained tasks from + # somewhere else (a restored backup, a hand edit, and in future a + # sync) could end up with the counter sitting at or below a live id. + # The next add_task() would then hand out an id a task already has, + # and since nothing anywhere checks id uniqueness that duplicate + # stays silent right up until delete_task() removes both of them. + max_id = 0 + for project in self.data.get("projects", []): + for task in project.get("tasks", []): + try: + tid = int(task.get("id")) + except (ValueError, TypeError): + continue + if tid > max_id: + max_id = tid + if self.data.get("next_id") is None or self.data["next_id"] <= max_id: self.data["next_id"] = max_id + 1 data_changed = True @@ -190,6 +318,11 @@ def _migrate_data_structure(self): project["tasks"] = project.pop("sub_projects") data_changed = True + # Schema 2: a machine-independent identity (see _new_uid). + if not project.get("uid"): + project["uid"] = _new_uid() + data_changed = True + for task in project.get("tasks", []): if "sub_project_name" in task: task["task_name"] = task.pop("sub_project_name") @@ -225,6 +358,60 @@ def _migrate_data_structure(self): task["id"] = self.data["next_id"] self.data["next_id"] += 1 data_changed = True + + # Schema 2: identity, and the time entries below it. + if not task.get("uid"): + task["uid"] = _new_uid() + data_changed = True + + for entry in task.get("time_entries", []): + if not entry.get("uid"): + entry["uid"] = _new_uid() + data_changed = True + + # Schema 2: 'last_started' will replace the implicit + # most-recently-used ordering that start_work() currently + # expresses by moving entries to the front of the list - + # array position is state two machines would otherwise have + # to agree on. Seeded from the newest time entry so the + # existing ordering survives the switch instead of every + # task starting out equal. + if "last_started" not in task: + starts = [e.get("start_time") for e in task.get("time_entries", []) if e.get("start_time")] + task["last_started"] = max(starts) if starts else None + data_changed = True + + if "last_started" not in project: + task_starts = [t["last_started"] for t in project.get("tasks", []) if t.get("last_started")] + project["last_started"] = max(task_starts) if task_starts else None + data_changed = True + + # Schema 2: deletions have to leave a trace. Without one, a machine + # receiving an update cannot tell 'this was deleted elsewhere' apart + # from 'this does not exist here yet', and deleted items come back on + # every merge. Nothing writes to this list yet - that lands together + # with the sync layer. + if "_deleted" not in self.data: + self.data["_deleted"] = [] + data_changed = True + + # Sweep expired tombstones. They only need to outlive the moment every + # copy of the data has certainly seen them; keeping them for good would + # grow the document without bound. Done here rather than on write so a + # long-idle file is tidied on the next start, and so the sweep cannot + # run in the middle of a deletion. + cutoff = (datetime.now() - timedelta(days=self.TOMBSTONE_RETENTION_DAYS)).isoformat() + still_relevant = [t for t in self.data["_deleted"] if t.get("at", "") >= cutoff] + if len(still_relevant) != len(self.data["_deleted"]): + self.data["_deleted"] = still_relevant + data_changed = True + + # Stamped last, so a run that fails part way through is not recorded + # as a completed migration. + if self.data.get("schema_version") != self.SCHEMA_VERSION: + self.data["schema_version"] = self.SCHEMA_VERSION + data_changed = True + return data_changed def _save_data(self): @@ -352,26 +539,40 @@ def _get_task(self, main_project_name, task_name=None, task_id=None): Helper method to find a task by ID or name within a main project. If searching by name and duplicates exist, it prioritizes 'open' tasks. + An id, when given, decides on its own: it names exactly one task, + while names are not unique - nothing stops two tasks in the same + project from sharing one. Both checks used to sit in the same pass of + one loop, so a task that merely matched the name could be returned + ahead of the task that actually carried the requested id, and callers + pass both together as a matter of course. + :param main_project_name: The name of the main project. :param task_name: The name of the task (optional). :param task_id: The unique ID of the task (optional, preferred). :return: The task dictionary or None if not found. """ project = self._get_project(main_project_name) - if project: - fallback_task = None + if not project: + return None + + if task_id is not None: for task in project["tasks"]: - if task_id is not None: - # Robust comparison handling integer and string IDs - if str(task.get("id")) == str(task_id): - return task - if task_name and task["task_name"] == task_name: - if task.get("status") == self.STATUS_OPEN: - return task - if fallback_task is None: - fallback_task = task - return fallback_task - return None + # Robust comparison handling integer and string IDs + if str(task.get("id")) == str(task_id): + return task + return None + + if not task_name: + return None + + fallback_task = None + for task in project["tasks"]: + if task["task_name"] == task_name: + if task.get("status") == self.STATUS_OPEN: + return task + if fallback_task is None: + fallback_task = task + return fallback_task def add_main_project(self, main_project_name): """ @@ -381,11 +582,15 @@ def add_main_project(self, main_project_name): :type main_project_name: str """ new_project = { + "uid": _new_uid(), "main_project_name": main_project_name, "tasks": [], - "status": self.STATUS_OPEN + "status": self.STATUS_OPEN, + "last_started": None } self.data["projects"].append(new_project) + self._emit('project.create', uid=new_project["uid"], + f={"name": main_project_name, "status": self.STATUS_OPEN}) self._save_data() def list_main_projects(self, status_filter='all'): @@ -408,6 +613,64 @@ def list_main_projects(self, status_filter='all'): }) return projects + def _record_deletion(self, entity, kind): + """ + Notes that one project or task was deleted on purpose. + + Deletion is the one change that cannot be recognised from the data + that is left behind: an object another copy has and this one does not + is either an object this copy has not been told about yet, or one it + deleted - and those look identical. This note is what tells them + apart, so a deleted object is not handed back on the next reconcile. + + Only real deletions belong here. Removing an entry from a list is not + by itself one: move_task() takes a task out of one project to put it + into another, and the task lives on. Recording that as a deletion + would destroy it everywhere else. + + :param entity: The project or task dict being deleted. + :type entity: dict + :param kind: Which level it sits on - 'project' or 'task'. + :type kind: str + """ + uid = entity.get("uid") + if not uid: + # Written by a version that predates uids (a rollback, say). + # There is no identity to point at, so there is nothing useful + # to record - better an unrecorded deletion than a note naming + # nothing. + return + self.data.setdefault("_deleted", []).append({ + "uid": uid, + "kind": kind, + "at": datetime.now().isoformat() + }) + # Told to the sync server from here rather than from each of the five + # call sites, so the operations and the tombstones cannot drift apart + # - they are now the same decision, taken once. In particular the + # deliberate omissions carry over for free: move_task never reaches + # this method, and time entries never get a note of their own. + self._emit(kind + '.delete', uid=uid) + + def _record_project_deletion(self, project): + """ + Notes a deleted project together with every task that went with it. + + Time entries deliberately get no note of their own: nothing in this + class deletes a single entry, so an entry only ever disappears along + with the task holding it - and that task's note already accounts for + it. One note per project plus one per task keeps the list bounded + while still covering the case where the other copy has meanwhile + moved a task out of this project, which a project-only note would + wrongly take down with it. + + :param project: The project dict being deleted. + :type project: dict + """ + self._record_deletion(project, "project") + for task in project.get("tasks", []): + self._record_deletion(task, "task") + def delete_main_project(self, main_project_name): """ Deletes a main project along with all associated tasks and time entries. @@ -418,10 +681,15 @@ def delete_main_project(self, main_project_name): :rtype: bool """ initial_count = len(self.data["projects"]) + # Duplicate project names are creatable, and the filter below drops + # every match - so collect them all rather than assuming there is one. + removed = [p for p in self.data["projects"] if p["main_project_name"] == main_project_name] self.data["projects"] = [ project for project in self.data["projects"] if project["main_project_name"] != main_project_name ] if len(self.data["projects"]) < initial_count: + for project in removed: + self._record_project_deletion(project) self._save_data() return True return False @@ -445,6 +713,7 @@ def rename_main_project(self, old_name, new_name): project = self._get_project(old_name) if project: project["main_project_name"] = new_name + self._emit('project.set', uid=project.get("uid"), f={"name": new_name}) self._save_data() return True return False @@ -461,6 +730,7 @@ def close_main_project(self, main_project_name): project = self._get_project(main_project_name) if project: project["status"] = self.STATUS_CLOSED + self._emit('project.set', uid=project.get("uid"), f={"status": self.STATUS_CLOSED}) self._save_data() return True return False @@ -477,6 +747,7 @@ def reopen_main_project(self, main_project_name): project = self._get_project(main_project_name) if project: project["status"] = self.STATUS_OPEN + self._emit('project.set', uid=project.get("uid"), f={"status": self.STATUS_OPEN}) self._save_data() return True return False @@ -506,6 +777,7 @@ def add_task(self, main_project_name, task_name, due_date=None, today=False, not project = self._get_project(main_project_name) if project: new_task = { + "uid": _new_uid(), "id": self.data["next_id"], "task_name": task_name, "time_entries": [], @@ -516,10 +788,13 @@ def add_task(self, main_project_name, task_name, due_date=None, today=False, not "recurring": recurring, "frequency": frequency, "userdefined_days": userdefined_days, - "priority": priority + "priority": priority, + "last_started": None } self.data["next_id"] += 1 project["tasks"].append(new_task) + self._emit('task.create', uid=new_task["uid"], + project=project.get("uid"), f=_task_fields(new_task)) self._save_data() return True return False @@ -613,6 +888,14 @@ def cleanup_overdue_today_tasks(self): """ Removes the 'today' flag (⭐) from tasks that have a due date in the past. + Deliberately sends nothing to the sync server. Both machines run this + same sweep, from the same rule, against the same due dates, so each + reaches the identical result on its own. Sending it would spend + traffic saying something the other side already knows, and two + machines re-deriving and re-sending it could bounce it back and + forth. What the sweep reads from - the due date - is synced; what it + concludes is not. + :return: True if any task was updated and saved. :rtype: bool """ @@ -632,6 +915,10 @@ def set_today_flag_for_due_tasks(self): Sets the 'today' flag (⭐) for tasks that have today's date as their due date and are not yet marked as 'today'. + Like cleanup_overdue_today_tasks above, this sends nothing to the + sync server: it is derived from the due date, which is synced, so the + other machine reaches the same conclusion by itself. + :return: True if any task was updated and saved. :rtype: bool """ @@ -664,12 +951,18 @@ def delete_task(self, main_project_name, task_name, task_id=None): project = self._get_project(main_project_name) if project: initial_count = len(project["tasks"]) + # Both filters below remove EVERY match, not just the first, so + # the removed set is collected the same way. if task_id is not None: + removed = [t for t in project["tasks"] if str(t.get("id")) == str(task_id)] project["tasks"] = [t for t in project["tasks"] if str(t.get("id")) != str(task_id)] else: + removed = [t for t in project["tasks"] if t["task_name"] == task_name] project["tasks"] = [t for t in project["tasks"] if t["task_name"] != task_name] - + if len(project["tasks"]) < initial_count: + for task in removed: + self._record_deletion(task, "task") self._save_data() return True return False @@ -686,6 +979,7 @@ def delete_all_closed_tasks(self): tasks = project.get("tasks", []) for i in range(len(tasks) - 1, -1, -1): if tasks[i].get("status") == self.STATUS_CLOSED: + self._record_deletion(tasks[i], "task") del tasks[i] deleted_count += 1 @@ -709,6 +1003,7 @@ def close_task(self, main_project_name, task_name, task_id=None): task = self._get_task(main_project_name, task_name, task_id) if task: task["status"] = self.STATUS_CLOSED + self._emit('task.set', uid=task.get("uid"), f={"status": self.STATUS_CLOSED}) self._save_data() return True return False @@ -728,6 +1023,7 @@ def reopen_task(self, main_project_name, task_name, task_id=None): task = self._get_task(main_project_name, task_name, task_id) if task: task["status"] = self.STATUS_OPEN + self._emit('task.set', uid=task.get("uid"), f={"status": self.STATUS_OPEN}) self._save_data() return True return False @@ -752,18 +1048,22 @@ def rename_task(self, main_project_name, old_task_name, new_task_name, task_id=N task = self._get_task(main_project_name, old_task_name, task_id) if task: task["task_name"] = new_task_name + self._emit('task.set', uid=task.get("uid"), f={"task_name": new_task_name}) self._save_data() return True return False - def update_task(self, main_project_name, old_task_name, new_task_name=None, due_date=None, today=None, note=None, status=None, recurring=None, frequency=None, userdefined_days=None, priority=None, task_id=None): + def update_task(self, main_project_name, old_task_name, new_task_name=None, due_date=None, today=None, note=None, status=None, recurring=None, frequency=None, userdefined_days=None, priority=None, task_id=None, clear_due_date=False): """ - Updates a task's properties. + Updates a task's properties. Every field is left as it is unless a + value for it is actually passed, the due date included - removing a + due date is asked for explicitly, via clear_due_date. :param main_project_name: Name of the main project. :param old_task_name: Current name of the task. :param new_task_name: New name (optional). - :param due_date: New due date (optional, ISO string or None). + :param due_date: New due date (optional, ISO string). None keeps the + current one; use clear_due_date to remove it. :param today: New today status (optional, bool). :param note: New note (optional, str). :param status: New status (optional, str). @@ -772,12 +1072,22 @@ def update_task(self, main_project_name, old_task_name, new_task_name=None, due_ :param userdefined_days: Days for userdefined frequency (optional, int). :param priority: Priority from 0 (lowest) to 9 (highest) (optional, int). :param task_id: Unique ID of the task (optional). + :param clear_due_date: Remove the task's due date (optional, bool). + Takes precedence over due_date. Last in the + signature so existing positional callers keep + working. :return: True if successful. """ project = self._get_project(main_project_name) if project: task = self._get_task(main_project_name, old_task_name, task_id) if task: + # Snapshot taken so the change can be reported as a difference + # rather than by listing the fields again here. That keeps the + # two from drifting when a field is added later, and means + # nothing is sent when a save turns out to change nothing. + before = _task_fields(task) + # Handle recurring task generation is_completing = (status == self.STATUS_DONE and task.get("status") != self.STATUS_DONE) is_recurring = recurring if recurring is not None else task.get("recurring", False) @@ -788,8 +1098,16 @@ def update_task(self, main_project_name, old_task_name, new_task_name=None, due_ if new_task_name: task["task_name"] = new_task_name - # Update due_date (always update to what's provided) - task["due_date"] = due_date + # An omitted due_date means "unchanged", exactly like every + # other field here. It used to mean "clear", so a caller + # updating one unrelated field - a PATCH carrying just a + # priority, say - wiped the due date as a side effect, and + # with sync enabled dutifully propagated that to the user's + # other machines. + if clear_due_date: + task["due_date"] = None + elif due_date is not None: + task["due_date"] = due_date # Update today status if provided if today is not None: @@ -812,6 +1130,11 @@ def update_task(self, main_project_name, old_task_name, new_task_name=None, due_ if priority is not None: task["priority"] = priority + after = _task_fields(task) + changed = {k: v for k, v in after.items() if before.get(k) != v} + if changed: + self._emit('task.set', uid=task.get("uid"), f=changed) + self._save_data() return True return False @@ -826,6 +1149,7 @@ def _create_next_recurring_instance(self, project, task, due_date_param, recurri next_due = self._calculate_next_due_date(base_due, freq, ud_days) new_task = { + "uid": _new_uid(), "id": self.data["next_id"], "task_name": task["task_name"], "time_entries": [], # Start with a fresh, empty list for the new instance @@ -836,10 +1160,13 @@ def _create_next_recurring_instance(self, project, task, due_date_param, recurri "recurring": True, "frequency": freq, "userdefined_days": ud_days, - "priority": priority + "priority": priority, + "last_started": None } self.data["next_id"] += 1 project["tasks"].append(new_task) + self._emit('task.create', uid=new_task["uid"], + project=project.get("uid"), f=_task_fields(new_task)) def _calculate_next_due_date(self, base_due_str, frequency, ud_days): if base_due_str: @@ -901,6 +1228,11 @@ def move_task(self, old_main_project_name, task_name, new_main_project_name, tas if task_to_move: dest_project["tasks"].append(task_to_move) + # A move, not a delete-and-recreate: the task keeps its identity, + # so the other machine re-parents the very same object and its + # time entries travel with it untouched. + self._emit('task.move', uid=task_to_move.get("uid"), + project=dest_project.get("uid")) self._save_data() return True, _("Task '{task_name}' moved successfully.").format(task_name=task_name) return False, _("Task '{task_name}' not found in '{main_name}'.").format(task_name=task_name, main_name=old_main_project_name) @@ -940,16 +1272,73 @@ def promote_task_to_project(self, main_project_name, task_name_to_promote, task_ if task_index is None: return False, _("Task '{task_name}' not found in '{main_name}'.").format(task_name=task_name_to_promote, main_name=main_project_name) - # Remove task from old main project and get its data + # Remove task from old main project and get its data. The task itself + # does not survive this - its time entries are re-homed under the + # "General" task created below, but the task object is gone, so it + # needs a tombstone. The entries do not: they are being moved, and + # they keep the identity they already carry. task_data = source_project["tasks"].pop(task_index) time_entries = task_data.get("time_entries", []) - - # Create the new main project + # The deletion is recorded further down, after the entries have been + # reported as re-parented. Recording it here would put the operations + # on the wire in the order "delete this task" then "move its entries + # somewhere else" - and the receiving machine, applying them in that + # order, would destroy the entries along with the task before being + # told where they were going. + + # Create the new main project. + # The project and its task used to be stored bare - no id, no status, + # none of the other task fields - and were only completed by the + # migration on the next start. They are filled in here now, because a + # uid has to be assigned exactly once at creation; leaving the object + # half-built means the uid only appears later, on whichever machine + # happens to restart first. The time entries keep the uids they + # already carry: they are being moved, not recreated. + starts = [e.get("start_time") for e in time_entries if e.get("start_time")] + last_started = max(starts) if starts else None new_main_project = { + "uid": _new_uid(), "main_project_name": task_name_to_promote, - "tasks": [{"task_name": _("General"), "time_entries": time_entries}] + "tasks": [{ + "uid": _new_uid(), + "id": self.data["next_id"], + "task_name": _("General"), + "time_entries": time_entries, + "status": self.STATUS_OPEN, + "due_date": None, + "today": False, + "note": "", + "recurring": False, + "frequency": "daily", + "userdefined_days": 1, + "priority": 0, + "last_started": last_started + }], + "status": self.STATUS_OPEN, + "last_started": last_started } + self.data["next_id"] += 1 self.data["projects"].append(new_main_project) + + # Reported as its parts rather than as one "promote" verb, so the + # other machine needs no rule for a compound restructuring: a project + # appears, a task appears inside it, the entries are re-parented, and + # the old task is deleted (by _record_deletion above). Each part is an + # operation the applier already knows, and the entries keep the + # identities they had, so no tracked time is recreated or lost. + general = new_main_project["tasks"][0] + self._emit('project.create', uid=new_main_project["uid"], + f={"name": task_name_to_promote, "status": self.STATUS_OPEN, + "last_started": last_started}) + self._emit('task.create', uid=general["uid"], + project=new_main_project["uid"], f=_task_fields(general)) + for entry in time_entries: + if entry.get("uid"): + self._emit('entry.move', uid=entry["uid"], task=general["uid"]) + + # Only now: the entries have a new home on both machines. + self._record_deletion(task_data, "task") + self._save_data() return True, _("Task '{task_name}' was promoted to a new main project.").format(task_name=task_name_to_promote) @@ -995,18 +1384,63 @@ def demote_main_project(self, main_project_to_demote_name, new_parent_main_proje # Sort entries by start time to maintain chronological order all_time_entries.sort(key=lambda x: x['start_time']) - # 3. Create the new task + # 3. Create the new task. Stored complete rather than bare for the + # same reason as in promote_task_to_project() above. The moved + # time entries keep their existing uids. + starts = [e.get("start_time") for e in all_time_entries if e.get("start_time")] new_task = { + "uid": _new_uid(), + "id": self.data["next_id"], "task_name": main_project_to_demote_name, - "time_entries": all_time_entries + "time_entries": all_time_entries, + "status": self.STATUS_OPEN, + "due_date": None, + "today": False, + "note": "", + "recurring": False, + "frequency": "daily", + "userdefined_days": 1, + "priority": 0, + "last_started": max(starts) if starts else None } + self.data["next_id"] += 1 new_parent_project["tasks"].append(new_task) - # 4. Remove the old main project and save + # Told to the other machine as its parts, as in promote above: the + # consolidated task appears, every entry is re-parented onto it, and + # only then is the old project (with its tasks) deleted. That order + # matters - re-homing the entries before their old task disappears is + # what keeps tracked time from being caught by the deletion. + self._emit('task.create', uid=new_task["uid"], + project=new_parent_project.get("uid"), f=_task_fields(new_task)) + for entry in all_time_entries: + if entry.get("uid"): + self._emit('entry.move', uid=entry["uid"], task=new_task["uid"]) + + # 4. Remove the old main project and save. The project and every task + # it held are destroyed here - only their time entries live on, in + # the single consolidated task created above - so both levels are + # recorded, the entries are not. + self._record_project_deletion(project_to_demote) self.data["projects"].pop(project_to_demote_index) self._save_data() return True, _("Main project '{demoted_name}' was demoted to a sub-project under '{parent_name}'.").format(demoted_name=main_project_to_demote_name, parent_name=new_parent_main_project_name) + @staticmethod + def _sort_by_last_started(items): + """ + Orders a list of projects or tasks most-recently-started first, in place. + + Items that were never started (last_started is None) go to the end. + Python's sort is stable and stays stable with reverse=True, so among + those the original order survives - which for a never-started item is + the order it was created in, exactly where it sits today. + + :param items: The list of project or task dicts to reorder. + :type items: list[dict] + """ + items.sort(key=lambda item: item.get("last_started") or "", reverse=True) + def start_work(self, main_project_name, task_name=None, task_id=None): """ Starts a new time tracking session for a task by saving the start time. @@ -1020,33 +1454,12 @@ def start_work(self, main_project_name, task_name=None, task_id=None): :return: True if work was started successfully, otherwise False. :rtype: bool """ - main_project = None - main_project_index = -1 - task = None - task_index = -1 - - fallback_task = None - fallback_index = -1 - - # Find the main project and task along with their indices - for i, p in enumerate(self.data["projects"]): - if p["main_project_name"] == main_project_name: - main_project_index = i - main_project = p - for j, t in enumerate(p["tasks"]): - if task_id is not None and str(t.get("id")) == str(task_id): - task, task_index = t, j - break - if task_name and t["task_name"] == task_name: - if t.get("status") == self.STATUS_OPEN: - task, task_index = t, j - break - if fallback_task is None: - fallback_task, fallback_index = t, j - break - - if not task and fallback_task: - task, task_index = fallback_task, fallback_index + # This used to carry its own copy of the project/task lookup - along + # with its own copy of the bug where a name match could beat the id + # that was actually asked for. It now defers to _get_task, so the + # lookup rules live in exactly one place. + main_project = self._get_project(main_project_name) + task = self._get_task(main_project_name, task_name, task_id) if main_project else None if task and main_project: # Only stop the previous session once we know a new one can @@ -1054,21 +1467,39 @@ def start_work(self, main_project_name, task_name=None, task_id=None): # silently end whatever was running without replacing it. self.stop_work() - # Add the new time entry + # Add the new time entry. The uid is what lets a specific entry be + # referred to at all - "the last element of some array" stops + # meaning anything once two machines hold their own copy. + started_at = datetime.now().isoformat() new_entry = { - "start_time": datetime.now().isoformat() + "uid": _new_uid(), + "start_time": started_at } task["time_entries"].append(new_entry) - # Move the task to the top of the list - if task_index > 0: - moved_task = main_project["tasks"].pop(task_index) - main_project["tasks"].insert(0, moved_task) - - # Move the main project to the top of the list - if main_project_index > 0: - moved_main_project = self.data["projects"].pop(main_project_index) - self.data["projects"].insert(0, moved_main_project) + # Most-recently-used ordering used to be expressed by physically + # moving the task and its project to the front of their arrays, + # which made array position the only record of "what did I work on + # last". That is state, and state nothing can derive: two machines + # holding the same projects would have to agree on it, with no + # field to reconcile it from. It is carried by last_started now, + # and the arrays are merely kept in that order - so the ordering + # every caller sees is unchanged, but it is reproducible from the + # data rather than stored alongside it. + task["last_started"] = started_at + main_project["last_started"] = started_at + self._sort_by_last_started(main_project["tasks"]) + self._sort_by_last_started(self.data["projects"]) + + # last_started is sent explicitly rather than left for the other + # machine to derive from the entry. Deriving it would work only + # as long as every entry ever reaches the other side, and the + # ordering the user sees should not depend on that. + self._emit('entry.add', uid=new_entry["uid"], task=task.get("uid"), + start=started_at) + self._emit('task.set', uid=task.get("uid"), f={"last_started": started_at}) + self._emit('project.set', uid=main_project.get("uid"), + f={"last_started": started_at}) self._save_data() return True @@ -1164,7 +1595,20 @@ def stop_work(self): for project in reversed(self.data["projects"]): for task in reversed(project["tasks"]): if task["time_entries"] and "end_time" not in task["time_entries"][-1]: - task["time_entries"][-1]["end_time"] = datetime.now().isoformat() + entry = task["time_entries"][-1] + end_time = datetime.now().isoformat() + # An entry must never end before it began. Every duration + # in every report is these two subtracted from one another, + # so a negative one does not announce itself - it just + # quietly makes the numbers wrong. It can happen without + # anyone doing something odd: a clock corrected backwards, + # the switch off daylight saving, and later a session + # closed on the strength of another machine's clock. + start_time = entry.get("start_time") + if start_time and end_time < start_time: + end_time = start_time + entry["end_time"] = end_time + self._emit('entry.close', uid=entry.get("uid"), end=end_time) self._save_data() return True return False diff --git a/tt/filelock.py b/tt/filelock.py new file mode 100644 index 0000000..15eccb8 --- /dev/null +++ b/tt/filelock.py @@ -0,0 +1,88 @@ +""" +A cross-platform advisory file lock. + +This exists because several processes write TimeControl's data at the same +time - the Streamlit GUI plus whichever of the MCP, REST and SOAP servers are +running - and they all share one outgoing operation queue. Two of them +handing the same sequence number to two different operations would make the +server treat the second as a repeat and drop it, losing a change without a +word. + +Python has no portable file lock in its standard library, so this is a thin +shim over the two platform mechanisms. Deliberately thin: the alternative was +another dependency, and the surface needed here is one context manager. + +Advisory means it only works between cooperating processes. That is enough: +every writer goes through this module. +""" + +import os +import time +from contextlib import contextmanager + +if os.name == 'nt': + import msvcrt +else: + import fcntl + + +class LockTimeout(RuntimeError): + """Raised when the lock could not be taken within the deadline.""" + + +@contextmanager +def locked(path, timeout=5.0): + """ + Holds an exclusive lock on `path` for the duration of the block. + + Never blocks indefinitely. A caller that waits forever on a lock some + crashed process appears to hold would freeze the interface it runs + behind; failing lets the caller retry on the next cycle instead. + + :param path: Lock file. Created if absent; never deleted, because + removing it would let another process take a lock on a file + this one still holds open. + :param timeout: Seconds to keep trying before giving up. + :raises LockTimeout: if the lock could not be acquired in time. + """ + directory = os.path.dirname(path) + if directory: + os.makedirs(directory, exist_ok=True) + + handle = open(path, 'a+b') + try: + os.chmod(path, 0o600) + except OSError: + pass + + deadline = time.monotonic() + timeout + while True: + try: + if os.name == 'nt': + # Windows locks byte ranges rather than whole files, so one + # fixed byte stands in for the file. LK_NBLCK is the + # non-blocking form. + handle.seek(0) + msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1) + else: + fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + break + except OSError: + if time.monotonic() >= deadline: + handle.close() + raise LockTimeout("could not lock %s within %.1fs" % (path, timeout)) + time.sleep(0.02) + + try: + yield + finally: + try: + if os.name == 'nt': + handle.seek(0) + msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1) + else: + fcntl.flock(handle.fileno(), fcntl.LOCK_UN) + except OSError: + # Closing the handle releases it anyway, on both platforms. + pass + handle.close() diff --git a/tt/sync_apply.py b/tt/sync_apply.py new file mode 100644 index 0000000..a51f34b --- /dev/null +++ b/tt/sync_apply.py @@ -0,0 +1,533 @@ +""" +Applying operations that came from another machine. + +A deliberately pure module: it takes a document and a list of operations and +changes the document. No network, no files, no clock it did not receive. That +is what makes the awkward cases - a deletion racing an edit, time booked +against a task somebody else removed - testable exhaustively rather than by +hoping. + +THE RULES, IN ONE PLACE +----------------------- +*Order.* Operations are applied strictly in the server's sequence order. That +number, not any timestamp, decides who wins: the two machines' clocks are +allowed to disagree by minutes, but they cannot disagree about the order the +server put things in. + +*Same object, different fields.* Both survive. Operations carry only the +fields that actually changed, so a priority set here and a due date set there +compose instead of overwriting each other. + +*Same object, same field.* The later sequence number wins. + +*Deletion beats editing, and takes the tracked time with it.* A deleted +object stays deleted, and any later operation naming it is dropped - +including a time entry booked against it. Without the first part, a deletion +here plus any edit there would resurrect the object on every reconcile, for +ever. The second part is what makes the two machines agree: deleting a task +in this application has always discarded its hours, so a machine that +receives the deletion must discard them too. Re-homing them somewhere safe +was tried and rejected - it left the machine that did the deleting with +nothing and the machine that received it with the hours, permanently, and +divergence that reports nothing is worse than the loss it was avoiding. + +So: work booked on the other machine and not yet sent, against a task deleted +here, is gone. That is a deliberate choice, not an oversight. +""" + +# Fields an incoming operation is allowed to set. Anything else is ignored: +# the server stores operations without understanding them, so this is where a +# malformed or hostile field would otherwise reach the document. +TASK_FIELDS = frozenset(( + "task_name", "status", "due_date", "today", "note", + "recurring", "frequency", "userdefined_days", "priority", "last_started", +)) +PROJECT_FIELDS = frozenset(("name", "status", "last_started")) +ENTRY_FIELDS = frozenset(("start_time", "end_time")) + + +class Report: + """What happened, for the caller to log or show.""" + + def __init__(self): + self.applied = 0 + self.ignored = 0 + # Time entries discarded because the task they belong to had been + # deleted. Counted separately from the rest of `ignored` because this + # is the one kind of dropped operation that costs the user something. + self.discarded_time = 0 + # Sessions this machine had left running that were closed because + # work had since begun elsewhere: (entry_uid, end_time) pairs. The + # caller is expected to report these back, or only this machine will + # know they ended. + self.auto_closed = [] + self.highest_seq = 0 + + def __repr__(self): + return ("" + % (self.applied, self.ignored, self.discarded_time, + len(self.auto_closed), self.highest_seq)) + + +class _Index: + """uid -> object lookups, kept current as operations are applied.""" + + def __init__(self, document): + self.document = document + self.projects = {} + self.tasks = {} + self.task_parent = {} + self.entries = {} + self.entry_parent = {} + for project in document.get("projects", []): + if project.get("uid"): + self.projects[project["uid"]] = project + for task in project.get("tasks", []): + if task.get("uid"): + self.tasks[task["uid"]] = task + self.task_parent[task["uid"]] = project + for entry in task.get("time_entries", []): + if entry.get("uid"): + self.entries[entry["uid"]] = entry + self.entry_parent[entry["uid"]] = task + + def add_project(self, project): + self.document.setdefault("projects", []).append(project) + self.projects[project["uid"]] = project + + def add_task(self, task, project): + project.setdefault("tasks", []).append(task) + self.tasks[task["uid"]] = task + self.task_parent[task["uid"]] = project + + def add_entry(self, entry, task): + task.setdefault("time_entries", []).append(entry) + self.entries[entry["uid"]] = entry + self.entry_parent[entry["uid"]] = task + + def move_task(self, uid, project): + task = self.tasks[uid] + old = self.task_parent[uid] + if old is project: + return + old["tasks"] = [t for t in old.get("tasks", []) if t.get("uid") != uid] + project.setdefault("tasks", []).append(task) + self.task_parent[uid] = project + + def move_entry(self, uid, task): + entry = self.entries[uid] + old = self.entry_parent[uid] + if old is task: + return + old["time_entries"] = [e for e in old.get("time_entries", []) if e.get("uid") != uid] + task.setdefault("time_entries", []).append(entry) + self.entry_parent[uid] = task + + def drop_project(self, uid): + project = self.projects.pop(uid, None) + if project is None: + return [] + self.document["projects"] = [p for p in self.document.get("projects", []) + if p.get("uid") != uid] + gone = [] + for task in project.get("tasks", []): + gone.extend(self.drop_task_bookkeeping(task)) + return gone + + def drop_task_bookkeeping(self, task): + uid = task.get("uid") + self.tasks.pop(uid, None) + self.task_parent.pop(uid, None) + for entry in task.get("time_entries", []): + self.entries.pop(entry.get("uid"), None) + self.entry_parent.pop(entry.get("uid"), None) + return [uid] if uid else [] + + def drop_task(self, uid): + task = self.tasks.get(uid) + if task is None: + return [] + parent = self.task_parent.get(uid) + if parent is not None: + parent["tasks"] = [t for t in parent.get("tasks", []) if t.get("uid") != uid] + return self.drop_task_bookkeeping(task) + + def drop_entry(self, uid): + entry = self.entries.pop(uid, None) + if entry is None: + return + parent = self.entry_parent.pop(uid, None) + if parent is not None: + parent["time_entries"] = [e for e in parent.get("time_entries", []) + if e.get("uid") != uid] + + +def _next_local_id(document): + """ + Hands out the next integer id for a task arriving from elsewhere. + + These ids never travel. They are a local convenience - short handles for + the interface and the MCP/REST/SOAP calls - and the same task is allowed + to carry different ones on different machines. + """ + nid = int(document.get("next_id", 1)) + document["next_id"] = nid + 1 + return nid + + +def _tombstones(document): + return {t.get("uid") for t in document.get("_deleted", []) if t.get("uid")} + + +def _add_tombstone(document, uid, kind, when): + for existing in document.setdefault("_deleted", []): + if existing.get("uid") == uid: + return + document["_deleted"].append({"uid": uid, "kind": kind, "at": when}) + + +def apply_ops(document, ops, on_conflict=None): + """ + Applies operations from the server to a document, in place. + + :param document: A schema-2 document. Modified. + :param ops: Operations as the server returned them, each with 's' (the + sequence number that decides order) and 'op'. + :param on_conflict: Optional callable, invoked as (kind, detail) whenever + something had to be decided rather than simply done: + 'discarded_time' when a time entry went with a deleted + task, 'auto_closed' when a session left running here + was ended because work began elsewhere. + :return: A Report. + """ + report = Report() + index = _Index(document) + dead = _tombstones(document) + + for op in sorted(ops, key=lambda o: int(o.get("s", 0))): + seq = int(op.get("s", 0)) + report.highest_seq = max(report.highest_seq, seq) + kind = op.get("op") + uid = op.get("uid") + when = op.get("ts") or op.get("start") or op.get("end") or "" + + # An operation naming something already deleted is dropped. The one + # exception is below: it is about time entries, and losing tracked + # time silently is the one outcome worth complicating this for. + if uid in dead and kind not in ("entry.add", "entry.move"): + report.ignored += 1 + continue + + handled = True + + if kind == "project.create": + if uid not in index.projects: + fields = op.get("f") or {} + index.add_project({ + "uid": uid, + "main_project_name": fields.get("name", ""), + "tasks": [], + "status": fields.get("status", "open"), + "last_started": fields.get("last_started"), + }) + + elif kind == "project.set": + project = index.projects.get(uid) + if project is None: + handled = False + else: + for key, value in (op.get("f") or {}).items(): + if key not in PROJECT_FIELDS: + continue + project["main_project_name" if key == "name" else key] = value + + elif kind == "project.delete": + for task_uid in index.drop_project(uid): + _add_tombstone(document, task_uid, "task", when) + dead.add(task_uid) + _add_tombstone(document, uid, "project", when) + dead.add(uid) + + elif kind == "task.create": + if uid not in index.tasks: + project = index.projects.get(op.get("project")) + if project is None: + handled = False + else: + fields = {k: v for k, v in (op.get("f") or {}).items() if k in TASK_FIELDS} + task = { + "uid": uid, + "id": _next_local_id(document), + "task_name": "", + "time_entries": [], + "status": "open", + "due_date": None, + "today": False, + "note": "", + "recurring": False, + "frequency": "daily", + "userdefined_days": 1, + "priority": 0, + "last_started": None, + } + task.update(fields) + index.add_task(task, project) + + elif kind == "task.set": + task = index.tasks.get(uid) + if task is None: + handled = False + else: + for key, value in (op.get("f") or {}).items(): + if key in TASK_FIELDS: + task[key] = value + + elif kind == "task.move": + task = index.tasks.get(uid) + project = index.projects.get(op.get("project")) + if task is None or project is None: + handled = False + else: + index.move_task(uid, project) + + elif kind == "task.delete": + index.drop_task(uid) + _add_tombstone(document, uid, "task", when) + dead.add(uid) + + elif kind in ("entry.add", "entry.move"): + target_uid = op.get("task") + task = index.tasks.get(target_uid) + if task is None or target_uid in dead: + # The task this time belongs to is gone here - deleted on this + # machine, or never created because its project was. The entry + # goes with it, which is what deleting a task has always done + # locally, and is the only answer that leaves both machines + # holding the same document: the one that did the deleting + # discarded these hours the moment the user asked it to, and + # it has no way to get them back. + index.drop_entry(uid) + report.discarded_time += 1 + if on_conflict: + on_conflict('discarded_time', + {'entry': uid, 'task': target_uid}) + handled = False + elif kind == "entry.add": + if uid in index.entries: + index.move_entry(uid, task) + else: + entry = {"uid": uid, "start_time": op.get("start")} + if op.get("end"): + entry["end_time"] = op["end"] + index.add_entry(entry, task) + else: + if uid in index.entries: + index.move_entry(uid, task) + else: + handled = False + + elif kind == "entry.close": + entry = index.entries.get(uid) + if entry is None: + handled = False + else: + end = op.get("end") + start = entry.get("start_time") + # Never before it began: durations are these two subtracted, + # and a negative one does not announce itself, it just makes + # the numbers wrong. Clocks on the two machines are allowed + # to disagree, so this really can happen. + if end and start and end < start: + end = start + if end: + entry["end_time"] = end + + elif kind == "entry.set": + entry = index.entries.get(uid) + if entry is None: + handled = False + else: + for key, value in (op.get("f") or {}).items(): + if key in ENTRY_FIELDS: + entry[key] = value + start, end = entry.get("start_time"), entry.get("end_time") + if start and end and end < start: + entry["end_time"] = start + + elif kind == "entry.delete": + index.drop_entry(uid) + + else: + handled = False + + if handled: + report.applied += 1 + else: + report.ignored += 1 + + _settle(document, report, on_conflict) + return report + + +def reconcile(document, incoming, local=None, on_conflict=None): + """ + One merge: what came from elsewhere, then this machine's own unsent work. + + WHY THE SECOND PASS EXISTS + -------------------------- + Local changes are written into the document the moment they are made - + the app cannot wait for a server to agree before showing the user their + own edit. But the server decides the order, and it puts everything this + machine sends AFTER everything already in the log. So a value set here + and not yet acknowledged has to end up on top of an incoming change to + the same field, even though it was applied to the document first. + + Without the second pass this machine keeps the incoming value while the + other machine, replaying both in the server's order, keeps ours - and the + two never notice they disagree. Two identical files that quietly stopped + being identical is the worst outcome this design has to avoid, worse than + an error, because nothing reports it. + + Replaying is safe because every operation here is written to be repeatable + - a create for something that exists does nothing, a set writes the same + value again - so an operation the document already reflects costs nothing. + + :param document: The document to bring up to date. Modified. + :param incoming: Operations from the server, each carrying its sequence. + :param local: This machine's queued operations, carrying the 'lc' they + were queued under. They are ordered by that alone: the + server appends a batch in the order it was sent, so 'lc' + order is already the order the server will give them. + :return: A Report covering both passes. + """ + report = apply_ops(document, incoming, on_conflict=on_conflict) + if not local: + return report + + floor = report.highest_seq + replay = [] + for position, op in enumerate(sorted(local, key=lambda o: int(o.get('lc', 0))), 1): + op = dict(op) + op['s'] = floor + position + replay.append(op) + + second = apply_ops(document, replay, on_conflict=on_conflict) + report.applied += second.applied + report.ignored += second.ignored + report.discarded_time += second.discarded_time + report.auto_closed.extend(second.auto_closed) + report.highest_seq = max(report.highest_seq, second.highest_seq) + return report + + +def seed_operations(document): + """ + Describes an existing document as the operations that would build it. + + Used once, by whichever machine reaches an empty server first. Everything + after that is incremental; this is the only time the whole document is + sent, and it is sent as operations rather than as a file so the server + never has to understand the format. + + The order matters and is the same order the app itself would have + produced: a project before its tasks, a task before its time. + """ + ops = [] + for project in document.get("projects", []): + if not project.get("uid"): + continue + ops.append({ + 'op': 'project.create', + 'uid': project["uid"], + 'f': {'name': project.get("main_project_name", ""), + 'status': project.get("status", "open"), + 'last_started': project.get("last_started")}, + }) + for task in project.get("tasks", []): + if not task.get("uid"): + continue + ops.append({ + 'op': 'task.create', + 'uid': task["uid"], + 'project': project["uid"], + 'f': {k: task.get(k) for k in sorted(TASK_FIELDS) if k in task}, + }) + for entry in task.get("time_entries", []): + if not entry.get("uid") or not entry.get("start_time"): + continue + ops.append({'op': 'entry.add', 'uid': entry["uid"], + 'task': task["uid"], 'start': entry["start_time"]}) + if entry.get("end_time"): + ops.append({'op': 'entry.close', 'uid': entry["uid"], + 'end': entry["end_time"]}) + + # Deletions travel too, or a machine that seeds from a document still + # carrying tombstones would hand the others no way to know those objects + # are meant to stay gone. + for stone in document.get("_deleted", []): + kind = stone.get("kind") + if kind in ("project", "task", "entry") and stone.get("uid"): + ops.append({'op': kind + '.delete', 'uid': stone["uid"], + 'ts': stone.get("at")}) + return ops + + +def _settle(document, report, on_conflict=None): + """ + Puts the document back into a shape the rest of the app relies on. + + Two invariants that applying operations can break, and that nothing else + would notice until much later: + + The id counter must stay above every id in use, or the next task created + here reuses one - and nothing anywhere checks for that. + + At most one time entry may be open. A running session is recognised as + "the entry with no end_time"; two of them and the app stops the wrong + one, leaving the other running for ever. Whichever started earlier is + closed at the later one's start, so no stretch of time is counted twice. + This is also what the user asked for in so many words: starting a task on + the second machine should end the one still running on the first. + + And an open entry must be the LAST one in its task. Three places in the + application - stopping work, showing what is running, and the per-task + report - all recognise a running session as the final element of the + list rather than by searching for one. An entry finished on the other + machine can arrive after a session started here and is simply appended, + which pushes the open one out of last place: the session then cannot be + stopped, does not appear as running, and its hours are never counted. + """ + highest = 0 + open_entries = [] + for project in document.get("projects", []): + for task in project.get("tasks", []): + try: + highest = max(highest, int(task.get("id"))) + except (TypeError, ValueError): + pass + for entry in task.get("time_entries", []): + if "end_time" not in entry and entry.get("start_time"): + open_entries.append(entry) + + if int(document.get("next_id", 1)) <= highest: + document["next_id"] = highest + 1 + + if len(open_entries) > 1: + open_entries.sort(key=lambda e: e.get("start_time") or "") + for earlier, later in zip(open_entries, open_entries[1:]): + end = later.get("start_time") + earlier["end_time"] = end + report.auto_closed.append((earlier.get("uid"), end)) + if on_conflict: + on_conflict('auto_closed', {'entry': earlier.get("uid"), 'end': end}) + + for project in document.get("projects", []): + for task in project.get("tasks", []): + entries = task.get("time_entries") + if not entries or "end_time" not in entries[-1]: + continue + for position, entry in enumerate(entries): + if "end_time" not in entry: + entries.append(entries.pop(position)) + break diff --git a/tt/sync_client.py b/tt/sync_client.py new file mode 100644 index 0000000..e9404ab --- /dev/null +++ b/tt/sync_client.py @@ -0,0 +1,360 @@ +""" +Talking to the sync server: where the credential lives, and signing in. + +This module deliberately holds no synchronisation logic yet - only the +connection and the credential. Emitting operations and applying incoming ones +come later and will use the session established here. + +WHY THE CREDENTIAL IS NOT IN config.json +---------------------------------------- +Two reasons, both specific to this project rather than general principle. + +config.json is tracked in a public git repository, so a token placed there is +one routine `git add -A` away from being published permanently, in every +clone and fork. + +More importantly, config.json is a file people copy. Setting up a second +machine by copying it across is the obvious thing to do, and it is even the +behaviour we want for the server address. But the token carries a device +identity, and the server keeps its duplicate-suppression counter per device +and replaces "this device's" token on every sign-in. Two machines sharing one +identity would revoke each other's tokens and swallow each other's retries. + +So the split is: the server address, the on/off switch and the interval are +ordinary settings and stay in config.json - copying those to a second machine +is helpful. The token and the device identity live here instead, per machine, +outside the project directory. +""" + +import json +import os +import platform +import secrets + +import requests + +try: + # The same helper update.py uses, for the same reason: requests' timeout + # begins after the address has been resolved, so it does not bound the + # DNS lookup - the hang that issue #539 was about. Guarded because tt/ + # modules are also imported by the servers, which may be started from a + # directory where the launcher script is not importable. + from update import _call_with_deadline +except ImportError: + _call_with_deadline = None + +# Every call gets one. update.py established the idiom, and a sync that can +# hang indefinitely would freeze the interface it runs behind. +TIMEOUT = 20 + +# The ceiling on a whole call, DNS included. Set above the request's own +# worst case - `timeout` applies separately to connect and read - so it only +# ever fires for a lookup that is genuinely stuck. +DEADLINE = 2 * TIMEOUT + 5 + + +def config_dir(): + """ + Returns the per-user directory holding this machine's sync credential. + + Resolved from the operating system, never relative to the working + directory: a frozen build chdir's to the directory holding the .exe, and + that is exactly where this must NOT end up. Under Programme/Program Files + it would not be writable at all; anywhere else it would be shared by every + Windows account on the machine, and a portable install on a USB stick + would carry the token around with it. + + :return: Absolute path to the directory (not created by this call). + :rtype: str + """ + if os.name == 'nt': + base = os.environ.get('APPDATA') or os.path.expanduser('~') + else: + base = os.environ.get('XDG_CONFIG_HOME') or os.path.join(os.path.expanduser('~'), '.config') + return os.path.join(base, 'TimeControl') + + +def _credentials_path(): + return os.path.join(config_dir(), 'sync_credentials.json') + + +def _device_path(): + return os.path.join(config_dir(), 'device.json') + + +def _write_private(path, data): + """ + Writes JSON so that, as far as the platform allows, only its owner can + read it. + + On POSIX the mode does the work. On Windows os.chmod only toggles the + read-only attribute - it cannot restrict *who* may read - so there the + protection comes from the location instead: %APPDATA% sits inside the + user profile, which Windows already keeps other standard accounts out of. + That is an assurance from the operating system rather than from us, which + is part of why the token expires on its own after ninety days. + """ + os.makedirs(config_dir(), exist_ok=True) + tmp = path + '.tmp' + with open(tmp, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2) + try: + os.chmod(tmp, 0o600) + except OSError: + pass + os.replace(tmp, path) + try: + os.chmod(path, 0o600) + except OSError: + pass + + +def _read_json(path): + try: + with open(path, 'r', encoding='utf-8') as f: + data = json.load(f) + return data if isinstance(data, dict) else None + except (OSError, json.JSONDecodeError): + return None + + +def device_identity(): + """ + Returns this machine's identity, creating it on first use. + + Kept apart from the credential on purpose. Signing out, or a token being + rejected, deletes the credential - but the identity has to survive that, + or every sign-in would look like a brand new machine to the server, + accumulate a fresh device entry each time, and defeat the very + idempotency that makes a repeated sign-in harmless. + + :return: {'device_uid': 16 hex chars, 'device_name': str} + :rtype: dict + """ + existing = _read_json(_device_path()) + if existing and existing.get('device_uid'): + return existing + identity = { + 'device_uid': secrets.token_hex(8), + 'device_name': (platform.node() or 'unnamed')[:60], + } + _write_private(_device_path(), identity) + return identity + + +def load_credentials(): + """Returns the stored credential, or None when not signed in.""" + data = _read_json(_credentials_path()) + if data and data.get('token') and data.get('base_url'): + return data + return None + + +def clear_credentials(): + """Forgets the token. The device identity is deliberately kept.""" + try: + os.remove(_credentials_path()) + except OSError: + pass + + +def _endpoint(base_url): + """ + Normalises whatever the user typed into the API entry point. + + People paste the address of the directory, with or without a trailing + slash, and sometimes the entry point itself. All three should work rather + than producing an unexplained 404. + """ + url = (base_url or '').strip().rstrip('/') + if url.endswith('index.php'): + return url + return url + '/index.php' + + +def _post(base_url, action, payload=None, token=None, params=None): + """ + Performs one request and converts every failure into a stable code. + + The caller has to be able to tell "wrong password" from "no network" - + they call for entirely different responses from the user - so transport + failures get their own codes rather than being folded into a generic + error. + + :param payload: Sent as a JSON body via POST. None makes it a GET. + :param params: Extra query parameters beside the action, for the + endpoints that read them from the query string. + """ + headers = {'Content-Type': 'application/json'} + if token: + headers['X-TC-Token'] = token + url = _endpoint(base_url) + query = {'a': action} + query.update(params or {}) + + def _send(): + if payload is None: + return requests.get(url, params=query, headers=headers, timeout=TIMEOUT) + return requests.post(url, params=query, headers=headers, + data=json.dumps(payload), timeout=TIMEOUT) + + try: + # requests' own timeout does not cover the DNS lookup that runs + # first, and with no route to the network that lookup can hang far + # longer than any of these numbers. update.py hit exactly this and + # solved it with a deadline around the whole call; a sync that hangs + # would wedge the worker permanently, so it needs the same guard. + if _call_with_deadline is not None: + response = _call_with_deadline(_send, DEADLINE) + else: + response = _send() + except TimeoutError: + return {'ok': False, 'error': 'timeout'} + except requests.exceptions.SSLError: + return {'ok': False, 'error': 'tls_failed'} + except requests.exceptions.Timeout: + return {'ok': False, 'error': 'timeout'} + except requests.exceptions.RequestException: + return {'ok': False, 'error': 'unreachable'} + + try: + return response.json() + except ValueError: + # An HTML error page, or another application answering on this path. + return {'ok': False, 'error': 'bad_response', 'status': response.status_code} + + +def login(base_url, username, password): + """ + Signs in and stores the token for this machine. + + :return: The server's reply, with 'ok' telling the caller what happened. + :rtype: dict + """ + if not (base_url or '').strip(): + return {'ok': False, 'error': 'no_server'} + if not username or not password: + return {'ok': False, 'error': 'missing_credentials'} + if not _endpoint(base_url).lower().startswith('https://'): + # The server refuses plain HTTP anyway; failing here saves sending + # the password in the clear to find that out. + return {'ok': False, 'error': 'https_required'} + + identity = device_identity() + result = _post(base_url, 'login', { + 'username': username, + 'password': password, + 'device_uid': identity['device_uid'], + 'device_name': identity['device_name'], + }) + if result.get('ok'): + if not result.get('token'): + # A success without a token is not something this server does, + # so the address is answering for something else. Saying so + # beats a KeyError from deep inside the sign-in button. + return {'ok': False, 'error': 'bad_response'} + _write_private(_credentials_path(), { + 'version': 1, + 'base_url': _endpoint(base_url), + 'username': username, + 'token': result['token'], + 'expires_at': result.get('expires_at'), + }) + return result + + +def logout(): + """ + Revokes this machine's token, server-side where possible. + + The local credential is dropped either way: a user who asked to sign out + should end up signed out even when the server cannot be reached, and the + token expires on its own regardless. + """ + creds = load_credentials() + if not creds: + return {'ok': True, 'revoked': False} + result = _post(creds['base_url'], 'logout', token=creds['token']) + clear_credentials() + return result + + +def status(): + """ + Checks the stored credential against the server. + + :return: dict with 'state' as one of: + 'not_configured' - no credential stored + 'ok' - the token works + 'rejected' - the server does not accept it any more + 'unreachable' - could not ask (network, TLS, wrong address) + """ + creds = load_credentials() + if not creds: + return {'state': 'not_configured'} + + result = _post(creds['base_url'], 'ping', token=creds['token']) + if result.get('ok'): + return { + 'state': 'ok', + 'username': creds.get('username'), + 'base_url': creds.get('base_url'), + 'expires_at': result.get('expires_at') or creds.get('expires_at'), + 'device_uid': result.get('device_uid'), + } + if result.get('error') == 'invalid_token': + # Expired, revoked from another machine, or the account was switched + # off. All three mean the same thing to the user: sign in again. + return {'state': 'rejected', 'username': creds.get('username')} + return {'state': 'unreachable', 'error': result.get('error', 'unreachable')} + + +# --------------------------------------------------------------------------- +# The log itself. These three speak for the stored credential, so the caller +# never handles the token - and cannot accidentally send it somewhere else. +# --------------------------------------------------------------------------- + +# The server's own ceiling (TC_PUSH_MAX_OPS / TC_PULL_MAX_OPS). Sending more +# has the whole batch rejected, so the caller must send it in pieces. +MAX_OPS_PER_CALL = 500 + + +def _authenticated(action, payload=None, params=None): + creds = load_credentials() + if not creds: + return {'ok': False, 'error': 'not_signed_in'} + return _post(creds['base_url'], action, payload, token=creds['token'], params=params) + + +def head(): + """The cheap poll: how far the log has got, without transferring it.""" + return _authenticated('head') + + +def push(base_seq, ops): + """ + Sends this machine's operations and reads back what it has not seen. + + One round trip, because submitting work and learning what happened + elsewhere are the same conversation. + + :param base_seq: The last sequence number already applied here. + :param ops: Queued operations. May be empty - that makes this a + plain catch-up, which is how a machine with nothing to + contribute stays up to date. + :return: On success 'head', 'assigned' ([lc, seq] pairs), 'dups' (lc + values the server had already recorded), 'ops' and 'more'. + The reply never contains this machine's own operations. + """ + return _authenticated('push', {'base_seq': int(base_seq), 'ops': list(ops)}) + + +def pull(since, limit=MAX_OPS_PER_CALL): + """ + Reads the log from a point, including this machine's own operations. + + That last part is the difference from push, and the reason this exists: + after a lost response, or on a machine restored from a backup, the only + way to learn where one's own operations sit in the order is to be told. + """ + return _authenticated('pull', params={'since': int(since), 'limit': int(limit)}) diff --git a/tt/sync_engine.py b/tt/sync_engine.py new file mode 100644 index 0000000..45a599f --- /dev/null +++ b/tt/sync_engine.py @@ -0,0 +1,735 @@ +""" +Keeping the machines in step, without anyone waiting for it. + +WHAT RUNS WHERE, AND WHY IT IS SPLIT +------------------------------------ +The work divides into a slow half and a fast half, and they must not happen +in the same place. + +*The slow half is the network.* It runs in one background thread per process +and touches nothing but the outgoing queue and its own state files. It never +opens data.json and never calls into Streamlit. What it fetches is written to +an inbox on disk and left there. + +*The fast half is applying what arrived.* It runs on the thread that draws +the interface, at the top of a redraw, on the document that thread already +holds. That is the whole reason for the split: the interface keeps its +document in memory between redraws, so a background thread writing the file +would be overwritten by the next thing the user did - a lost update that +reports nothing. Applying on the drawing thread, into the document it is +already holding, makes that impossible rather than unlikely. + +The inbox is durable, so a machine switched off between the two halves loses +nothing: the operations are on disk and are applied on the next start. + +WHY NOT SIMPLY CALL IT WHEN THE VIEW CHANGES +-------------------------------------------- +That is how the update check works, and for a once-per-view version check a +pause is tolerable. Here it is not: a sync runs every few minutes, and a +server that has gone away would stall every navigation for the length of the +timeout. Hence a thread, and hence nothing in the interface ever waiting on +it. + +ONE CYCLE AT A TIME, ACROSS PROCESSES +------------------------------------- +The GUI, the MCP server and the REST server are separate processes sharing +one queue. Two of them pushing at once would interleave their batches, so a +cycle holds a lock; whoever cannot take it skips that round rather than +waiting. +""" + +import contextlib +import json +import os +import threading +import time + +from tt import sync_client +from tt.filelock import locked, LockTimeout +from tt.sync_apply import reconcile, seed_operations +from tt.sync_outbox import Outbox + +# How long between cycles when everything is working. The user asked for +# "several minutes": long enough that this is invisible, short enough that +# moving between machines over a coffee break does not need a nudge. +DEFAULT_INTERVAL_MINUTES = 5 + +# After a failure, back off rather than hammering a server that is down or +# a connection that is not there. Doubles each time up to the ceiling. +BACKOFF_START_SECONDS = 60 +BACKOFF_MAX_SECONDS = 30 * 60 + +# How often the worker wakes to see whether anything is due. Short enough to +# react to a nudge promptly, long enough to cost nothing. +TICK_SECONDS = 2.0 + +# Failures that will not fix themselves by trying again. Retrying an address +# that is not a sync server, or a token the server has revoked, for ever is +# just noise - the user has to do something. +TERMINAL_ERRORS = frozenset(( + 'not_signed_in', 'invalid_token', 'https_required', + 'not_installed', 'bad_response', 'tls_failed', +)) + + +def state_path(): + return os.path.join(sync_client.config_dir(), 'sync_state.json') + + +def inbox_path(): + return os.path.join(sync_client.config_dir(), 'sync_inbox.jsonl') + + +def _cycle_lock_path(): + return os.path.join(sync_client.config_dir(), 'sync_cycle.lock') + + +def _state_lock_path(): + return os.path.join(sync_client.config_dir(), 'sync_state.lock') + + +def _seed_lock_path(): + return os.path.join(sync_client.config_dir(), 'sync_seed.lock') + + +# --------------------------------------------------------------------------- +# State: what this machine knows about its own position in the log. +# --------------------------------------------------------------------------- + +_DEFAULT_STATE = { + 'base_seq': 0, # the last sequence number applied to data.json + 'seeded': False, # whether this machine has offered its document + 'last_ok': None, # epoch seconds of the last successful cycle + 'last_error': None, # the code from the last failure, or None + 'failures': 0, # consecutive failures, for the backoff + 'next_attempt': 0, # epoch seconds before which not to try again + 'server_head': 0, # how far the log had got when last asked +} + + +def read_state(): + """Returns the stored state, filled out with defaults.""" + state = dict(_DEFAULT_STATE) + try: + with open(state_path(), 'r', encoding='utf-8') as f: + stored = json.load(f) + if isinstance(stored, dict): + state.update({k: v for k, v in stored.items() if k in _DEFAULT_STATE}) + except (OSError, ValueError): + pass + return state + + +def write_state(changes, required=False): + """ + Merges changes into the stored state. + + Read-modify-write under a lock, because the worker and the drawing thread + both update it and they change different fields: the worker owns the + outcome of a cycle, the drawing thread owns how far the document has been + brought. A blind overwrite would lose one or the other. + + :param required: Raise instead of shrugging when the write fails. Most of + what is kept here is a convenience - when the last cycle + ran, what went wrong - and losing it costs nothing. The + cursor is not: consuming the fetched operations while + failing to record how far they reached would leave the + cursor behind what the document already holds, and the + same operations would be fetched and replayed over newer + work. The caller passes this so that failure aborts the + whole step and the operations stay where they are. + """ + os.makedirs(sync_client.config_dir(), exist_ok=True) + try: + with locked(_state_lock_path()): + state = read_state() + state.update(changes) + tmp = state_path() + '.tmp' + with open(tmp, 'w', encoding='utf-8') as f: + json.dump(state, f, indent=2) + try: + os.chmod(tmp, 0o600) + except OSError: + pass + os.replace(tmp, state_path()) + return state + except (OSError, LockTimeout): + if required: + raise + # State is a convenience, not the truth. The queue and the log are + # the truth, and both survive this going wrong. + return read_state() + + +# --------------------------------------------------------------------------- +# The inbox: fetched operations waiting to be applied to the document. +# +# The worker appends to it and the drawing thread consumes it, so both ends +# go through the same lock. Reading and then removing what was read has to be +# one indivisible step: a record the worker adds in between would be deleted +# without ever being applied, while the cursor moved past it. Nothing would +# report that, and the two machines would simply stop agreeing. +# --------------------------------------------------------------------------- + +def _inbox_lock_path(): + return os.path.join(sync_client.config_dir(), 'sync_inbox.lock') + + +def _read_inbox_unlocked(): + out = [] + try: + with open(inbox_path(), 'r', encoding='utf-8') as f: + for line in f: + line = line.strip() + if not line: + continue + try: + record = json.loads(line) + except ValueError: + continue + if isinstance(record, dict) and 'ops' in record: + out.append(record) + except OSError: + return [] + return out + + +def read_inbox(): + """Returns the pending records, oldest first. Never raises.""" + return _read_inbox_unlocked() + + +def _append_inbox(record): + os.makedirs(sync_client.config_dir(), exist_ok=True) + with locked(_inbox_lock_path()): + with open(inbox_path(), 'a', encoding='utf-8') as f: + f.write(json.dumps(record, ensure_ascii=False) + '\n') + try: + os.chmod(inbox_path(), 0o600) + except OSError: + pass + + +def clear_inbox(): + """Discards everything waiting. Used when a machine is re-seeded.""" + with locked(_inbox_lock_path()): + _remove_inbox() + + +def _remove_inbox(): + try: + os.remove(inbox_path()) + except OSError: + pass + + +@contextlib.contextmanager +def taken_inbox(): + """ + Hands over the pending records and removes exactly those. + + The lock is held for the whole block, so a record the worker files while + the document is being brought up to date survives to the next round + rather than being swept away with the ones that were applied. The body is + local work only - never a network call - so the wait is imperceptible. + + If the body raises, nothing is removed and the records are applied again + next time. Applying twice is harmless; losing them is not. + """ + with locked(_inbox_lock_path()): + records = _read_inbox_unlocked() + yield records + if not records: + return + remaining = _read_inbox_unlocked()[len(records):] + if remaining: + tmp = inbox_path() + '.tmp' + with open(tmp, 'w', encoding='utf-8') as f: + for record in remaining: + f.write(json.dumps(record, ensure_ascii=False) + '\n') + try: + os.chmod(tmp, 0o600) + except OSError: + pass + os.replace(tmp, inbox_path()) + else: + _remove_inbox() + + +# --------------------------------------------------------------------------- +# The slow half: one network cycle. +# --------------------------------------------------------------------------- + +def run_cycle(outbox=None): + """ + Sends what is queued, fetches what is not, and files it for applying. + + Deliberately never touches data.json. Everything it learns goes into the + inbox; the drawing thread puts it into the document. + + :return: A dict with 'ok', and on failure an 'error' code. + """ + outbox = outbox or Outbox() + + try: + with locked(_cycle_lock_path(), timeout=0.1): + return _run_cycle_locked(outbox) + except LockTimeout: + # Another process is mid-cycle. Its work is our work. + return {'ok': True, 'skipped': 'busy'} + except OSError as exc: + return {'ok': False, 'error': 'local_io', 'detail': str(exc)} + + +def _since(state): + """ + Where to ask from. + + Not simply the applied position: operations already fetched but not yet + applied are sitting in the inbox, and asking for them again would fetch + the same batch every cycle until the interface next redraws. + """ + highest = int(state.get('base_seq', 0)) + for record in read_inbox(): + highest = max(highest, int(record.get('base_seq', 0))) + return highest + + +# A stop on the catch-up loop. Only reachable if the server keeps reporting +# more than it sends; without it a bad answer would spin here for ever. +MAX_PAGES_PER_CYCLE = 40 + + +def _run_cycle_locked(outbox): + state = read_state() + since = _since(state) + + sending = outbox.pending() + batch = sending[:sync_client.MAX_OPS_PER_CALL] + + result = sync_client.push(since, [_wire(op) for op in batch]) + if not result.get('ok'): + return _record_failure(result.get('error') or 'unreachable') + + dups = [int(x) for x in (result.get('dups') or [])] + seq_of = {int(lc): int(seq) for lc, seq in (result.get('assigned') or [])} + head = int(result.get('head', 0)) + truncated = bool(result.get('more')) + failure = None + + if dups or truncated: + # Two situations, one answer. + # + # Duplicates mean an earlier push landed but its answer never + # arrived, so those operations are in the log at positions we were + # never told. Truncation means the reply left some out, and this + # machine's own operations sit above the cut - filing them now would + # move the cursor past everything in the gap, and the log is only + # ever read forwards, so that gap would never be offered again. + # + # In both cases the push reply is not a usable picture of the order, + # and pull is: unlike push it includes this machine's own work, so + # what comes back is the whole sequence, as the server has it. + incoming = [] + cursor = since + for _page in range(MAX_PAGES_PER_CYCLE): + fetched = sync_client.pull(cursor) + if not fetched.get('ok'): + # Keep the contiguous run we did get; the rest comes next + # time. The cursor below advances only over what is in hand. + failure = fetched.get('error') or 'unreachable' + break + page = fetched.get('ops') or [] + incoming.extend(page) + head = max(head, int(fetched.get('head', 0))) + cursor = max([int(op.get('s', 0)) for op in page] or [cursor]) + if not fetched.get('more'): + truncated = False + break + else: + truncated = True + else: + incoming = list(result.get('ops') or []) + # Our own operations are not echoed back, but we now know where they + # went - and they belong in the same ordered stream, above everything + # that was already there. + for op in batch: + seq = seq_of.get(int(op.get('lc', 0))) + if seq: + incoming.append(dict(_wire(op), s=seq)) + + # The cursor may only advance as far as we were actually given. + highest_seen = max([int(op.get('s', 0)) for op in incoming] or [0]) + complete = not truncated and failure is None + reached = max(head, highest_seen, since) if complete else max(highest_seen, since) + + if incoming or reached > since: + _append_inbox({'base_seq': reached, 'ops': incoming}) + + # Only now, once what came back is safely on disk - and only for what is + # covered by it. An operation dropped from the queue before its place in + # the order is recorded would be gone from both: the queue no longer has + # it to replay, and the reply that carried it was never filed. + acknowledged = [lc for lc, seq in seq_of.items() if seq <= reached] + if complete: + acknowledged.extend(dups) + if acknowledged: + outbox.drop(acknowledged) + + if failure is not None: + return _record_failure(failure) + + write_state({ + 'last_ok': int(time.time()), + 'last_error': None, + 'failures': 0, + 'next_attempt': 0, + 'server_head': max(head, reached), + }) + return {'ok': True, 'sent': len(batch), 'received': len(incoming), + 'more': truncated or len(sending) > len(batch)} + + +def _wire(op): + """Strips the queue's own bookkeeping down to what the server accepts.""" + allowed = ('op', 'lc', 'uid', 'f', 'ts', 'project', 'task', 'start', 'end') + return {k: v for k, v in op.items() if k in allowed} + + +def _record_failure(code): + state = read_state() + failures = int(state.get('failures', 0)) + 1 + if code in TERMINAL_ERRORS: + # Nothing will change by asking again sooner. Wait out the longest + # interval and let the user's next action - signing in, correcting + # the address - be what resumes it. + delay = BACKOFF_MAX_SECONDS + else: + delay = min(BACKOFF_START_SECONDS * (2 ** (failures - 1)), BACKOFF_MAX_SECONDS) + write_state({ + 'last_error': code, + 'failures': failures, + 'next_attempt': int(time.time()) + delay, + }) + return {'ok': False, 'error': code} + + +# --------------------------------------------------------------------------- +# The fast half: putting what arrived into the document. +# --------------------------------------------------------------------------- + +def apply_pending(tracker): + """ + Applies everything fetched so far to the tracker's document and saves it. + + Must be called on the thread that owns the document - in the interface, + the one drawing it, immediately after it has reloaded from disk. It works + on that thread's own document object, so the change is visible to the + rest of the redraw and cannot be overwritten by it. + + :raises OSError: if the document cannot be saved. Nothing is consumed in + that case, so the next call tries again - which is why + the caller must not simply swallow it. + :return: A summary dict, or None when there was nothing to do. + """ + if not read_inbox(): + return None + + outbox = tracker.op_outbox or Outbox() + + try: + with taken_inbox() as records: + if not records: + return None + + # Every record at once, in one pass. They are one stream of + # operations that happens to have arrived in instalments, and + # apply_ops orders by sequence number anyway. Applying them + # record by record would replay this machine's own unsent work + # once per record - and count the same discarded time entry once + # per record along with it. + incoming = [op for record in records for op in (record.get('ops') or [])] + reached = max([int(record.get('base_seq', 0)) for record in records] + + [int(read_state().get('base_seq', 0))]) + + local, settled = _split_placed(outbox.pending(), incoming) + report = reconcile(tracker.data, incoming, local) + + # A session this machine had left running was ended because work + # began elsewhere. That was worked out here, from the order alone, + # so unless it is reported the other machines go on showing it as + # running. Queued before the document is saved and before the + # records are consumed: a machine switched off in between would + # otherwise have the closure in its own file, no way to re-derive + # it, and no way to pass it on. + for entry_uid, end in report.auto_closed: + tracker._emit('entry.close', uid=entry_uid, end=end) + + tracker._save_data() + write_state({'base_seq': reached}, required=True) + if settled: + outbox.drop(settled) + except LockTimeout: + return None + + return {'applied': report.applied, 'discarded_time': report.discarded_time, + 'auto_closed': len(report.auto_closed), 'base_seq': reached} + + +def _split_placed(queued, incoming): + """ + Separates queued operations the log has already placed from the rest. + + The replay in reconcile() rests on one assumption: that what it is given + is work the server has NOT yet ordered, so putting it above everything + incoming matches where the server will put it. An operation that is both + queued here AND present in what just arrived breaks that assumption - it + already has a place, further down, and lifting it back to the top inverts + the order. The other machine, replaying the same log, keeps the other + value, and the two quietly stop agreeing. + + The queue and the log can drift apart for several dull reasons: a push + whose reply was lost, a catch-up cut short before the queue was drained, a + machine switched off between filing what arrived and clearing the queue. + Rather than trying to close each of those windows, this asks the only + question that matters - is this operation already in the log? - which the + entries answer themselves, since each carries the device and number it was + sent with. + + :return: (still unplaced, numbers now known to be in the log) + """ + try: + mine = sync_client.device_identity()['device_uid'] + except Exception: + return list(queued), [] + + placed = {int(op['lc']) for op in incoming + if op.get('dev') == mine and op.get('lc') is not None} + if not placed: + return list(queued), [] + return ([op for op in queued if int(op.get('lc', 0)) not in placed], + sorted(placed)) + + +def offer_document(tracker): + """ + Queues this machine's existing document the first time it reaches a server. + + Whoever gets there first fills an empty account; a machine joining later + offers what it has too, and the operations settle by uid. That way a + document built up before synchronisation was switched on is not quietly + left behind - and re-offering the same objects costs nothing, because + creating something that already exists does nothing. + + Done here rather than in the cycle because it reads the document, which + belongs to this thread. Called on every redraw, so the ordinary case - + already offered - has to be one cheap file read and nothing else. + """ + if read_state().get('seeded'): + return 0 + outbox = tracker.op_outbox + if outbox is None or not sync_client.load_credentials(): + return 0 + + try: + # Re-checked under a lock, and the queueing and the mark made together + # inside it. Two browser tabs redraw independently and would otherwise + # both see "not offered yet" and both queue the whole document, which + # is two copies of every operation the other machine has to chew + # through. And were the mark written only afterwards, a machine shut + # down midway would start over from the beginning every time it was + # opened, until the queue filled up and every later change was + # dropped in silence. + with locked(_seed_lock_path()): + if read_state().get('seeded'): + return 0 + ops = seed_operations(tracker.data) + if ops: + # Over the queue limit if need be: a long history is a big + # one-off batch, not a runaway, and it drains 500 at a time. + outbox.extend(ops, allow_overflow=True) + write_state({'seeded': True}) + return len(ops) + except Exception: + # Same reasoning as _emit: a queue that will not take a write costs a + # sync, never the application. Nothing is marked, so this is tried + # again on the next redraw. + return 0 + + +# --------------------------------------------------------------------------- +# The worker. +# --------------------------------------------------------------------------- + +_worker = None +_worker_guard = threading.Lock() +_wake = threading.Event() + +# Each worker gets a stop signal of its own rather than sharing one. A worker +# that is asked to stop while it happens to be inside a request cannot be +# waited for - the interface must not pause for a network timeout - so it is +# left to finish and die on its own. With a shared signal, starting the next +# worker would clear that signal and bring the abandoned one back to life, +# and two of them would then push the same queue. + + +def _blocked(state): + """ + Whether a failure is still being waited out. + + This holds against a nudge as well as against the interval. A nudge comes + from changing view, which happens constantly, and a server that is down + or a token that has been revoked answers the same way every time - so + without this, the backoff would exist on paper and every navigation would + still go and ask. + """ + return int(state.get('next_attempt', 0)) > time.time() + + +def _interval_elapsed(state): + last = state.get('last_ok') + if not last: + return True + return time.time() - float(last) >= _interval_seconds() + + +_interval_minutes = DEFAULT_INTERVAL_MINUTES + + +def _interval_seconds(): + return max(60, int(_interval_minutes) * 60) + + +def _loop(stopping): + while not stopping.is_set(): + try: + woken = _wake.is_set() + _wake.clear() + state = read_state() + if not _blocked(state) and (woken or _interval_elapsed(state)): + outcome = run_cycle() + if stopping.is_set(): + return + if outcome.get('ok') and outcome.get('more'): + # A backlog too large for one exchange. Carry straight on + # rather than waiting out the interval between each batch, + # which would take hours to clear after a long absence. + _wake.set() + except Exception: + # The worker must outlive anything that goes wrong inside it. + # A cycle that fails is one missed sync; a worker that dies is + # no synchronisation at all until the app is restarted, with + # nothing on screen to say so. + pass + stopping.wait(TICK_SECONDS) + + +def ensure_started(config=None): + """ + Brings the worker into line with the setting: running, or not. + + Call this on every redraw, and call it whether or not synchronisation is + switched on - it is what stops the worker as well as what starts it. + Switching the feature off has to actually stop it: otherwise the thread + goes on talking to the server with the stored token for as long as the + application is open, filing operations into an inbox that nobody is + reading any more, and the user who just turned it off has no way to tell. + + Safe to call constantly. Streamlit re-runs its script from the top on + every redraw, so a guard placed in that script would start a thread per + redraw; the guard lives here instead, in a module, which is imported once + per process however many times the script above it runs. + """ + global _worker, _interval_minutes + + if config is not None: + sync_cfg = config.get('sync') if isinstance(config, dict) else None + if not isinstance(sync_cfg, dict) or not sync_cfg.get('enabled'): + stop() + return False + try: + _interval_minutes = int(sync_cfg.get('interval_minutes') + or DEFAULT_INTERVAL_MINUTES) + except (TypeError, ValueError): + _interval_minutes = DEFAULT_INTERVAL_MINUTES + + with _worker_guard: + if _worker is not None and _worker.is_alive(): + return True + stopping = threading.Event() + # Daemon, because the interface exits with os._exit() and closing the + # window terminates the process outright. Nothing here may delay that, + # and nothing here needs to: every write it makes is atomic on its own. + _worker = threading.Thread(target=_loop, args=(stopping,), + name='tc-sync', daemon=True) + _worker.stopping = stopping + _worker.start() + return True + + +def nudge(force=False): + """ + Asks the worker to run a cycle now rather than at the next interval. + + :param force: Also cancels a failure the worker is waiting out. Only for + something the user did that could have fixed the cause - + signing in, correcting the address - never for a nudge the + interface generates on its own. + """ + if force: + write_state({'last_error': None, 'failures': 0, 'next_attempt': 0}) + _wake.set() + + +def stop(): + """ + Ends the worker if one is running. + + Cheap and safe to call when there is nothing to stop, which is what lets + ensure_started() call it on every redraw where the feature is off. + """ + global _worker + with _worker_guard: + worker = _worker + _worker = None + if worker is None: + return + worker.stopping.set() + _wake.set() + # Not joined for long. The thread may be inside a request, and the + # interface must not wait out a network timeout to redraw; it is a daemon, + # so an abandoned one dies with the process and every write it makes is + # atomic on its own. + worker.join(timeout=0.2) + + +def snapshot(): + """ + What the interface needs to show, without asking the network anything. + + :return: dict with 'state' as one of 'off', 'never', 'ok', 'failing'; + plus 'last_ok', 'error', 'pending' and 'incoming'. + """ + state = read_state() + try: + pending = Outbox().count() + except Exception: + pending = 0 + incoming = len(read_inbox()) + + if state.get('last_error'): + kind = 'failing' + elif state.get('last_ok'): + kind = 'ok' + else: + kind = 'never' + + return { + 'state': kind, + 'last_ok': state.get('last_ok'), + 'error': state.get('last_error'), + 'failures': int(state.get('failures', 0)), + 'pending': pending, + 'incoming': incoming, + 'base_seq': state.get('base_seq', 0), + } diff --git a/tt/sync_outbox.py b/tt/sync_outbox.py new file mode 100644 index 0000000..e963e04 --- /dev/null +++ b/tt/sync_outbox.py @@ -0,0 +1,286 @@ +""" +The outgoing queue of operations waiting to reach the server. + +Every change made locally is recorded here as an intention - "set the +priority of task X to 3" - and stays until the server has acknowledged it. +That is what lets the app be used offline and catch up later, and it is why +the queue has to survive a restart. + +WHERE IT LIVES +-------------- +Beside the credential, in the per-user configuration directory, not beside +data.json. Two reasons. It is per-machine state - "what has THIS machine not +sent yet" means nothing on another one - and the data file's location is a +setting the user can point at a shared or cloud-synced folder, where a queue +would be picked up by a second machine and replayed as if it were its own. + +THE SEQUENCE NUMBER +------------------- +Each operation carries an `lc` that must rise strictly and never repeat for +this device: the server treats anything at or below what it has already seen +from a device as a repeat and drops it. That is exactly the behaviour that +makes a lost response harmless - and exactly what turns a duplicate number +into silent data loss. Since the GUI and the MCP/REST/SOAP servers can all be +appending at once, the number is handed out under a lock rather than from a +counter held in one process's memory. + +The counter cannot be derived from the queue alone. A successful sync empties +it, and the next number would then start again at one - which the server has +already seen and would discard, silently, for ever after. So the high-water +mark is kept in a file of its own, beside the queue and written under the +same lock, and survives the queue being emptied. +""" + +import json +import os + +from tt.filelock import locked, LockTimeout +from tt import sync_client + + +def outbox_path(): + return os.path.join(sync_client.config_dir(), 'sync_outbox.jsonl') + + +def _lock_path(): + return os.path.join(sync_client.config_dir(), 'sync_outbox.lock') + + +def _highwater_path(): + return os.path.join(sync_client.config_dir(), 'sync_outbox.hw') + + +class OutboxFull(RuntimeError): + """Raised when the queue has grown past the point of being useful.""" + + +# A queue this long means syncing has been failing for a very long time. +# Growing without limit would turn a broken connection into a filled disk, +# and a push that can never fit in one request into a permanent blockage. +MAX_PENDING = 20000 + + +class Outbox: + """ + Append-only queue of operations awaiting acknowledgement. + + Reading and writing are cheap enough to do per change: the queue only + holds what has not been sent, which under normal use is a handful of + lines drained every few minutes. + """ + + def __init__(self, path=None, lock_path=None, highwater_path=None): + self.path = path or outbox_path() + self.lock_path = lock_path or _lock_path() + self.highwater_path = highwater_path or _highwater_path() + + # -- reading --------------------------------------------------------- + + def pending(self): + """ + Returns the queued operations in the order they were made. + + Sorted by 'lc' rather than left in file order. The server stamps a + batch in the order it receives it and then refuses anything at or + below the highest number it has seen from this device - so sending + 5 before 3 would make 3 look like a repeat and lose it. + + A line that will not parse is skipped rather than raising. The queue + is appended to by several processes and a machine can be switched off + mid-write; one damaged line should cost that one change, not the + ability to sync at all. + """ + out = [] + try: + with open(self.path, 'r', encoding='utf-8') as f: + for line in f: + line = line.strip() + if not line: + continue + try: + entry = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(entry, dict) and 'lc' in entry: + out.append(entry) + except OSError: + return [] + out.sort(key=lambda e: int(e.get('lc', 0))) + return out + + def count(self): + return len(self.pending()) + + # -- the counter ----------------------------------------------------- + + def _read_highwater(self): + try: + with open(self.highwater_path, 'r', encoding='utf-8') as f: + return int((f.read() or '0').strip() or 0) + except (OSError, ValueError): + return 0 + + def _write_highwater(self, value): + tmp = self.highwater_path + '.tmp' + with open(tmp, 'w', encoding='utf-8') as f: + f.write(str(int(value))) + try: + os.chmod(tmp, 0o600) + except OSError: + pass + os.replace(tmp, self.highwater_path) + + # -- writing --------------------------------------------------------- + + def append(self, op, **fields): + """ + Adds one operation to the queue and returns the number it was given. + + :param op: One of the operation names the server accepts. + :raises OutboxFull: when the queue has grown implausibly long. + :raises LockTimeout: when another process holds the queue too long. + """ + # The directory of the queue itself, not the configuration directory: + # the two are the same in the app, but a caller that passed its own + # path meant that path. + os.makedirs(os.path.dirname(self.path) or '.', exist_ok=True) + with locked(self.lock_path): + existing = self.pending() + if len(existing) >= MAX_PENDING: + raise OutboxFull( + "%d operations are waiting to be sent" % len(existing)) + + # Read from disk, not from memory, because the next append may + # well come from a different process - and taken from the + # high-water mark as well as the queue, because a successful sync + # empties the queue and the number must not start over. + next_lc = max( + self._read_highwater(), + max((int(e.get('lc', 0)) for e in existing), default=0), + ) + 1 + + entry = {'op': op, 'lc': next_lc} + entry.update({k: v for k, v in fields.items() if v is not None}) + + # The mark is raised before the line is written. Should the write + # fail, a number is skipped - which costs nothing, since the + # server only requires them to rise. The reverse order could hand + # the same number out twice. + self._write_highwater(next_lc) + with open(self.path, 'a', encoding='utf-8') as f: + f.write(json.dumps(entry, ensure_ascii=False) + '\n') + try: + os.chmod(self.path, 0o600) + except OSError: + pass + return next_lc + + def extend(self, operations, allow_overflow=False): + """ + Adds many operations at once, numbered consecutively. + + The same work as calling append() in a loop, but the lock is taken + once and the queue is read once, instead of once per operation. That + matters for the one caller that has thousands of them - offering an + existing document to an empty server - where the loop would be + quadratic and would freeze the interface for minutes. + + :param operations: Dicts with an 'op' key and the operation's fields. + :param allow_overflow: Accept the batch even if it takes the queue + past the limit. The limit exists to stop a queue growing + without bound while syncing is broken; describing an + existing document for a server that has never seen it is + the opposite - a single finite batch that then drains. A + long history could exceed the limit, and refusing it would + mean that machine's document is never offered at all, + silently, with nothing anywhere to say why. + :return: The numbers handed out. + :raises OutboxFull: when the queue would grow past the limit. + """ + operations = list(operations) + if not operations: + return [] + os.makedirs(os.path.dirname(self.path) or '.', exist_ok=True) + with locked(self.lock_path): + existing = self.pending() + if not allow_overflow and len(existing) + len(operations) > MAX_PENDING: + raise OutboxFull( + "%d operations would exceed the queue limit" % len(operations)) + + next_lc = max( + self._read_highwater(), + max((int(e.get('lc', 0)) for e in existing), default=0), + ) + 1 + + lines = [] + numbers = [] + for offset, op in enumerate(operations): + fields = dict(op) + name = fields.pop('op') + entry = {'op': name, 'lc': next_lc + offset} + entry.update({k: v for k, v in fields.items() if v is not None}) + lines.append(json.dumps(entry, ensure_ascii=False)) + numbers.append(entry['lc']) + + self._write_highwater(numbers[-1]) + with open(self.path, 'a', encoding='utf-8') as f: + f.write('\n'.join(lines) + '\n') + try: + os.chmod(self.path, 0o600) + except OSError: + pass + return numbers + + def drop(self, acknowledged_lcs): + """ + Removes operations the server has confirmed. + + Rewrites the file rather than truncating it, because acknowledgement + does not have to arrive in order: a batch can be partly accepted and + partly reported as already known. + """ + done = set(int(x) for x in acknowledged_lcs) + if not done: + return 0 + with locked(self.lock_path): + keep = [e for e in self.pending() if int(e.get('lc', 0)) not in done] + tmp = self.path + '.tmp' + with open(tmp, 'w', encoding='utf-8') as f: + for entry in keep: + f.write(json.dumps(entry, ensure_ascii=False) + '\n') + try: + os.chmod(tmp, 0o600) + except OSError: + pass + os.replace(tmp, self.path) + return len(done) + + def clear(self): + """ + Discards everything queued. Used when a machine is re-seeded. + + The high-water mark is deliberately left alone: the server still + remembers the numbers this device has used, and starting over would + make everything sent afterwards look like a repeat. + """ + with locked(self.lock_path): + try: + os.remove(self.path) + except OSError: + pass + + +def default_outbox_if_enabled(config): + """ + Returns an Outbox when synchronisation is switched on, otherwise None. + + Keeping the decision here means TimeTracker does not have to know how the + setting is spelled, and that an absent 'sync' key - every installation + that predates this feature - simply means off. + """ + if not isinstance(config, dict): + return None + sync_cfg = config.get('sync') + if not isinstance(sync_cfg, dict) or not sync_cfg.get('enabled'): + return None + return Outbox() From 48de933b88d23257159eec1e25c0c9b96fdee377 Mon Sep 17 00:00:00 2001 From: Frank Faulstich Date: Wed, 12 Aug 2026 19:42:58 +0200 Subject: [PATCH 2/4] Correction 1 --- ISSUES-sync.md | 319 +++++++++++++++++++++++++++++++++ php-server/check-login.sh | 13 +- php-server/check-oplog.py | 27 ++- php-server/check-sync-apply.py | 32 +++- php-server/check-sync-cycle.py | 26 ++- php-server/tcprobe/tcprobe.php | 8 +- sl/SL_Menu.py | 3 + tests/test_sync_apply.py | 37 +++- tests/test_sync_engine.py | 149 ++++++++++++++- tt/TimeTracker.py | 10 +- tt/sync_apply.py | 59 +++--- tt/sync_engine.py | 100 +++++++++++ 12 files changed, 739 insertions(+), 44 deletions(-) create mode 100644 ISSUES-sync.md diff --git a/ISSUES-sync.md b/ISSUES-sync.md new file mode 100644 index 0000000..670b69d --- /dev/null +++ b/ISSUES-sync.md @@ -0,0 +1,319 @@ +# Open issues after the synchronisation work + +Ready to paste into GitHub, one section per issue. Written in English to match +the repository. Delete this file once they are filed. + +Not repeated here: **“Sync server: compact the operation log”**, which was +drafted separately — file that one from the earlier text so there are not two +slightly different versions of it. + +--- + +## 1. Sync: changing the server address after signing in has no effect + +**Labels:** `bug`, `sync` + +`Settings → Sync Server Settings` writes `sync.base_url` into `config.json`, +but nothing reads it again once you are signed in. Every request goes to the +address stored inside the credential (`tt/sync_client.py`, `_authenticated()` +uses `load_credentials()['base_url']`), which was frozen at sign-in time. + +So moving the server, or fixing a typo you only noticed later, silently does +nothing: the app keeps talking to the old address, the settings screen shows +the new one, and the two never meet. + +The address actually in use is not displayed anywhere either, so there is no +way to tell from the interface which one is live. + +**Suggested fix:** show the address from the credential next to the field +(“currently in use: …”), and either re-resolve `base_url` on each request or +tell the user plainly that a changed address takes effect after signing in +again. Signing out and back in already works — it just is not discoverable. + +--- + +## 2. Sync: unknown server errors surface as “Sign-in failed” in the background + +**Labels:** `bug`, `sync` + +`_sync_error_message()` (`sl/SL_Menu.py`) maps thirteen codes and falls back to +`"Sign-in failed ({code})."`. That wording was right when the function was only +used by the sign-in form. It is now also used by the settings status line, +which passes whatever `sync_engine.snapshot()` last recorded — so any code the +table does not know is reported to a user who has not touched the sign-in form: + + busy -> "Sign-in failed (busy)." + too_many_ops -> "Sign-in failed (too_many_ops)." + +The header notice is not affected: it filters to a whitelist of five codes that +the table does cover. + +**Suggested fix:** give the function a context, or split it: a sign-in variant +and a general one whose fallback reads like “Synchronisation failed ({code}).” +Add the codes the server can actually return that are missing from the table. + +--- + +## 3. Sync: the settings screen’s “unreachable” branch can never run + +**Labels:** `bug`, `sync`, `good first issue` + +`view_settings()` still has a branch rendering “The server could not be reached +({reason})”. It is dead: the surrounding code no longer calls +`sync_client.status()` on every redraw — that was removed because it put a +blocking network round trip behind every keystroke — and reads the cached +credential instead, which only ever yields `ok` or `not_configured`. + +The state does still occur; it now arrives through `sync_engine.snapshot()` and +is rendered a few lines higher. So this is dead code that looks like coverage. + +**Suggested fix:** remove the branch, or make the **Check connection** button +(which does call `status()`) the thing that feeds it. + +--- + +## 4. Sync: the repo-hygiene guard does not cover the sync block + +**Labels:** `bug`, `sync`, `security` + +`tests/test_repo_hygiene.py` exists to stop a real e-mail address being +committed in the tracked `config.json`. The settings screen now also writes a +`sync` block into that same tracked file, containing the address of the user’s +private server — and the guard says nothing about it. + +`config.json` is tracked in a public repository, so this is the same class of +leak the guard was written to prevent. + +**Suggested fix:** extend the guard to fail when `sync.base_url` is set to +anything other than empty or the documented placeholder. + +--- + +## 5. Sync server: the installer skips its own reachability proof + +**Labels:** `bug`, `sync`, `php-server` + +`setup.php` proves the store is not web-readable by fetching a canary over +HTTP before it will install. That proof is skipped when `tc/` is not directly +under the document root, because the installer cannot work out the canary’s +public URL — and it then installs anyway, dropping the store somewhere inside +the web space. + +The whole storage design rests on the store not being readable from outside. +Skipping the proof in exactly the layout where it is least obvious whether the +store is exposed is the wrong way round. + +**Suggested fix:** when the URL cannot be derived, ask for it rather than +proceeding, and refuse to install until the canary comes back 403/404. + +--- + +## 6. Sync server: a BOM in setup.enable means “Wrong passphrase” for ever + +**Labels:** `bug`, `sync`, `php-server` + +The installation passphrase is read from `setup.enable` and compared verbatim. +A file saved by Notepad or a Windows editor carries a UTF-8 byte order mark, +which becomes part of the comparison — so every attempt is refused, with a +message saying the passphrase is wrong when it is not. + +There is no way to tell the two cases apart from the page. + +**Suggested fix:** strip a leading BOM and surrounding whitespace when reading +the file. Worth checking the same for the username and password fields. + +--- + +## 7. Sync server: open registration + +**Labels:** `enhancement`, `sync`, `php-server` + +Deliberately not implemented. Accounts are created by hand through `setup.php`. + +Adding a self-service registration button needs the protections that go with +it: rate limiting that cannot be used to lock out existing users, some defence +against automated sign-ups, a decision about whether new accounts need +approval, and a way to remove abandoned ones. None of that exists yet. + +Not needed while the server has one user. + +--- + +## 8. Sync: only the GUI sends; the MCP, REST and SOAP servers only queue + +**Labels:** `enhancement`, `sync` + +`sync_engine.ensure_started()` is called from `sl/SL_Menu.py` and nowhere else, +so the background worker exists only in the GUI process. Changes made through +the MCP, REST or SOAP interfaces are recorded into the outgoing queue correctly +— but they leave the machine only once the GUI is running. + +For someone who drives TimeControl mainly through Claude Desktop and rarely +opens the GUI, their work reaches the second machine hours late or not at all. + +**Suggested fix:** start the worker from the three server entry points too. The +cross-process cycle lock already exists, so several workers are safe; the open +question is whether a short-lived stdio MCP process should sync at all, or +whether it should push once at exit. + +--- + +## 9. Sync: clock skew decides which running session is auto-closed + +**Labels:** `bug`, `sync` + +When work begins on the second machine, the session left running on the first +is closed at the moment the new one began. Which of the two is “earlier” is +decided by `start_time` — a wall clock, on two machines that are allowed to +disagree. + +With a laptop resumed from sleep and no NTP, the machines can be minutes apart, +and `_settle()` then closes the session that is actually still running while +leaving the finished one open. + +The server’s sequence numbers already carry the true order and would decide +this correctly, but `sync_apply` deliberately does not look at them here. + +**Suggested fix:** where both sessions arrived through the log, prefer the +sequence order over the timestamps; fall back to the clock only for two +sessions that were both created locally. + +--- + +## 10. Sync: verify the client against the real server + +**Labels:** `task`, `sync`, `verification` + +Everything client-side has so far been verified against a stand-in that +reimplements `php-server/tc` in Python. The stand-in was written from the same +reading of the contract as the client, so a misunderstanding would be present +in both and invisible. + +Still to do, against a **throwaway account**: + +- `python3 php-server/check-sync-cycle.py` — two machines, the real engine +- `python3 php-server/check-sync-apply.py` — the merge rules +- a two-machine run through the GUI, which the scripts do not cover + +Both scripts now ask for the address or read `TC_SYNC_URL`. Delete the old +`synctest2` account first and create a fresh one; the log cannot be cleaned up +selectively. + +--- + +## 11. Sync: the Windows lock path and the frozen build are unverified + +**Labels:** `task`, `sync`, `windows` + +Two things have never been exercised on the platform they exist for: + +- `tt/filelock.py`’s `msvcrt` branch is covered across processes but not + between two threads of one process — which is precisely what the sync worker + and the drawing thread do. The POSIX guarantee comes from `flock` attaching + to the open file description and does not transfer. +- `TimeControl.spec` gained the sync modules under `hiddenimports`, because + `sl/SL_Menu.py` is shipped as data and never scanned for imports. That fix + has not been checked against an actual PyInstaller build. + +If the sync modules are missing from a build, `SYNC_AVAILABLE` becomes `False` +and the feature silently disappears — the failure mode gives no clue. + +--- + +## 12. generate_task_report reports the wrong first and last activity + +**Labels:** `bug` + +Pre-existing, unrelated to synchronisation, but easy to hit now that entries +can arrive out of order. + +`generate_task_report()` takes “First entry” from the first element of +`time_entries` and “Last activity” from the last, rather than from the minimum +and maximum. The list is only chronological by accident — `entry.add` appends, +nothing sorts, and `_settle()` moves an open entry to the end. + +Reproducible without sync: give a task one entry 12:30–13:00, apply an incoming +entry 09:00–10:00, and the report says the task started at 12:30 and last saw +activity at 10:00. The per-day breakdown is out of order too. + +**Suggested fix:** compute both from `min()`/`max()` over the entries, and sort +each day’s lines. + +--- + +## 13. data.json has five potential writers and no lock + +**Labels:** `bug`, `reliability` + +Pre-existing, but synchronisation makes it sharper. + +`TimeTracker._save_data()` writes the whole document through a temp file and +`os.replace()`. That is atomic for *readers* — nobody ever sees a half-written +file — but it does nothing about lost updates. The GUI, the MCP server, the +REST server, the SOAP server and a second browser tab can all hold the document +in memory and write it back; the last one wins and the others’ changes are gone +with no error anywhere. + +With sync enabled a lost write is partly recoverable, because the operations +were queued before the save. Partly is not the same as reliably. + +`tt/filelock.py` already exists and is used for the outgoing queue. + +**Suggested fix:** take the lock around reload → modify → save, at least in the +GUI and the three servers. Note this changes `_save_data()` for every caller and +introduces a failure mode (`LockTimeout`) that none of them currently handle. + +--- + +## 14. data.json and config.json are tracked in a public repository + +**Labels:** `security`, `privacy` + +Both files are committed and public. No credentials have ever been in them — +the sync token deliberately lives outside the project directory — but +`data.json` carries real project and task names, and its history keeps them +even after they are removed. + +`.gitignore` lists `data.json`, which has no effect: the file is already +tracked, so the entry is inert. + +**Suggested fix:** `git rm --cached` both, ship `data.example.json` and +`config.example.json` instead, and decide separately whether the history needs +rewriting — that is disruptive and only worth it if the contents are sensitive. + +--- + +## 15. docs/ points autodoc at modules that no longer exist + +**Labels:** `bug`, `documentation` + +`docs/modules.rst` has `automodule:: TimeTracker` and +`automodule:: TimeTrackerMCP`. Neither resolves: the first moved to +`tt.TimeTracker`, and the second is `TimeTrackerMCP_Server`. Only `update` +still matches. + +None of the sync modules are covered either — `tt/sync_client.py`, +`tt/sync_engine.py`, `tt/sync_apply.py`, `tt/sync_outbox.py` and +`tt/filelock.py` all carry full docstrings that appear nowhere in the built +documentation. + +**Suggested fix:** correct the two paths and add the five new modules. Worth a +CI check that the build emits no autodoc warnings, or this recurs. + +--- + +## 16. Sync: two functions with no production caller + +**Labels:** `chore`, `sync`, `good first issue` + +- `sync_client.head()` implements the cheap poll and is called only by its own + test. Both `README.md` and `php-server/README.md` describe `?a=head` as what + the client asks first; it does not. Either use it — a cycle could skip the + push entirely when the head has not moved and there is nothing queued — or + stop describing it that way. +- `Outbox.clear()` has no caller outside the tests. Its docstring refers to + re-seeding a machine, which is not something the app does; the re-offer path + added later works differently. + +Neither is harmful. Both are the kind of thing that reads as coverage and is +not. diff --git a/php-server/check-login.sh b/php-server/check-login.sh index 1fbee93..1ca767d 100755 --- a/php-server/check-login.sh +++ b/php-server/check-login.sh @@ -12,7 +12,18 @@ # # The password is read without echo and never appears in the command line or # the shell history. -URL=https://www.familiefaulstich.de/tc/index.php +# No address baked in: this file lives in a public repository, and where +# somebody's private server sits is not something to publish. Pass it in. +URL="${TC_SYNC_URL:-}" +if [ -z "$URL" ]; then + printf 'Server address (https://host/tc/): ' + read -r URL +fi +case "$URL" in + */index.php) ;; + */) URL="${URL}index.php" ;; + *) URL="${URL}/index.php" ;; +esac printf 'Kontoname [frank]: '; read TCUSER; [ -z "$TCUSER" ] && TCUSER=frank printf 'Kontopasswort: '; stty -echo; read TCPW; stty echo; echo diff --git a/php-server/check-oplog.py b/php-server/check-oplog.py index 2c8ee61..f78d416 100755 --- a/php-server/check-oplog.py +++ b/php-server/check-oplog.py @@ -14,6 +14,7 @@ """ import getpass +import os import json import secrets import sys @@ -23,7 +24,29 @@ except ImportError: sys.exit("requests is missing - pip install -r requirements.txt") -BASE = "https://www.familiefaulstich.de/tc/index.php" +BASE = None # set in main(), see server_address() + + +def server_address(suffix=""): + """ + Where the server is, asked for rather than baked in. + + This file lives in a public repository. A default here would publish the + address of somebody's private server, and would also be wrong for anyone + else who ran it. + """ + url = os.environ.get('TC_SYNC_URL', '').strip() + if not url: + url = input("Server address (https://host/tc/): ").strip() + if not url: + sys.exit("No server address given. Set TC_SYNC_URL or type one.") + if not url.lower().startswith('https://'): + sys.exit("The address must start with https:// - the server refuses anything else.") + url = url.rstrip('/') + if suffix and not url.endswith(suffix): + url += '/' + suffix + return url + passed = 0 failed = 0 @@ -57,6 +80,8 @@ def call(action, payload=None, token=None, params=None): def main(): + global BASE + BASE = server_address('index.php') user = input("Throwaway account name: ").strip() if not user: sys.exit("No account given.") diff --git a/php-server/check-sync-apply.py b/php-server/check-sync-apply.py index 7d570c3..c7abfea 100644 --- a/php-server/check-sync-apply.py +++ b/php-server/check-sync-apply.py @@ -32,7 +32,29 @@ from tt.sync_apply import apply_ops, reconcile, seed_operations -BASE = "https://www.familiefaulstich.de/tc/index.php" +BASE = None # set in main(), see server_address() + + +def server_address(suffix=""): + """ + Where the server is, asked for rather than baked in. + + This file lives in a public repository. A default here would publish the + address of somebody's private server, and would also be wrong for anyone + else who ran it. + """ + url = os.environ.get('TC_SYNC_URL', '').strip() + if not url: + url = input("Server address (https://host/tc/): ").strip() + if not url: + sys.exit("No server address given. Set TC_SYNC_URL or type one.") + if not url.lower().startswith('https://'): + sys.exit("The address must start with https:// - the server refuses anything else.") + url = url.rstrip('/') + if suffix and not url.endswith(suffix): + url += '/' + suffix + return url + passed = 0 failed = 0 @@ -126,6 +148,8 @@ def find_entry(doc, entry_uid): def main(): + global BASE + BASE = server_address('index.php') user = input("Throwaway account name: ").strip() if not user: sys.exit("No account given.") @@ -163,11 +187,13 @@ def login(name): "next_id": 2, "_deleted": [], "schema_version": 2, } seeded = copy.deepcopy(a.doc) + expected = len(seed_operations(a.doc)) for op in seed_operations(a.doc): a.queue(op.pop("op"), **op) r, _ = a.sync() - check("the seed was accepted", r.get("ok") and len(r.get("assigned", [])) == 4, - str(r.get("assigned"))) + check("the seed was accepted", + r.get("ok") and len(r.get("assigned", [])) == expected, + "%s of %d" % (len(r.get("assigned", [])), expected)) check("the document is unchanged by seeding it", a.doc["projects"] == seeded["projects"]) r, _ = b.sync() diff --git a/php-server/check-sync-cycle.py b/php-server/check-sync-cycle.py index 1494422..526fb0d 100644 --- a/php-server/check-sync-cycle.py +++ b/php-server/check-sync-cycle.py @@ -30,7 +30,29 @@ from tt.sync_outbox import Outbox from tt.TimeTracker import TimeTracker -SERVER = "https://www.familiefaulstich.de/tc/" +SERVER = None # set in main(), see server_address() + + +def server_address(suffix=""): + """ + Where the server is, asked for rather than baked in. + + This file lives in a public repository. A default here would publish the + address of somebody's private server, and would also be wrong for anyone + else who ran it. + """ + url = os.environ.get('TC_SYNC_URL', '').strip() + if not url: + url = input("Server address (https://host/tc/): ").strip() + if not url: + sys.exit("No server address given. Set TC_SYNC_URL or type one.") + if not url.lower().startswith('https://'): + sys.exit("The address must start with https:// - the server refuses anything else.") + url = url.rstrip('/') + if suffix and not url.endswith(suffix): + url += '/' + suffix + return url + passed = 0 failed = 0 @@ -120,6 +142,8 @@ def running_entries(machine): def main(): + global SERVER + SERVER = server_address('') user = input("Throwaway account name: ").strip() if not user: sys.exit("No account given.") diff --git a/php-server/tcprobe/tcprobe.php b/php-server/tcprobe/tcprobe.php index 2954fec..7de72ef 100644 --- a/php-server/tcprobe/tcprobe.php +++ b/php-server/tcprobe/tcprobe.php @@ -29,8 +29,12 @@ * Nothing here writes anything a later install depends on. */ -const PROBE_KEY = '12345678901234567890'; -const PROBE_BUILD = 2; +// Ships empty on purpose. The gate below is a length check, and an empty +// value fails it - so an unedited copy refuses every call. A shipped key long +// enough to pass its own gate would arm the probe for anyone who uploaded it +// without reading, with a key published in this repository for all to see. +const PROBE_KEY = ''; +const PROBE_BUILD = 3; // --------------------------------------------------------------------------- diff --git a/sl/SL_Menu.py b/sl/SL_Menu.py index d7edc23..014e4e9 100644 --- a/sl/SL_Menu.py +++ b/sl/SL_Menu.py @@ -382,6 +382,9 @@ def render_icon_button_css(): # would have been seen by nobody. if st.session_state.pop('sync_discarded_shown', False): st.session_state.pop('sync_discarded', None) + # Before anything is applied: if data.json has been restored from + # a backup, the cursor has to come back with it. + sync_engine.align_cursor(st.session_state.tracker) sync_engine.offer_document(st.session_state.tracker) try: _sync_summary = sync_engine.apply_pending(st.session_state.tracker) diff --git a/tests/test_sync_apply.py b/tests/test_sync_apply.py index 73a10a7..f06d474 100644 --- a/tests/test_sync_apply.py +++ b/tests/test_sync_apply.py @@ -794,10 +794,39 @@ def test_a_document_is_described_as_the_operations_that_would_build_it(self): ops = seed_operations(doc) self.assertEqual([o["op"] for o in ops], - ["project.create", "task.create", "entry.add", "entry.close"]) - self.assertEqual(ops[1]["project"], P1) - self.assertNotIn("id", ops[1]["f"]) - self.assertNotIn("time_entries", ops[1]["f"]) + ["project.create", "project.set", + "task.create", "task.set", "task.move", + "entry.add", "entry.set"]) + create = next(o for o in ops if o["op"] == "task.create") + self.assertEqual(create["project"], P1) + self.assertNotIn("id", create["f"]) + self.assertNotIn("time_entries", create["f"]) + + def test_it_also_states_the_current_value_of_everything(self): + """ + A machine re-introducing itself is talking to machines that already + have these objects, where a create does nothing. Without the set, + everything that changed while it was out of contact would be + announced and silently dropped. + """ + from tt.sync_apply import seed_operations + + doc = document(project(P1, "Renamed", tasks=[ + task(T1, "Also renamed", priority=6, + entries=[entry(E1, "2026-08-10 09:00:00", "2026-08-10 10:00:00")])])) + ops = seed_operations(doc) + + # Apply to a machine that already holds the old values. + elsewhere = document(project(P1, "Old", tasks=[ + task(T1, "Old name", priority=0, + entries=[entry(E1, "2026-08-10 09:00:00")])])) + apply_ops(elsewhere, [dict(o, s=i) for i, o in enumerate(ops, 1)]) + + self.assertEqual(elsewhere["projects"][0]["main_project_name"], "Renamed") + arrived = find_task(elsewhere, T1) + self.assertEqual(arrived["task_name"], "Also renamed") + self.assertEqual(arrived["priority"], 6) + self.assertEqual(arrived["time_entries"][0]["end_time"], "2026-08-10 10:00:00") def test_a_seeded_document_rebuilds_exactly(self): """ diff --git a/tests/test_sync_engine.py b/tests/test_sync_engine.py index 8d3f465..b0b38a8 100644 --- a/tests/test_sync_engine.py +++ b/tests/test_sync_engine.py @@ -495,8 +495,9 @@ def test_an_existing_document_is_offered_the_first_time(self): queued = sync_engine.offer_document(self.tracker) self.assertGreater(queued, 0) + from tt.sync_apply import seed_operations self.assertEqual([o['op'] for o in self.outbox.pending()], - ['project.create', 'task.create']) + [o['op'] for o in seed_operations(self.tracker.data)]) def test_it_is_offered_only_once(self): self.tracker.add_main_project('Existing') @@ -994,16 +995,19 @@ def counted(op, **fields): queued = sync_engine.offer_document(self.tracker) - self.assertEqual(queued, 61) + from tt.sync_apply import seed_operations + self.assertEqual(queued, len(seed_operations(self.tracker.data))) self.assertEqual(calls['n'], 0, "it still appends one at a time") # Numbered consecutively, and above the mark left by the changes that # were made and then cleared - the server refuses anything at or # below a number it has already seen from this device. + expected = len(seed_operations(self.tracker.data)) numbers = [e['lc'] for e in self.outbox.pending()] - self.assertEqual(len(numbers), 61) - self.assertEqual(numbers, list(range(numbers[0], numbers[0] + 61))) - self.assertGreater(numbers[0], 61) + self.assertEqual(len(numbers), expected) + self.assertEqual(numbers, list(range(numbers[0], numbers[0] + expected))) + self.assertGreater(numbers[0], 61, + "numbering restarted below what the server has seen") def test_two_tabs_offering_at_once_queue_one_copy(self): """ @@ -1029,8 +1033,11 @@ def offer(): for t in threads: t.join(10) - self.assertEqual(sorted(counts), [0, 2], str(counts)) - self.assertEqual(len(self.outbox.pending()), 2) + from tt.sync_apply import seed_operations + expected = len(seed_operations(self.tracker.data)) + self.assertEqual(sorted(counts), [0, expected], str(counts)) + self.assertEqual(len(self.outbox.pending()), expected, + "the document was queued twice over") class TestWhenTheDiskItselfMisbehaves(EngineTestCase): @@ -1159,6 +1166,134 @@ def test_the_interface_still_gets_an_answer_when_the_queue_is_unreadable(self): self.assertEqual(snap['pending'], 0) +class TestTheCursorAndTheLogComingApart(EngineTestCase): + """ + The cursor is a position in one particular log, held in one particular + document. Both can be replaced underneath it, and when that happens + nothing complains: the cycle keeps reporting success while sending and + receiving nothing at all. + """ + + DATA = 'test_engine_reset.json' + + def setUp(self): + super().setUp() + if os.path.exists(self.DATA): + os.remove(self.DATA) + self.tracker = TimeTracker(file_path=self.DATA, op_outbox=self.outbox) + + def tearDown(self): + if os.path.exists(self.DATA): + os.remove(self.DATA) + super().tearDown() + + def test_a_log_shorter_than_our_position_is_a_different_log(self): + """ + A re-created account, a wiped store, a rebuilt server. A log only ever + grows, so a head below the cursor cannot be the log the cursor came + from. + """ + sync_engine.write_state({'base_seq': 50, 'seeded': True}) + self.tracker.add_main_project('Mine') + + sync_engine.run_cycle(self.outbox) + + state = sync_engine.read_state() + self.assertEqual(state['base_seq'], 0, + "the cursor still points into a log that no longer exists") + self.assertGreater(len(self.server.log), 0, + "nothing was ever sent to the new log") + + def test_and_the_document_is_offered_to_it_again(self): + sync_engine.write_state({'base_seq': 50, 'seeded': True}) + self.tracker.add_main_project('Mine') + sync_engine.run_cycle(self.outbox) + sync_engine.apply_pending(self.tracker) + + self.assertFalse(sync_engine.read_state()['seeded'], + "the new log is never told what this machine holds") + sync_engine.offer_document(self.tracker) + self.assertTrue(self.outbox.pending()) + + def test_signing_in_to_a_different_account_starts_over(self): + sync_engine.run_cycle(self.outbox) + sync_engine.apply_pending(self.tracker) + sync_engine.write_state({'base_seq': 3}) + + sync_client.load_credentials = lambda: {'token': 't', 'username': 'someone-else', + 'base_url': 'https://x/index.php'} + sync_engine.run_cycle(self.outbox) + + self.assertEqual(sync_engine.read_state()['base_seq'], 0) + + def test_a_restored_backup_takes_the_cursor_back_with_it(self): + """ + data.json goes back to yesterday while the state file stays at today. + Everything in between is already marked as applied, so without this it + is never fetched again and the restored copy stays incomplete - with a + healthy-looking sync throughout. + """ + self.server.add_foreign('project.create', uid=P1, f={'name': 'Remote'}) + sync_engine.run_cycle(self.outbox) + sync_engine.apply_pending(self.tracker) + self.assertEqual(sync_engine.read_state()['base_seq'], 1) + self.assertEqual(self.tracker.data['_sync_seq'], 1) + + # Yesterday's file: it predates that operation. + self.tracker.data['_sync_seq'] = 0 + self.tracker.data['projects'] = [] + + given_up = sync_engine.align_cursor(self.tracker) + + self.assertEqual(given_up, 1) + self.assertEqual(sync_engine.read_state()['base_seq'], 0) + + sync_engine.run_cycle(self.outbox) + sync_engine.apply_pending(self.tracker) + self.assertIsNotNone(self.tracker._get_project('Remote'), + "what the restored copy was missing never came back") + + def test_a_cursor_ahead_of_the_document_is_the_only_one_that_moves(self): + """Going forward over a gap cannot be undone, so it never happens.""" + sync_engine.write_state({'base_seq': 2}) + self.tracker.data['_sync_seq'] = 9 + self.assertEqual(sync_engine.align_cursor(self.tracker), 0) + self.assertEqual(sync_engine.read_state()['base_seq'], 2) + + def test_a_document_that_carries_no_mark_is_left_alone(self): + sync_engine.write_state({'base_seq': 4}) + self.tracker.data.pop('_sync_seq', None) + self.assertEqual(sync_engine.align_cursor(self.tracker), 0) + self.assertEqual(sync_engine.read_state()['base_seq'], 4) + + +class TestSwitchingItOffAndOnAgain(EngineTestCase): + """ + While it is off nothing is recorded - that is what off means. So the + changes made in between exist nowhere but this machine, and switching it + back on has to make the machine describe itself again. + """ + + def test_the_gap_is_remembered(self): + sync_engine.ensure_started({'sync': {'enabled': True}}) + sync_engine.write_state({'seeded': True}) + + sync_engine.ensure_started({'sync': {'enabled': False}}) + self.assertTrue(sync_engine.read_state()['was_off']) + + sync_engine.ensure_started({'sync': {'enabled': True}}) + state = sync_engine.read_state() + self.assertFalse(state['seeded'], + "the changes made while it was off reach nobody") + self.assertFalse(state['was_off']) + + def test_staying_on_does_not_keep_re_offering(self): + sync_engine.write_state({'seeded': True}) + for _ in range(3): + sync_engine.ensure_started({'sync': {'enabled': True}}) + self.assertTrue(sync_engine.read_state()['seeded']) + + class TestTheInterval(EngineTestCase): def test_a_nonsense_interval_falls_back_instead_of_crashing(self): diff --git a/tt/TimeTracker.py b/tt/TimeTracker.py index 11b271e..2759184 100644 --- a/tt/TimeTracker.py +++ b/tt/TimeTracker.py @@ -640,17 +640,23 @@ def _record_deletion(self, entity, kind): # to record - better an unrecorded deletion than a note naming # nothing. return + at = datetime.now().isoformat() self.data.setdefault("_deleted", []).append({ "uid": uid, "kind": kind, - "at": datetime.now().isoformat() + "at": at }) # Told to the sync server from here rather than from each of the five # call sites, so the operations and the tombstones cannot drift apart # - they are now the same decision, taken once. In particular the # deliberate omissions carry over for free: move_task never reaches # this method, and time entries never get a note of their own. - self._emit(kind + '.delete', uid=uid) + # + # The moment is sent along. The other machine keeps a note of its own + # and expires it after ninety days; with nothing to date it from, that + # note is swept on the very next start and the deletion it was + # recording can then be undone by any later edit. + self._emit(kind + '.delete', uid=uid, ts=at) def _record_project_deletion(self, project): """ diff --git a/tt/sync_apply.py b/tt/sync_apply.py index a51f34b..fb59106 100644 --- a/tt/sync_apply.py +++ b/tt/sync_apply.py @@ -187,13 +187,18 @@ def _add_tombstone(document, uid, kind, when): document["_deleted"].append({"uid": uid, "kind": kind, "at": when}) -def apply_ops(document, ops, on_conflict=None): +def apply_ops(document, ops, on_conflict=None, now=None): """ Applies operations from the server to a document, in place. :param document: A schema-2 document. Modified. :param ops: Operations as the server returned them, each with 's' (the sequence number that decides order) and 'op'. + :param now: A timestamp to date a tombstone that arrives without one - + from a version that predates sending it. This module takes no + clock of its own; an undated tombstone is swept the moment it + is written, and the deletion it records can then be undone by + any later edit. Passing nothing keeps the old behaviour. :param on_conflict: Optional callable, invoked as (kind, detail) whenever something had to be decided rather than simply done: 'discarded_time' when a time entry went with a deleted @@ -210,7 +215,7 @@ def apply_ops(document, ops, on_conflict=None): report.highest_seq = max(report.highest_seq, seq) kind = op.get("op") uid = op.get("uid") - when = op.get("ts") or op.get("start") or op.get("end") or "" + when = op.get("ts") or op.get("start") or op.get("end") or now or "" # An operation naming something already deleted is dropped. The one # exception is below: it is about time entries, and losing tracked @@ -370,7 +375,7 @@ def apply_ops(document, ops, on_conflict=None): return report -def reconcile(document, incoming, local=None, on_conflict=None): +def reconcile(document, incoming, local=None, on_conflict=None, now=None): """ One merge: what came from elsewhere, then this machine's own unsent work. @@ -401,7 +406,7 @@ def reconcile(document, incoming, local=None, on_conflict=None): order is already the order the server will give them. :return: A Report covering both passes. """ - report = apply_ops(document, incoming, on_conflict=on_conflict) + report = apply_ops(document, incoming, on_conflict=on_conflict, now=now) if not local: return report @@ -412,7 +417,7 @@ def reconcile(document, incoming, local=None, on_conflict=None): op['s'] = floor + position replay.append(op) - second = apply_ops(document, replay, on_conflict=on_conflict) + second = apply_ops(document, replay, on_conflict=on_conflict, now=now) report.applied += second.applied report.ignored += second.ignored report.discarded_time += second.discarded_time @@ -425,10 +430,18 @@ def seed_operations(document): """ Describes an existing document as the operations that would build it. - Used once, by whichever machine reaches an empty server first. Everything - after that is incremental; this is the only time the whole document is - sent, and it is sent as operations rather than as a file so the server - never has to understand the format. + Used by whichever machine reaches an empty server first, and again by any + machine that has to re-introduce itself - after synchronisation was + switched off for a while, say. Everything in between is incremental; this + is the only time the whole document is sent, and it goes as operations + rather than as a file so the server never has to understand the format. + + Each object is described twice: created, then set. The create is what an + unfamiliar machine needs, and it does nothing on one that already has the + object - which is precisely the case this has to work for. Without the + set, a machine re-introducing itself would announce objects the others + already know and silently fail to pass on everything that changed about + them while it was not talking. The order matters and is the same order the app itself would have produced: a project before its tasks, a task before its time. @@ -437,30 +450,30 @@ def seed_operations(document): for project in document.get("projects", []): if not project.get("uid"): continue - ops.append({ - 'op': 'project.create', - 'uid': project["uid"], - 'f': {'name': project.get("main_project_name", ""), + fields = {'name': project.get("main_project_name", ""), 'status': project.get("status", "open"), - 'last_started': project.get("last_started")}, - }) + 'last_started': project.get("last_started")} + ops.append({'op': 'project.create', 'uid': project["uid"], 'f': fields}) + ops.append({'op': 'project.set', 'uid': project["uid"], 'f': fields}) for task in project.get("tasks", []): if not task.get("uid"): continue - ops.append({ - 'op': 'task.create', - 'uid': task["uid"], - 'project': project["uid"], - 'f': {k: task.get(k) for k in sorted(TASK_FIELDS) if k in task}, - }) + fields = {k: task.get(k) for k in sorted(TASK_FIELDS) if k in task} + ops.append({'op': 'task.create', 'uid': task["uid"], + 'project': project["uid"], 'f': fields}) + ops.append({'op': 'task.set', 'uid': task["uid"], 'f': fields}) + # Where it sits now, in case it was moved while out of contact. + ops.append({'op': 'task.move', 'uid': task["uid"], + 'project': project["uid"]}) for entry in task.get("time_entries", []): if not entry.get("uid") or not entry.get("start_time"): continue ops.append({'op': 'entry.add', 'uid': entry["uid"], 'task': task["uid"], 'start': entry["start_time"]}) + times = {'start_time': entry["start_time"]} if entry.get("end_time"): - ops.append({'op': 'entry.close', 'uid': entry["uid"], - 'end': entry["end_time"]}) + times['end_time'] = entry["end_time"] + ops.append({'op': 'entry.set', 'uid': entry["uid"], 'f': times}) # Deletions travel too, or a machine that seeds from a document still # carrying tombstones would hand the others no way to know those objects diff --git a/tt/sync_engine.py b/tt/sync_engine.py index 45a599f..ffa80bb 100644 --- a/tt/sync_engine.py +++ b/tt/sync_engine.py @@ -43,6 +43,7 @@ import os import threading import time +from datetime import datetime from tt import sync_client from tt.filelock import locked, LockTimeout @@ -104,6 +105,8 @@ def _seed_lock_path(): 'failures': 0, # consecutive failures, for the backoff 'next_attempt': 0, # epoch seconds before which not to try again 'server_head': 0, # how far the log had got when last asked + 'account': None, # whose log base_seq is measured against + 'was_off': False, # synchronisation was switched off since the last offer } @@ -300,6 +303,46 @@ def _since(state): MAX_PAGES_PER_CYCLE = 40 +def _current_account(): + creds = sync_client.load_credentials() or {} + return creds.get('username') + + +def _log_is_not_the_one_we_know(state, head): + """ + Whether the cursor still refers to the log the server is answering from. + + Two ways it stops doing so, and both are quiet: + + The log is shorter than the position we hold. A log only grows, so a head + below our cursor means this is a different log - the account was + re-created, the store was wiped, or the server was rebuilt. Left alone, + every cycle asks for operations after a point the log will never reach, + the server rightly answers with nothing, and the machine reports success + for ever while sending and receiving nothing at all. + + The credential belongs to somebody else. Signing in to a second account + leaves a cursor measured against the first one's log. + + Both are rare, and both are indistinguishable from working correctly from + the outside, which is exactly why they are worth detecting rather than + leaving to be noticed months later. + """ + if head < int(state.get('base_seq', 0)): + return True + account = _current_account() + known = state.get('account') + return bool(account) and known is not None and account != known + + +def _drop_inbox_for_reset(): + """Fetched operations numbered against a log that is no longer there.""" + try: + clear_inbox() + except (OSError, LockTimeout): + pass + + def _run_cycle_locked(outbox): state = read_state() since = _since(state) @@ -311,6 +354,15 @@ def _run_cycle_locked(outbox): if not result.get('ok'): return _record_failure(result.get('error') or 'unreachable') + if _log_is_not_the_one_we_know(state, int(result.get('head', 0))): + # Start again from the beginning against this log. Everything below + # depends on `since` pointing into the same log the server is + # answering from, and it no longer does. + state = write_state({'base_seq': 0, 'seeded': False, + 'account': _current_account()}) + _drop_inbox_for_reset() + since = 0 + dups = [int(x) for x in (result.get('dups') or [])] seq_of = {int(lc): int(seq) for lc, seq in (result.get('assigned') or [])} head = int(result.get('head', 0)) @@ -385,6 +437,7 @@ def _run_cycle_locked(outbox): 'failures': 0, 'next_attempt': 0, 'server_head': max(head, reached), + 'account': _current_account(), }) return {'ok': True, 'sent': len(batch), 'received': len(incoming), 'more': truncated or len(sending) > len(batch)} @@ -465,6 +518,12 @@ def apply_pending(tracker): for entry_uid, end in report.auto_closed: tracker._emit('entry.close', uid=entry_uid, end=end) + # Stamped into the document as well as into the state file. The + # two travel differently: restoring data.json from a backup takes + # the document back but leaves the state file where it was, and + # the machine would then never re-fetch what the restored copy is + # missing. align_cursor() below reads this back. + tracker.data['_sync_seq'] = reached tracker._save_data() write_state({'base_seq': reached}, required=True) if settled: @@ -511,6 +570,37 @@ def _split_placed(queued, incoming): sorted(placed)) +def align_cursor(tracker): + """ + Brings the cursor back to what the document on disk actually contains. + + Call this on the drawing thread, before applying anything. + + The cursor lives in the state file, the document in data.json, and the two + can be separated: restoring data.json from a backup - or copying yesterday's + over today's - takes the document back while the state file stays where it + was. Everything after that point has already been marked as applied, so it + is never fetched again, and the restored copy silently stays missing + whatever it was missing. The interface reports a healthy sync throughout. + + So the document carries its own mark, and the lower of the two wins. Going + back over ground already covered costs nothing - applying an operation + twice is harmless by design - while going forward over a gap cannot be + undone. + + :return: The number of sequence positions given up, for the caller to log. + """ + stamped = tracker.data.get('_sync_seq') + if stamped is None: + return 0 + state = read_state() + behind = int(state.get('base_seq', 0)) - int(stamped) + if behind <= 0: + return 0 + write_state({'base_seq': int(stamped)}) + return behind + + def offer_document(tracker): """ Queues this machine's existing document the first time it reaches a server. @@ -646,7 +736,17 @@ def ensure_started(config=None): sync_cfg = config.get('sync') if isinstance(config, dict) else None if not isinstance(sync_cfg, dict) or not sync_cfg.get('enabled'): stop() + # Remember the gap. While the feature is off nothing is recorded - + # that is what off means - so the changes made in between exist + # nowhere but this machine. Switching it back on has to make the + # machine describe itself again, or those changes are silently + # absent from the other one for ever, and the other machine's + # version of the same objects quietly wins. + if not read_state().get('was_off'): + write_state({'was_off': True}) return False + if read_state().get('was_off'): + write_state({'was_off': False, 'seeded': False}) try: _interval_minutes = int(sync_cfg.get('interval_minutes') or DEFAULT_INTERVAL_MINUTES) From 25326c4da70a9bcf195b5b6c67cf0b86396df869 Mon Sep 17 00:00:00 2001 From: Frank Faulstich Date: Wed, 12 Aug 2026 19:52:40 +0200 Subject: [PATCH 3/4] Correction 2 --- ISSUES-sync.md | 22 +++++++++--------- tests/test_sync_client.py | 4 ++++ tests/test_sync_engine.py | 49 ++++++++++++++++++++++++++++++--------- 3 files changed, 53 insertions(+), 22 deletions(-) diff --git a/ISSUES-sync.md b/ISSUES-sync.md index 670b69d..c9bb551 100644 --- a/ISSUES-sync.md +++ b/ISSUES-sync.md @@ -201,22 +201,22 @@ selectively. --- -## 11. Sync: the Windows lock path and the frozen build are unverified +## 11. Sync: the frozen build has never been checked **Labels:** `task`, `sync`, `windows` -Two things have never been exercised on the platform they exist for: +`TimeControl.spec` gained the sync modules under `hiddenimports`, because +`sl/SL_Menu.py` is shipped as data and PyInstaller never scans data files for +imports. That fix has not been checked against an actual build. -- `tt/filelock.py`’s `msvcrt` branch is covered across processes but not - between two threads of one process — which is precisely what the sync worker - and the drawing thread do. The POSIX guarantee comes from `flock` attaching - to the open file description and does not transfer. -- `TimeControl.spec` gained the sync modules under `hiddenimports`, because - `sl/SL_Menu.py` is shipped as data and never scanned for imports. That fix - has not been checked against an actual PyInstaller build. +If the modules are missing, `SYNC_AVAILABLE` becomes `False` and the feature +silently disappears — the failure mode gives no clue at all. Worth building +once and confirming the settings section is there. -If the sync modules are missing from a build, `SYNC_AVAILABLE` becomes `False` -and the feature silently disappears — the failure mode gives no clue. +*(The other half of this — whether `tt/filelock.py`’s `msvcrt` branch excludes +two threads of one process, which is what the sync worker and the drawing +thread do — is now answered: `test_two_threads_of_one_process_exclude_each_other` +runs on the Windows CI job and passes.)* --- diff --git a/tests/test_sync_client.py b/tests/test_sync_client.py index 862115f..5cc3b2d 100644 --- a/tests/test_sync_client.py +++ b/tests/test_sync_client.py @@ -127,6 +127,10 @@ def test_plain_http_is_refused_before_the_password_is_sent(self): self.assertEqual(result['error'], 'https_required') post.assert_not_called() + @unittest.skipIf(os.name == 'nt', + "chmod only toggles the read-only bit on Windows; what " + "keeps the file private there is the ACL on the user's " + "own profile directory, which this cannot assert on") def test_credential_file_is_owner_only_on_posix(self): with patch('tt.sync_client.requests.post', return_value=_Response({'ok': True, 'token': 't'})): sync_client.login('https://x.de/tc', 'frank', 'pw') diff --git a/tests/test_sync_engine.py b/tests/test_sync_engine.py index b0b38a8..743c8aa 100644 --- a/tests/test_sync_engine.py +++ b/tests/test_sync_engine.py @@ -1,3 +1,4 @@ +import contextlib import os import shutil import sys @@ -1059,35 +1060,61 @@ def tearDown(self): os.remove(self.DATA) super().tearDown() + @contextlib.contextmanager + def _writes_fail(self): + """ + A full disk, or a folder that has gone read-only. + + Injected at the last step of the atomic write rather than by taking + the permissions off the directory: chmod does not stop a write on + Windows, where the test would then pass for the wrong reason - and + did, until CI said so. + """ + real = os.replace + + def refuse(src, dst, *a, **k): + if str(dst).startswith(self.tmp): + raise OSError(28, "No space left on device") + return real(src, dst, *a, **k) + + os.replace = refuse + try: + yield + finally: + os.replace = real + def test_state_that_cannot_be_written_is_shrugged_off_by_default(self): """ When the cycle ran and what went wrong are a convenience. Losing them costs a line on the settings screen, so it must not cost the sync. """ - os.chmod(self.tmp, 0o500) - try: + with self._writes_fail(): state = sync_engine.write_state({'last_error': 'unreachable'}) - finally: - os.chmod(self.tmp, 0o700) self.assertIsInstance(state, dict) def test_but_the_cursor_refuses_to_fail_quietly(self): - os.chmod(self.tmp, 0o500) - try: + with self._writes_fail(): with self.assertRaises(OSError): sync_engine.write_state({'base_seq': 5}, required=True) - finally: - os.chmod(self.tmp, 0o700) def test_a_cycle_that_cannot_write_locally_says_so(self): self.queue('project.create', uid=P1, f={'name': 'P'}) - os.chmod(self.tmp, 0o500) + self.server.add_foreign('project.create', uid='c' * 16, f={'name': 'C'}) + + real_append = sync_engine._append_inbox + + def refuse(record): + raise OSError(28, "No space left on device") + sync_engine._append_inbox = refuse try: result = sync_engine.run_cycle(self.outbox) finally: - os.chmod(self.tmp, 0o700) + sync_engine._append_inbox = real_append + self.assertFalse(result['ok']) - self.assertIn(result['error'], ('local_io', 'unreachable')) + self.assertEqual(result['error'], 'local_io') + self.assertEqual(len(self.outbox.pending()), 1, + "the change was dropped although nothing was recorded") def test_a_damaged_inbox_line_costs_that_line_and_no_more(self): self.server.add_foreign('project.create', uid=P1, f={'name': 'Remote'}) From c567130f02aa3fa1177f8455624e8ac50f52e3c8 Mon Sep 17 00:00:00 2001 From: Frank Faulstich Date: Wed, 12 Aug 2026 20:01:19 +0200 Subject: [PATCH 4/4] Correction 3 --- tests/test_TimeTracker.py | 70 +++++++++++++++++++++++++++++++++++++++ tt/TimeTracker.py | 39 +++++++++++++++++++++- 2 files changed, 108 insertions(+), 1 deletion(-) diff --git a/tests/test_TimeTracker.py b/tests/test_TimeTracker.py index 333a807..2331f8a 100644 --- a/tests/test_TimeTracker.py +++ b/tests/test_TimeTracker.py @@ -1338,6 +1338,76 @@ def test_start_work_reorders_projects(self): self.tracker.start_work("P2", "S3") self.assertEqual([p['main_project_name'] for p in self.tracker.data['projects']], ["P2", "P1"]) + def test_ordering_holds_when_the_clock_cannot_tell_two_starts_apart(self): + """ + `datetime.now()` resolves to about 16 milliseconds on Windows, so two + starts in quick succession land on the same value there and the + most-recently-used order comes out backwards - the older one stays in + front. Frozen here so the property is checked everywhere, not only on + the platform coarse enough to expose it. + """ + from datetime import datetime as _real + import tt.TimeTracker as module + + frozen = _real(2026, 8, 12, 17, 54, 6, 534224) + + class Stopped(_real): + @classmethod + def now(cls, tz=None): + return frozen + + self.tracker.add_main_project("P1") + self.tracker.add_task("P1", "T1") + self.tracker.add_main_project("P2") + self.tracker.add_task("P2", "T2") + + original = module.datetime + module.datetime = Stopped + try: + self.tracker.start_work("P1", "T1") + self.tracker.start_work("P2", "T2") + finally: + module.datetime = original + + self.assertEqual([p['main_project_name'] for p in self.tracker.data['projects']], + ["P2", "P1"], + "the more recent start did not come first") + stamps = [p['last_started'] for p in self.tracker.data['projects']] + self.assertGreater(stamps[0], stamps[1], + "two starts share a timestamp, so nothing orders them") + self.assertIn("end_time", + self.tracker._get_task("P1", "T1")['time_entries'][0], + "the previous session was left running") + + def test_a_clock_that_steps_backwards_does_not_reorder_the_past(self): + """An NTP correction or a daylight-saving change moves it back.""" + from datetime import datetime as _real, timedelta as _delta + import tt.TimeTracker as module + + self.tracker.add_main_project("P1") + self.tracker.add_task("P1", "T1") + self.tracker.add_main_project("P2") + self.tracker.add_task("P2", "T2") + self.tracker.start_work("P1", "T1") + + earlier = _real.now() - _delta(hours=2) + + class WoundBack(_real): + @classmethod + def now(cls, tz=None): + return earlier + + original = module.datetime + module.datetime = WoundBack + try: + self.tracker.start_work("P2", "T2") + finally: + module.datetime = original + + self.assertEqual([p['main_project_name'] for p in self.tracker.data['projects']], + ["P2", "P1"], + "the later start was filed behind the earlier one") + def test_start_work_records_last_started(self): """Starting work stamps the task and its project, not just their position.""" self.tracker.add_main_project("P1") diff --git a/tt/TimeTracker.py b/tt/TimeTracker.py index 2759184..91ff5c0 100644 --- a/tt/TimeTracker.py +++ b/tt/TimeTracker.py @@ -1432,6 +1432,43 @@ def demote_main_project(self, main_project_to_demote_name, new_parent_main_proje self._save_data() return True, _("Main project '{demoted_name}' was demoted to a sub-project under '{parent_name}'.").format(demoted_name=main_project_to_demote_name, parent_name=new_parent_main_project_name) + def _next_started_at(self): + """ + A start timestamp that is strictly later than every one recorded. + + Most-recently-used ordering is derived by sorting on `last_started`, + so two stamps that are equal leave the order undecided - and a stable + sort then keeps the older one in front, which is exactly backwards. + + That is not hypothetical. `datetime.now()` resolves to about 16 + milliseconds on Windows, so two starts in quick succession - a click + followed by another, or the GUI and an MCP call - genuinely land on + the same value there. The clock can also step backwards, over a + daylight-saving change or an NTP correction. + + So the wall clock is used when it is ahead of everything on record, + and nudged past the highest stamp when it is not. The result is still + a real timestamp a person can read; it is only ever adjusted by + microseconds, and it goes back to following the clock as soon as the + clock has caught up. + """ + now = datetime.now() + highest = None + for project in self.data.get("projects", []): + for value in [project.get("last_started")] + \ + [t.get("last_started") for t in project.get("tasks", [])]: + if value and (highest is None or value > highest): + highest = value + if highest is None: + return now.isoformat() + try: + latest = datetime.fromisoformat(highest) + except (TypeError, ValueError): + return now.isoformat() + if now > latest: + return now.isoformat() + return (latest + timedelta(microseconds=1)).isoformat() + @staticmethod def _sort_by_last_started(items): """ @@ -1476,7 +1513,7 @@ def start_work(self, main_project_name, task_name=None, task_id=None): # Add the new time entry. The uid is what lets a specific entry be # referred to at all - "the last element of some array" stops # meaning anything once two machines hold their own copy. - started_at = datetime.now().isoformat() + started_at = self._next_started_at() new_entry = { "uid": _new_uid(), "start_time": started_at