Files
agendula/scripts/make_import_fixture.py
T
Jean-Luc Makiolaandmakiolaj aa2aade930
Beta — Codeberg pre-release / detect (push) Successful in 6s
Beta — Codeberg pre-release / beta (push) Skipped
feat: tags (#35) (#46)
Adds tags, stored as the standard `CATEGORIES` property so they stay compatible with Nextcloud Tasks, tasks.org and DAVx⁵.

## What changed

- **Storage and sync:** tags are a new `categories` column (database v2). The migration lifts `CATEGORIES` out of the stored unknown properties for tasks that were synced before, so existing tags show up without a re-download. A line Agendula can't write back exactly as it came (e.g. a duplicated value, or the property split across several lines) stays where it was and still syncs byte for byte.
- **Provider mode (OpenTasks / tasks.org):** tags are read and written as the provider's category properties. Writes are a single batch, so a refused tag can't leave half the tags behind or create a task twice.
- **Imports:** the copy from OpenTasks/tasks.org and the legacy one-shot import both bring tags across.
- **Editor:** a new optional Tags field. Type a tag and press Done or type a comma; tags already in use are suggested while typing, and text left in the field is still saved.
- **Display:** tags show as pills on the detail screen and in each row's info line in task lists.
- **Using tags:** the home screen has a Tags section listing every tag on an open task with its count. Tapping one opens that tag's tasks across all lists, and "New task" there starts with the tag set. Every task list's sort button is now a "Sort and filter" picker, where you can tick one or more tags to narrow the list.

## Also in here

- **Keyboard:** a focused input now scrolls fully above the keyboard, not just its text line. This uses floret-kit's new `revealOnFocus` (jlmakiola/floret-kit#12), and the pointer moves to floret-kit main.
- **Edit screen:** once the title scrolls out of view, it shows in the top bar.
- **main merged in:** the branch brings main's #40 and #44 (floret-kit on its shared main, Gradle 9.8), which `release/v1.1.0` doesn't have yet.

## Where this differs from the issue

- **Filtering by several tags** shows tasks that carry *all* of them, since ticking more tags is meant to narrow the list.
- **Tags are case-sensitive:** "Work" and "work" are two tags, matching how they are stored and synced.
- **The overview** lists tags on open tasks only, the same set the editor suggests.
- **Widgets** can't be set to a tag yet.

Closes #35

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/46
2026-10-09 12:20:21 +02:00

143 lines
6.4 KiB
Python

#!/usr/bin/env python3
"""Build the dmfs `tasks.db` fixture the one-shot import is tested against.
The provider is deleted, so the import cannot
keep a real v0.3.x database around by asking the provider to create one. This
writes the schema the provider's TaskDatabaseHelper produces at DATABASE_VERSION
23 — tables `Lists`, `Tasks`, `Properties` only, which are the three the import
reads — and seeds a spread that covers what the import has to get right.
Regenerate with: python3 scripts/make_import_fixture.py
"""
from __future__ import annotations
import os
import sqlite3
import sys
OUT = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"app/src/androidTest/assets/tasks-v23.db",
)
DDL = [
"""CREATE TABLE Lists (
_id INTEGER PRIMARY KEY AUTOINCREMENT,
account_name TEXT, account_type TEXT, list_name TEXT, list_color INTEGER,
list_access_level INTEGER, visible INTEGER, sync_enabled INTEGER,
list_owner TEXT, _dirty INTEGER DEFAULT 0, _sync_id TEXT,
sync_version TEXT, sync1 TEXT, sync2 TEXT, sync3 TEXT, sync4 TEXT,
sync5 TEXT, sync6 TEXT, sync7 TEXT, sync8 TEXT)""",
"""CREATE TABLE Tasks (
_id INTEGER PRIMARY KEY AUTOINCREMENT, version INTEGER DEFAULT 0,
list_id INTEGER NOT NULL, title TEXT, location TEXT, geo TEXT,
description TEXT, url TEXT, organizer TEXT, priority INTEGER,
task_color INTEGER, class INTEGER, completed INTEGER,
completed_is_allday INTEGER, percent_complete INTEGER,
status INTEGER DEFAULT 0, is_new INTEGER, is_closed INTEGER,
dtstart INTEGER, created INTEGER, last_modified INTEGER,
is_allday INTEGER, tz TEXT, due INTEGER, duration TEXT, rdate TEXT,
exdate TEXT, rrule TEXT, parent_id INTEGER, sorting TEXT,
has_alarms INTEGER, has_properties INTEGER, pinned INTEGER,
original_instance_sync_id TEXT, original_instance_id INTEGER,
original_instance_time INTEGER, original_instance_allday INTEGER,
_dirty INTEGER DEFAULT 1, _deleted INTEGER DEFAULT 0, _sync_id TEXT,
_uid TEXT, sync_version TEXT, sync1 TEXT, sync2 TEXT, sync3 TEXT,
sync4 TEXT, sync5 TEXT, sync6 TEXT, sync7 TEXT, sync8 TEXT)""",
"""CREATE TABLE Properties (
property_id INTEGER PRIMARY KEY AUTOINCREMENT, task_id INTEGER,
mimetype INTEGER, prop_version INTEGER,
data0 TEXT, data1 TEXT, data2 TEXT, data3 TEXT, data4 TEXT, data5 TEXT,
data6 TEXT, data7 TEXT, data8 TEXT, data9 TEXT, data10 TEXT,
data11 TEXT, data12 TEXT, data13 TEXT, data14 TEXT, data15 TEXT,
prop_sync1 TEXT, prop_sync2 TEXT, prop_sync3 TEXT, prop_sync4 TEXT,
prop_sync5 TEXT, prop_sync6 TEXT, prop_sync7 TEXT, prop_sync8 TEXT)""",
]
LOCAL = ("Local", "org.dmfs.account.LOCAL")
CALDAV = ("me@example.org", "bitfire.at.davdroid")
ALARM_MIMETYPE = "vnd.android.cursor.item/alarm"
# 2026-01-15T09:00:00Z, and a day in millis.
T0 = 1_768_467_600_000
DAY = 86_400_000
def seed(db: sqlite3.Connection) -> None:
lists = db.executemany(
"INSERT INTO Lists (_id, account_name, account_type, list_name, list_color,"
" visible, sync_enabled, list_owner) VALUES (?,?,?,?,?,?,?,?)",
[
(1, *LOCAL, "Personal", 0xFF7A5C6B, 1, 1, None),
(2, *LOCAL, "Hidden list", 0xFF445566, 0, 1, None),
# An external account inside our own authority: only reachable if the
# user pointed DAVx5 at us. Imported as a local list, UID preserved.
(3, *CALDAV, "Work", 0xFF2244AA, 1, 1, "Me"),
],
)
del lists
def task(**kw):
cols = ", ".join(kw)
marks = ", ".join("?" * len(kw))
db.execute(f"INSERT INTO Tasks ({cols}) VALUES ({marks})", tuple(kw.values()))
task(_id=1, list_id=1, title="Buy milk", due=T0 + DAY, status=0,
_uid="a1b2c3d4-0000-4000-8000-000000000001", created=T0, last_modified=T0)
# No UID: the import mints one.
task(_id=2, list_id=1, title="Call the dentist", due=T0 + 2 * DAY, status=1,
percent_complete=40, created=T0, last_modified=T0)
task(_id=3, list_id=1, title="Gather receipts", parent_id=1, status=0,
_uid="a1b2c3d4-0000-4000-8000-000000000003", created=T0, last_modified=T0)
task(_id=4, list_id=1, title="Renew domain", status=2, percent_complete=100,
completed=T0 - DAY, is_closed=1,
_uid="a1b2c3d4-0000-4000-8000-000000000004", created=T0, last_modified=T0)
task(_id=5, list_id=1, title="Water the plants", dtstart=T0, due=T0 + 3600_000,
rrule="FREQ=WEEKLY;BYDAY=MO,TH", tz="Europe/Berlin",
_uid="a1b2c3d4-0000-4000-8000-000000000005", created=T0, last_modified=T0)
task(_id=6, list_id=1, title="Team offsite", dtstart=T0 - T0 % DAY,
due=T0 - T0 % DAY + DAY, is_allday=1,
_uid="a1b2c3d4-0000-4000-8000-000000000006", created=T0, last_modified=T0)
# Deleted-but-unsynced: gone as far as the user is concerned, so not imported.
task(_id=7, list_id=1, title="Cancelled thing", _deleted=1,
_uid="a1b2c3d4-0000-4000-8000-000000000007", created=T0, last_modified=T0)
task(_id=8, list_id=2, title="Task in a hidden list", status=0,
_uid="a1b2c3d4-0000-4000-8000-000000000008", created=T0, last_modified=T0)
task(_id=9, list_id=3, title="Ship the release", due=T0 + 5 * DAY, status=0,
_uid="a1b2c3d4-0000-4000-8000-000000000009", created=T0, last_modified=T0,
_sync_id="https://dav.example.org/tasks/9.ics")
db.executemany(
"INSERT INTO Properties (property_id, task_id, mimetype, data0, data1, data2, data3)"
" VALUES (?,?,?,?,?,?,?)",
[
# data0 minutes before, data1 reference (1 = DUE), data3 alarm type.
(1, 1, ALARM_MIMETYPE, "30", "1", None, "1"),
(2, 9, ALARM_MIMETYPE, "1440", "1", "Ship it", "1"),
# A category: data0 the provider's category id, data1 the name. A tag, not an alarm.
(3, 1, "vnd.android.cursor.item/category", "1", "Errands", None, None),
],
)
def main() -> int:
os.makedirs(os.path.dirname(OUT), exist_ok=True)
if os.path.exists(OUT):
os.remove(OUT)
db = sqlite3.connect(OUT)
try:
for statement in DDL:
db.execute(statement)
db.execute("PRAGMA user_version = 23")
seed(db)
db.commit()
finally:
db.close()
print(f"wrote {OUT}")
return 0
if __name__ == "__main__":
sys.exit(main())