diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b0b9886 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +MY2DOS_API_URL=https://my2dos.rucki.ch +MY2DOS_API_KEY=replace-with-api-key diff --git a/AGENTS.md b/AGENTS.md index 22922a2..426568f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,6 +60,31 @@ Einzelne Testklasse ausführen: .venv/bin/python manage.py test core.tests.TestKlasse ``` +## Zugriff auf produktive Aufgaben +- Produktive Aufgaben liegen unter `https://my2dos.rucki.ch` und können über die JSON-API abgerufen werden. +- Lokale Zugangsdaten stehen in `.env` und dürfen nicht committed oder ausgegeben werden: + - `MY2DOS_API_URL` + - `MY2DOS_API_KEY` +- API-Authentifizierung: Header `X-Api-Key: $MY2DOS_API_KEY` oder `Authorization: Bearer $MY2DOS_API_KEY`. +- Offene Remote-Aufgaben anzeigen: + +```bash +./scripts/my2dos_remote_tasks.py list +``` + +- Aufgabe als erledigt markieren: + +```bash +./scripts/my2dos_remote_tasks.py done +``` + +- Direkt per curl abrufen: + +```bash +source .env +curl -H "X-Api-Key: $MY2DOS_API_KEY" "$MY2DOS_API_URL/api/items/?limit=500" +``` + ## Arbeitsregeln für Coding-Agenten - Vor Änderungen relevante Dateien lesen. - Änderungen möglichst klein und gezielt halten. diff --git a/core/forms.py b/core/forms.py index fc8e5bf..f352a76 100644 --- a/core/forms.py +++ b/core/forms.py @@ -156,19 +156,26 @@ class CollectionForm(forms.ModelForm): def clean(self): cleaned = super().clean() - if cleaned.get('mode') == Collection.Mode.TAGS and not cleaned.get('filter_tags'): - self.add_error('filter_tags', _('Select at least one tag for a tag collection.')) + if cleaned.get('mode') == Collection.Mode.TAGS: + if not cleaned.get('filter_tags'): + self.add_error('filter_tags', _('Select at least one tag for a tag collection.')) + if not any(cleaned.get(field) for field in ['include_notes', 'include_todos', 'include_links', 'include_journal']): + self.add_error('include_notes', _('Select at least one entry type for a tag collection.')) return cleaned class Meta: model = Collection - fields = ['title', 'description', 'visibility', 'mode', 'team', 'filter_tags'] + fields = ['title', 'description', 'visibility', 'mode', 'team', 'filter_tags', 'include_notes', 'include_todos', 'include_links', 'include_journal'] labels = { 'title': _('Title'), 'description': _('Description'), 'visibility': _('Visibility'), 'mode': _('Collection type'), 'filter_tags': _('Content tags'), + 'include_notes': _('Notes'), + 'include_todos': _('Tasks'), + 'include_links': _('Links'), + 'include_journal': _('Journal'), } widgets = { 'title': forms.TextInput(attrs={'class': 'form-control'}), @@ -176,6 +183,10 @@ class CollectionForm(forms.ModelForm): 'visibility': forms.Select(attrs={'class': 'form-select'}), 'mode': forms.Select(attrs={'class': 'form-select', 'x-model': 'mode'}), 'filter_tags': forms.CheckboxSelectMultiple(), + 'include_notes': forms.CheckboxInput(attrs={'class': 'form-check-input'}), + 'include_todos': forms.CheckboxInput(attrs={'class': 'form-check-input'}), + 'include_links': forms.CheckboxInput(attrs={'class': 'form-check-input'}), + 'include_journal': forms.CheckboxInput(attrs={'class': 'form-check-input'}), } diff --git a/core/migrations/0021_collection_type_filters.py b/core/migrations/0021_collection_type_filters.py new file mode 100644 index 0000000..5b511fc --- /dev/null +++ b/core/migrations/0021_collection_type_filters.py @@ -0,0 +1,33 @@ +# Generated by Pi coding agent + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0020_collection_tutorial_modes'), + ] + + operations = [ + migrations.AddField( + model_name='collection', + name='include_notes', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='collection', + name='include_todos', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='collection', + name='include_links', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='collection', + name='include_journal', + field=models.BooleanField(default=False), + ), + ] diff --git a/core/models.py b/core/models.py index cb5af02..e583a95 100644 --- a/core/models.py +++ b/core/models.py @@ -215,6 +215,10 @@ class Collection(models.Model): visibility = models.CharField(max_length=10, choices=Item.Visibility.choices, default=Item.Visibility.PRIVATE) mode = models.CharField(max_length=10, choices=Mode.choices, default=Mode.MANUAL) filter_tags = models.ManyToManyField(Tag, blank=True, related_name='tag_collections') + include_notes = models.BooleanField(default=True) + include_todos = models.BooleanField(default=False) + include_links = models.BooleanField(default=False) + include_journal = models.BooleanField(default=False) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) @@ -227,6 +231,18 @@ class Collection(models.Model): def get_absolute_url(self): return reverse('collection_detail', args=[self.pk]) + def included_item_kinds(self): + kinds = [] + if self.include_notes: + kinds.append(Item.Kind.NOTE) + if self.include_todos: + kinds.append(Item.Kind.TODO) + if self.include_links: + kinds.append(Item.Kind.LINK) + if self.include_journal: + kinds.append(Item.Kind.JOURNAL) + return kinds or [Item.Kind.NOTE] + class CollectionSection(models.Model): collection = models.ForeignKey(Collection, on_delete=models.CASCADE, related_name='sections') diff --git a/core/templates/core/_edit_form.html b/core/templates/core/_edit_form.html index c6718ae..209767c 100644 --- a/core/templates/core/_edit_form.html +++ b/core/templates/core/_edit_form.html @@ -1,5 +1,5 @@ {% load i18n %} -
+ {% csrf_token %}
{% if form.errors %}
{{ form.errors }}
{% endif %} diff --git a/core/templates/core/collection_detail.html b/core/templates/core/collection_detail.html index d8f5042..3c11b71 100644 --- a/core/templates/core/collection_detail.html +++ b/core/templates/core/collection_detail.html @@ -27,7 +27,7 @@
{% if collection.mode == 'tags' %} - {% for item in tag_items %}
{{ item|collection_item_markdown }}{% if item.comment %}
{{ item.comment|markdown }}
{% endif %}
{% empty %}
{% trans "No notes match the selected tags yet." %}
{% endfor %} + {% for item in tag_items %}
{{ item|collection_item_markdown }}{% if item.comment %}
{{ item.comment|markdown }}
{% endif %}
{% empty %}
{% trans "No entries match the selected tags and types yet." %}
{% endfor %} {% else %} {% for section in sections %}{% include 'core/_collection_section_read.html' with section=section %}{% empty %}
{% trans "This collection has no sections yet." %}
{% endfor %} {% endif %} diff --git a/core/templates/core/collection_form.html b/core/templates/core/collection_form.html index a4d39c9..14f678c 100644 --- a/core/templates/core/collection_form.html +++ b/core/templates/core/collection_form.html @@ -13,7 +13,15 @@
{{ form.mode }}{{ form.mode.errors }}
{% trans "Tag collections update automatically and are sorted by note creation time." %}
{{ form.visibility }}{{ form.visibility.errors }}
{{ form.team }}{{ form.team.errors }}
{% trans "Optional. Selecting a team makes the collection visible and editable for that team." %}
-
{% if form.filter_tags.field.queryset.exists %}{{ form.filter_tags }}{% else %}{% trans "No content tags are available yet." %}{% endif %}
{{ form.filter_tags.errors }}
{% trans "Notes must contain all selected tags. Team tags are not content filters." %}
+
+
{% if form.filter_tags.field.queryset.exists %}{{ form.filter_tags }}{% else %}{% trans "No content tags are available yet." %}{% endif %}
{{ form.filter_tags.errors }}
{% trans "Entries must contain all selected tags. Team tags are not content filters." %}
+
+ + + + +
{{ form.include_notes.errors }}
+
{% trans "Cancel" %}
diff --git a/core/templates/core/index.html b/core/templates/core/index.html index f9a24e8..3acf5ea 100644 --- a/core/templates/core/index.html +++ b/core/templates/core/index.html @@ -111,6 +111,8 @@
{% csrf_token %} +
+
{% trans "Inline editor is open; quick entry is disabled." %}
@@ -130,6 +132,7 @@
+
{% for item in items %}{% include 'core/_item.html' %}{% empty %}
{% trans "Nothing here yet." %}
{% endfor %} @@ -150,12 +153,16 @@ function quickComposer(tags, itemId=null){ linkSuggestions:[], attachmentSuggestions:[], pendingFiles:[], + editingItem:false, itemId:itemId, tags:tags||[], init(){ document.body.addEventListener('htmx:afterRequest', (event) => { if(event.detail.successful && event.target === this.$el){ this.preview=null; } }); + document.body.addEventListener('htmx:afterSwap', () => { + this.editingItem=!!document.querySelector('.inline-edit-form'); + }); document.body.addEventListener('tagsChanged', () => { fetch('{% url "api_tags" %}').then(r=>r.json()).then(data=>{this.tags=data.tags||[]}); }); diff --git a/core/tests.py b/core/tests.py index 83ab3c1..dff5391 100644 --- a/core/tests.py +++ b/core/tests.py @@ -151,6 +151,31 @@ class MarkdownHeadingTests(TestCase): self.assertEqual(markdown_title('## Ziel von my2dos\n\nText'), 'Ziel von my2dos') +class QuickCreateFilterTests(TestCase): + def setUp(self): + self.user = get_user_model().objects.create_user(username='quick-filter', password='secret') + self.client.force_login(self.user) + + def test_active_tag_is_added_only_when_content_has_no_tags(self): + self.client.post(f'{reverse("index")}?tag=work', {'content': 'Task without tag'}) + first = Item.objects.get(content='Task without tag #work') + self.assertEqual(list(first.tags.values_list('name', flat=True)), ['work']) + + self.client.post(f'{reverse("index")}?tag=work', {'content': 'Task with own #other'}) + second = Item.objects.get(content='Task with own #other') + self.assertEqual(list(second.tags.values_list('name', flat=True)), ['other']) + + def test_hx_create_response_keeps_active_tag_marked(self): + response = self.client.post( + f'{reverse("index")}?tag=work', {'content': 'Task without tag'}, HTTP_HX_REQUEST='true', + ) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'hx-swap-oob="true"') + self.assertContains(response, '#work ×') + self.assertContains(response, 'text-bg-dark') + + class FormattingToolbarTests(TestCase): def test_toolbar_and_shortcuts_are_available(self): user = get_user_model().objects.create_user(username='formatting', password='secret') @@ -189,6 +214,31 @@ class NavigationAndLayoutTests(TestCase): self.assertLess(menu_position, toolbar_position) self.assertLess(toolbar_position, file_position) + def test_quick_create_is_disabled_while_inline_editor_is_open(self): + response = self.client.get(reverse('index')) + self.assertContains(response, ':disabled="editingItem"') + self.assertContains(response, 'inline-edit-form') + item = Item.objects.create(owner=self.user, content='Editable') + response = self.client.get(reverse('edit_item', args=[item.id])) + self.assertContains(response, 'inline-edit-form') + + def test_tag_filter_list_depends_on_current_filters(self): + open_todo = Item.objects.create(owner=self.user, kind=Item.Kind.TODO, content='Open #openonly') + open_todo.sync_metadata() + done_todo = Item.objects.create(owner=self.user, kind=Item.Kind.TODO, content='Done #archiveonly', is_done=True) + done_todo.sync_metadata() + note = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='Note #noteonly') + note.sync_metadata() + + response = self.client.get(f'{reverse("index")}?status=archive') + self.assertContains(response, '#archiveonly') + self.assertNotContains(response, '#openonly') + self.assertNotContains(response, '#noteonly') + + response = self.client.get(f'{reverse("tag_list")}?status=archive') + self.assertContains(response, '#archiveonly') + self.assertNotContains(response, '#openonly') + class AttachmentEditTests(TestCase): def setUp(self): @@ -500,6 +550,34 @@ class CollectionTests(TestCase): manage_response = self.client.get(reverse('collection_manage', args=[collection.id])) self.assertRedirects(manage_response, reverse('collection_edit', args=[collection.id])) + def test_tag_collection_can_filter_included_item_types(self): + note = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='Matching note #project') + note.sync_metadata() + todo = Item.objects.create(owner=self.user, kind=Item.Kind.TODO, content='Matching todo #project') + todo.sync_metadata() + link = Item.objects.create(owner=self.user, kind=Item.Kind.LINK, content='Matching link #project') + link.sync_metadata() + collection = Collection.objects.create( + owner=self.user, title='Typed tags', mode=Collection.Mode.TAGS, + include_notes=False, include_todos=True, include_links=False, include_journal=False, + ) + collection.filter_tags.add(Tag.objects.get(name='project')) + + response = self.client.get(collection.get_absolute_url()) + + self.assertContains(response, 'Matching todo') + self.assertNotContains(response, 'Matching note') + self.assertNotContains(response, 'Matching link') + + def test_tag_collection_form_offers_item_type_selection(self): + response = self.client.get(reverse('collection_create')) + + self.assertContains(response, 'Entry types') + self.assertContains(response, 'name="include_notes"') + self.assertContains(response, 'name="include_todos"') + self.assertContains(response, 'name="include_links"') + self.assertContains(response, 'name="include_journal"') + def test_collection_opens_as_continuous_reading_view(self): collection = Collection.objects.create(owner=self.user, title='Reading view') first = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='First paragraph') diff --git a/core/views.py b/core/views.py index 81fa8ac..27c919d 100644 --- a/core/views.py +++ b/core/views.py @@ -20,7 +20,7 @@ from django.shortcuts import get_object_or_404, redirect, render from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_http_methods, require_POST from .forms import ApiKeyForm, CollectionForm, EditItemForm, InviteRegistrationForm, KanbanForm, QuickItemForm, TeamForm, TeamInviteForm, UserInviteForm, UserPreferenceForm, UserSettingsForm -from .models import ApiKey, Attachment, Collection, CollectionSection, Item, Kanban, Tag, Team, TeamInvite, TeamMembership, UserInvite, UserPreference, without_markdown_code +from .models import ApiKey, Attachment, Collection, CollectionSection, Item, Kanban, Tag, Team, TeamInvite, TeamMembership, UserInvite, UserPreference, TAG_RE, without_markdown_code TYPE_COMMAND_RE = re.compile(r'^/(todo|note|link|journal)\b', re.I) VISIBILITY_COMMAND_RE = re.compile(r'/(public|private)\b', re.I) @@ -276,6 +276,46 @@ def api_auth_required(view): return wrapper +def index_filter_tags(request, user, prefs, active_tags=None): + active_tags = active_tags or [] + kind = request.GET.get('kind') + status = request.GET.get('status') + day = request.GET.get('day') + q = request.GET.get('q', '').strip() + items = visible_items(user) + applied_preferences = False + if kind in dict(Item.Kind.choices): + items = items.filter(kind=kind) + elif not any([active_tags, status, q, day]): + applied_preferences = True + type_filter = Q() + if prefs.show_notes: + type_filter |= Q(kind=Item.Kind.NOTE) + if prefs.show_links: + type_filter |= Q(kind=Item.Kind.LINK) + if prefs.show_journal: + type_filter |= Q(kind=Item.Kind.JOURNAL) + if prefs.show_open_todos: + type_filter |= Q(kind=Item.Kind.TODO, is_done=False) + if prefs.show_done_todos: + type_filter |= Q(kind=Item.Kind.TODO, is_done=True) + items = items.filter(type_filter) if type_filter else items.none() + if day: + items = items.filter(created_at__date=day) + if status == 'open': + items = items.filter(kind=Item.Kind.TODO, is_done=False) + elif status == 'due': + today_end = timezone.make_aware(datetime.combine(timezone.localdate(), time(23, 59, 59))) + items = items.filter(kind=Item.Kind.TODO, is_done=False, due_at__lte=today_end) + elif status == 'archive': + items = items.filter(kind=Item.Kind.TODO, is_done=True) + elif not applied_preferences: + items = items.exclude(kind=Item.Kind.TODO, is_done=True) + if q: + items = items.filter(Q(content__icontains=q) | Q(url__icontains=q) | Q(tags__name__icontains=q)).distinct() + return Tag.objects.filter(Q(items__in=items) | Q(name__in=active_tags)).distinct().order_by('name') + + def index(request): if not request.user.is_authenticated: if request.method == 'POST': @@ -289,14 +329,15 @@ def index(request): raw, due_at, _ = parse_due_command(raw) kind, visibility, content, url = parse_quick_content(raw, prefs.default_item_kind) active_tag = request.GET.get('tag') - if active_tag and f'#{active_tag.lower()}' not in content.lower(): - content = f'{content} #{active_tag}' + if active_tag and not TAG_RE.search(without_markdown_code(content)): + content = f'{content} #{active_tag.strip().lower()}' item = Item.objects.create(owner=request.user, kind=kind, visibility=visibility, content=content, url=url, due_at=due_at) attach_files_and_replace_tokens(item, request.FILES.getlist('files')) item.sync_metadata() apply_team_from_tags(item, request.user, force_private=bool(re.search(r'/private\b', raw, re.I))) if request.headers.get('HX-Request'): - response = render(request, 'core/_create_response.html', {'item': item, 'tags': Tag.objects.all(), 'active_tag': request.GET.get('tag')}) + active_tags = [t.strip().lower() for t in request.GET.getlist('tag') if t.strip()] + response = render(request, 'core/_create_response.html', {'item': item, 'tags': index_filter_tags(request, request.user, prefs, active_tags), 'active_tag': active_tags[0] if active_tags else None, 'active_tags': active_tags}) response['HX-Trigger'] = 'tagsChanged' return response return redirect('index') @@ -357,7 +398,7 @@ def index(request): default=Value(1), output_field=IntegerField(), )).order_by('due_rank', 'due_at', '-created_at') return render(request, 'core/index.html', { - 'form': QuickItemForm(), 'items': items, 'tags': Tag.objects.all(), 'active_tag': tag, 'active_tags': active_tags, 'active_tag_mode': active_tag_mode, 'active_kind': kind, 'active_status': status, 'active_day': day, 'q': q, 'due_count': due_count, 'overview_lines': prefs.overview_lines, + 'form': QuickItemForm(), 'items': items, 'tags': index_filter_tags(request, request.user, prefs, active_tags), 'active_tag': tag, 'active_tags': active_tags, 'active_tag_mode': active_tag_mode, 'active_kind': kind, 'active_status': status, 'active_day': day, 'q': q, 'due_count': due_count, 'overview_lines': prefs.overview_lines, }) @@ -661,7 +702,7 @@ def collection_target_from_form(form, user): def collection_tag_items(collection): - items = Item.objects.filter(kind=Item.Kind.NOTE).select_related('owner', 'team').prefetch_related('tags', 'attachments') + items = Item.objects.filter(kind__in=collection.included_item_kinds()).select_related('owner', 'team').prefetch_related('tags', 'attachments') if collection.visibility == Item.Visibility.PUBLIC: items = items.filter(visibility=Item.Visibility.PUBLIC) elif collection.visibility == Item.Visibility.TEAM: @@ -1120,9 +1161,10 @@ def kanban_move(request, pk, item_pk): @login_required def tag_list(request): + prefs, created = UserPreference.objects.get_or_create(user=request.user) active_tags = [t.strip().lower() for t in request.GET.getlist('tag') if t.strip()] active_tag_mode = 'all' if request.GET.get('tag_mode') == 'all' else 'any' - return render(request, 'core/_tags.html', {'tags': Tag.objects.all(), 'active_tag': active_tags[0] if active_tags else None, 'active_tags': active_tags, 'active_tag_mode': active_tag_mode}) + return render(request, 'core/_tags.html', {'tags': index_filter_tags(request, request.user, prefs, active_tags), 'active_tag': active_tags[0] if active_tags else None, 'active_tags': active_tags, 'active_tag_mode': active_tag_mode}) @login_required diff --git a/scripts/my2dos_remote_tasks.py b/scripts/my2dos_remote_tasks.py new file mode 100755 index 0000000..a4bd680 --- /dev/null +++ b/scripts/my2dos_remote_tasks.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""Fetch and update remote my2dos tasks via the JSON API. + +Configuration is read from environment variables or from .env in the project root: +- MY2DOS_API_URL, e.g. https://my2dos.rucki.ch +- MY2DOS_API_KEY, API key token +""" +from __future__ import annotations + +import argparse +import json +import os +import sys +from pathlib import Path +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.parse import urlencode +from urllib.request import Request, urlopen + + +ROOT = Path(__file__).resolve().parents[1] + + +def load_dotenv(path: Path = ROOT / ".env") -> None: + if not path.exists(): + return + for raw_line in path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'")) + + +def config() -> tuple[str, str]: + load_dotenv() + base_url = os.environ.get("MY2DOS_API_URL", "").rstrip("/") + api_key = os.environ.get("MY2DOS_API_KEY", "") + if not base_url or not api_key: + print("Missing MY2DOS_API_URL or MY2DOS_API_KEY. Configure them in .env.", file=sys.stderr) + sys.exit(2) + return base_url, api_key + + +def request_json(method: str, path: str, data: dict[str, Any] | None = None, query: dict[str, str] | None = None) -> Any: + base_url, api_key = config() + url = f"{base_url}{path}" + if query: + url = f"{url}?{urlencode(query)}" + body = json.dumps(data).encode("utf-8") if data is not None else None + request = Request( + url, + data=body, + method=method, + headers={"X-Api-Key": api_key, "Accept": "application/json", "Content-Type": "application/json"}, + ) + try: + with urlopen(request, timeout=20) as response: + return json.loads(response.read().decode("utf-8")) + except HTTPError as exc: + details = exc.read().decode("utf-8", errors="replace") + print(f"HTTP {exc.code} for {method} {url}: {details}", file=sys.stderr) + sys.exit(1) + except URLError as exc: + print(f"Request failed for {method} {url}: {exc}", file=sys.stderr) + sys.exit(1) + + +def list_tasks(args: argparse.Namespace) -> None: + payload = request_json("GET", "/api/items/", query={"limit": str(args.limit)}) + items = payload.get("items", []) + tasks = [item for item in items if item.get("kind") == "todo"] + if args.open: + tasks = [item for item in tasks if not item.get("is_done")] + for item in tasks: + status = "x" if item.get("is_done") else " " + due = f" due:{item['due_at'][:10]}" if item.get("due_at") else "" + tags = " ".join(f"#{tag}" for tag in item.get("tags", [])) + print(f"[{status}] #{item['id']}{due} {item.get('content', '').strip()} {tags}".rstrip()) + + +def mark_done(args: argparse.Namespace) -> None: + item = request_json("PATCH", f"/api/items/{args.id}/", data={"is_done": True}) + print(f"Done: #{item['id']} {item.get('content', '').strip()}") + + +def main() -> None: + parser = argparse.ArgumentParser(description="Work with remote my2dos tasks.") + subparsers = parser.add_subparsers(dest="command") + + list_parser = subparsers.add_parser("list", help="List remote todos") + list_parser.add_argument("--all", dest="open", action="store_false", help="include completed todos") + list_parser.add_argument("--limit", type=int, default=500) + list_parser.set_defaults(func=list_tasks, open=True) + + done_parser = subparsers.add_parser("done", help="Mark a todo as done") + done_parser.add_argument("id", type=int) + done_parser.set_defaults(func=mark_done) + + args = parser.parse_args() + if not hasattr(args, "func"): + args = parser.parse_args(["list"]) + args.func(args) + + +if __name__ == "__main__": + main()