django-dg-core 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. datagroceries/core/__init__.py +0 -0
  2. datagroceries/core/api.py +30 -0
  3. datagroceries/core/apps.py +17 -0
  4. datagroceries/core/areas.py +39 -0
  5. datagroceries/core/attachments.py +92 -0
  6. datagroceries/core/authentication.py +128 -0
  7. datagroceries/core/backlinks.py +40 -0
  8. datagroceries/core/checks.py +118 -0
  9. datagroceries/core/history.py +20 -0
  10. datagroceries/core/locale/nl/LC_MESSAGES/django.mo +0 -0
  11. datagroceries/core/locale/nl/LC_MESSAGES/django.po +119 -0
  12. datagroceries/core/locale/nl/LC_MESSAGES/djangojs.mo +0 -0
  13. datagroceries/core/locale/nl/LC_MESSAGES/djangojs.po +62 -0
  14. datagroceries/core/markdown.py +258 -0
  15. datagroceries/core/mcp.py +178 -0
  16. datagroceries/core/mentions.py +66 -0
  17. datagroceries/core/notifications.py +150 -0
  18. datagroceries/core/people.py +190 -0
  19. datagroceries/core/static/dg_core/apple-touch-icon-180.png +0 -0
  20. datagroceries/core/static/dg_core/datagroceries.css +12 -0
  21. datagroceries/core/static/dg_core/editor.css +33 -0
  22. datagroceries/core/static/dg_core/editor.js +245 -0
  23. datagroceries/core/static/dg_core/favicon-32.png +0 -0
  24. datagroceries/core/static/dg_core/favicon.svg +1 -0
  25. datagroceries/core/static/dg_core/vendor/bootstrap/LICENSE +21 -0
  26. datagroceries/core/static/dg_core/vendor/bootstrap/bootstrap.bundle.min.js +6 -0
  27. datagroceries/core/static/dg_core/vendor/bootstrap/bootstrap.min.css +5 -0
  28. datagroceries/core/static/dg_core/vendor/bootstrap-icons/LICENSE +21 -0
  29. datagroceries/core/static/dg_core/vendor/bootstrap-icons/bootstrap-icons.min.css +5 -0
  30. datagroceries/core/static/dg_core/vendor/bootstrap-icons/fonts/bootstrap-icons.woff +0 -0
  31. datagroceries/core/static/dg_core/vendor/bootstrap-icons/fonts/bootstrap-icons.woff2 +0 -0
  32. datagroceries/core/static/dg_core/vendor/easymde/LICENSE +22 -0
  33. datagroceries/core/static/dg_core/vendor/easymde/easymde.min.css +7 -0
  34. datagroceries/core/static/dg_core/vendor/easymde/easymde.min.js +7 -0
  35. datagroceries/core/templates/dg_core/_editor_assets.html +9 -0
  36. datagroceries/core/templates/dg_core/_markdown_help.html +31 -0
  37. datagroceries/core/templates/dg_core/base.html +60 -0
  38. datagroceries/core/templatetags/__init__.py +0 -0
  39. datagroceries/core/templatetags/dg_core.py +80 -0
  40. datagroceries/core/tests/__init__.py +56 -0
  41. datagroceries/core/tests/test_apps.py +35 -0
  42. datagroceries/core/tests/test_authentication.py +109 -0
  43. datagroceries/core/tests/test_checks.py +40 -0
  44. datagroceries/core/tests/test_languages.py +48 -0
  45. datagroceries/core/tests/test_markdown.py +136 -0
  46. datagroceries/core/tests/test_notifications.py +104 -0
  47. datagroceries/core/tests/test_people.py +115 -0
  48. datagroceries/core/urls.py +27 -0
  49. datagroceries/urls.py +19 -0
  50. django_dg_core-1.0.0.dist-info/METADATA +64 -0
  51. django_dg_core-1.0.0.dist-info/RECORD +53 -0
  52. django_dg_core-1.0.0.dist-info/WHEEL +4 -0
  53. django_dg_core-1.0.0.dist-info/licenses/LICENSE +21 -0
File without changes
@@ -0,0 +1,30 @@
1
+ """What the DataGroceries apps' REST APIs share: checking a request's body, a page form's errors as the API's, and
2
+ a person as the APIs show them."""
3
+
4
+ from rest_framework.exceptions import ValidationError
5
+
6
+ from datagroceries.core import people
7
+
8
+
9
+ def validated(serializer_class, request, **kwargs) -> dict:
10
+ """The request body, checked by `serializer_class`, refusing fields it does not know."""
11
+ serializer = serializer_class(data=request.data, **kwargs)
12
+ unknown = sorted(set(request.data) - set(serializer.fields))
13
+ if unknown:
14
+ raise ValidationError({'unknown_fields': unknown})
15
+ serializer.is_valid(raise_exception=True)
16
+ return serializer.validated_data
17
+
18
+
19
+ def form_errors(form) -> ValidationError:
20
+ """A page form's errors as a 400; errors not tied to a field come under "detail"."""
21
+ errors = form.errors.get_json_data()
22
+ return ValidationError(
23
+ {('detail' if name == '__all__' else name): [e['message'] for e in field] for name, field in errors.items()},
24
+ )
25
+
26
+
27
+ def person(user) -> dict | None:
28
+ """A person as the APIs show them: by their handle, as @-mentions name them, with their email address
29
+ beside it when they have one."""
30
+ return {'name': people.name(user), 'handle': people.handle(user), 'email': people.email(user)} if user else None
@@ -0,0 +1,17 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class CoreConfig(AppConfig):
5
+ name = 'datagroceries.core'
6
+ label = 'dg_core'
7
+ verbose_name = 'DataGroceries'
8
+ default_auto_field = 'django.db.models.BigAutoField'
9
+
10
+ def ready(self):
11
+ from datagroceries.core import (
12
+ checks, # noqa: F401 - registers the system checks
13
+ markdown,
14
+ )
15
+ from datagroceries.core.mentions import Mentions
16
+
17
+ markdown.register(Mentions)
@@ -0,0 +1,39 @@
1
+ """The areas of the DataGroceries apps that agents reach: one per app, such as the board.
2
+
3
+ An app registers its area in its AppConfig.ready(). An area is a token scope, the Django permission
4
+ that lets someone use it (datagroceries.core.people), a sentence for the agent about it, and its MCP tools; and,
5
+ for the switch between the apps at the top of every page, the URL name of its start page and an icon. A
6
+ token opens an area only while its user still may use it, so losing that right ends it on every token
7
+ at once.
8
+ """
9
+
10
+ import dataclasses
11
+
12
+ from django.core.exceptions import ImproperlyConfigured
13
+
14
+ from datagroceries.core import people
15
+
16
+
17
+ @dataclasses.dataclass(frozen=True)
18
+ class Area:
19
+ scope: str
20
+ label: str
21
+ permission: str
22
+ instructions: str
23
+ tools: tuple = ()
24
+ home: str = ''
25
+ icon: str = ''
26
+
27
+
28
+ AREAS: dict[str, Area] = {}
29
+
30
+
31
+ def register(area: Area):
32
+ if area.scope in AREAS:
33
+ raise ImproperlyConfigured(f'The area {area.scope!r} is registered twice.')
34
+ AREAS[area.scope] = area
35
+
36
+
37
+ def scopes_for(user) -> list[str]:
38
+ """The scopes `user` may use now, in the order their apps registered them."""
39
+ return [scope for scope in AREAS if people.may_use(user, scope)]
@@ -0,0 +1,92 @@
1
+ """Files people attach, as every DataGroceries app keeps them: each app has a table of its own, on `Attachment`,
2
+ saying what a file belongs to, and serves the files with `serve`, only to those who may use the app.
3
+
4
+ Images pasted or dropped into the Markdown editor go to the app's upload address, which answers with
5
+ `editor_upload`.
6
+ """
7
+
8
+ import pathlib
9
+
10
+ from django.conf import settings
11
+ from django.db import models
12
+ from django.http import FileResponse, JsonResponse
13
+ from django.utils import timezone
14
+ from django.utils.text import slugify
15
+
16
+ MAX_BYTES = 20 * 1024 * 1024
17
+ # What is shown in the page; anything else is a download.
18
+ INLINE_CONTENT_TYPES = ('image/png', 'image/jpeg', 'image/gif', 'image/webp')
19
+
20
+
21
+ def upload_to(attachment, filename: str) -> str:
22
+ return f'{attachment._meta.app_label}/{timezone.now():%Y/%m}/{filename}'
23
+
24
+
25
+ class Attachment(models.Model):
26
+ """A file, in the default storage. The app serves it only to its people (`serve`), so the storage must
27
+ not publish it at an address of its own."""
28
+
29
+ INLINE_CONTENT_TYPES = INLINE_CONTENT_TYPES
30
+
31
+ file = models.FileField(upload_to=upload_to)
32
+ name = models.CharField(max_length=255)
33
+ content_type = models.CharField(max_length=100)
34
+ size = models.PositiveBigIntegerField()
35
+ uploaded_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='+')
36
+ uploaded_at = models.DateTimeField(auto_now_add=True)
37
+
38
+ class Meta:
39
+ abstract = True
40
+ ordering = ('uploaded_at', 'id')
41
+
42
+ def __str__(self):
43
+ return self.name
44
+
45
+ @property
46
+ def is_image(self) -> bool:
47
+ return self.content_type in INLINE_CONTENT_TYPES
48
+
49
+ @property
50
+ def url_name(self) -> str:
51
+ """The file's name for the end of its address. The Markdown editor inserts an upload as an image only
52
+ when its address ends in an image extension; slugified, so no space or bracket breaks a link."""
53
+ path = pathlib.PurePath(self.name)
54
+ return f'{slugify(path.stem) or "file"}{path.suffix.lower()}'
55
+
56
+ @classmethod
57
+ def from_upload(cls, upload, user, **fields):
58
+ """A new attachment of the uploaded file, by `user`, with the app's own `fields`."""
59
+ return cls.objects.create(
60
+ file=upload,
61
+ name=upload.name,
62
+ content_type=upload.content_type or 'application/octet-stream',
63
+ size=upload.size,
64
+ uploaded_by=user,
65
+ **fields,
66
+ )
67
+
68
+
69
+ def serve(attachment: Attachment) -> FileResponse:
70
+ """The file. Only known image types are shown inline; anything else (HTML, SVG, ...) is a download, so
71
+ an uploaded file can never run as a page on this site."""
72
+ response = FileResponse(
73
+ attachment.file.open('rb'),
74
+ content_type=attachment.content_type if attachment.is_image else 'application/octet-stream',
75
+ as_attachment=not attachment.is_image,
76
+ filename=attachment.name,
77
+ )
78
+ response['X-Content-Type-Options'] = 'nosniff'
79
+ return response
80
+
81
+
82
+ def editor_upload(request, create) -> JsonResponse:
83
+ """The answer to an image the Markdown editor uploads, in the form EasyMDE expects. `create(upload)`
84
+ keeps the image and returns its Attachment."""
85
+ upload = request.FILES.get('image')
86
+ if upload is None:
87
+ return JsonResponse({'error': 'noFileGiven'}, status=400)
88
+ if upload.size > MAX_BYTES:
89
+ return JsonResponse({'error': 'fileTooLarge'}, status=400)
90
+ if upload.content_type not in INLINE_CONTENT_TYPES:
91
+ return JsonResponse({'error': 'typeNotAllowed'}, status=400)
92
+ return JsonResponse({'data': {'filePath': create(upload).get_absolute_url()}})
@@ -0,0 +1,128 @@
1
+ """How a program gets into the DataGroceries apps' API and MCP endpoint. Core checks credentials and never issues
2
+ them (docs/decisions.md); every way in lands on the same check, `opens`.
3
+
4
+ The ways in, in order:
5
+
6
+ 1. Those of DataGroceries packages that issue tokens, such as django-dg-connect's OAuth for the Claude
7
+ connector and its personal tokens: they `register` here, as apps register their areas.
8
+ 2. The host project's own, the DRF authentication classes listed in `DG_AUTHENTICATION_CLASSES`:
9
+ its own tokens, JWT, anything DRF can check.
10
+
11
+ Whatever let a request in, it reaches an area only while its user may use it, and only if the token,
12
+ when it carries scopes, covers it. A request let in without credentials gets a 401 that points at the
13
+ protected resource metadata (RFC 9728), which names the authorization server, so an MCP client given
14
+ only the endpoint's address finds where to sign in.
15
+ """
16
+
17
+ from collections.abc import Callable
18
+
19
+ from django.conf import settings
20
+ from django.dispatch import receiver
21
+ from django.http import Http404, JsonResponse
22
+ from django.urls import reverse
23
+ from django.utils import translation
24
+ from django.utils.module_loading import import_string
25
+ from rest_framework.authentication import BaseAuthentication
26
+ from rest_framework.permissions import BasePermission
27
+ from simple_history.models import HistoricalRecords
28
+ from simple_history.signals import pre_create_historical_record
29
+
30
+ from datagroceries.core import areas, history
31
+
32
+ # What DataGroceries packages that issue tokens registered (`register`).
33
+ _ways_in: list[type] = []
34
+ _authorization_servers: list[Callable] = []
35
+
36
+
37
+ def register(authentication_class: type, authorization_server: Callable | None = None):
38
+ """For a DataGroceries package that issues tokens: its DRF authentication class, and a function that takes the
39
+ request and returns the URL of its authorization server, if it has one."""
40
+ _ways_in.append(authentication_class)
41
+ if authorization_server:
42
+ _authorization_servers.append(authorization_server)
43
+
44
+
45
+ def authorization_server(request) -> str | None:
46
+ """Where an MCP client signs in: the project's own authorization server, `DG_AUTHORIZATION_SERVER`,
47
+ or a registered one."""
48
+ if url := getattr(settings, 'DG_AUTHORIZATION_SERVER', None):
49
+ return url
50
+ return _authorization_servers[0](request) if _authorization_servers else None
51
+
52
+
53
+ class NoWayIn(BaseAuthentication):
54
+ """Lets nobody in. Always last, so that without any other way in a request still gets a 401 that says
55
+ where to sign in, not DRF's 403."""
56
+
57
+ def authenticate(self, request):
58
+ return None
59
+
60
+
61
+ class DataGroceriesAuthentication:
62
+ """For an APIView: every way in, and a 401 that says where to sign in."""
63
+
64
+ def get_authenticators(self):
65
+ extra = getattr(settings, 'DG_AUTHENTICATION_CLASSES', ())
66
+ return [*(cls() for cls in _ways_in), *(import_string(path)() for path in extra), NoWayIn()]
67
+
68
+ def dispatch(self, request, *args, **kwargs):
69
+ # In English whatever the project's language: the API and the tools are for programs and agents,
70
+ # and its error messages come from the same forms and models as the pages'.
71
+ with history.recording(request), translation.override('en'):
72
+ return super().dispatch(request, *args, **kwargs)
73
+
74
+ def get_authenticate_header(self, request):
75
+ if authorization_server(request):
76
+ return f'Bearer resource_metadata="{request.build_absolute_uri(reverse("dg_core:resource_metadata"))}"'
77
+ return 'Bearer'
78
+
79
+
80
+ def opens(request, scope: str) -> bool:
81
+ """Whether the request reaches the area `scope`: its user may use it, and the token, if it carries a
82
+ `scope` (space-separated area names, as OAuth tokens and DataGroceries' personal tokens have), covers it."""
83
+ if not request.user.is_authenticated or scope not in areas.scopes_for(request.user):
84
+ return False
85
+ # A page's request, signed in with a session, has no `auth`, and so no token to narrow it.
86
+ granted = getattr(getattr(request, 'auth', None), 'scope', None)
87
+ return not isinstance(granted, str) or scope in granted.split()
88
+
89
+
90
+ class HasTokenScope(BasePermission):
91
+ """Lets in requests that reach the view's area, `token_scope` (opens)."""
92
+
93
+ message = 'This token does not open this part of the API, or you no longer have access to it.'
94
+
95
+ def has_permission(self, request, view): # type: ignore[override]
96
+ return opens(request, view.token_scope)
97
+
98
+
99
+ def resource_metadata(request):
100
+ """The protected resource metadata of the MCP endpoint (RFC 9728): what it is, and where to sign in
101
+ for it. There is none while nothing issues tokens for the Claude connector."""
102
+ server = authorization_server(request)
103
+ if not server:
104
+ raise Http404('No authorization server.')
105
+ return JsonResponse(
106
+ {
107
+ 'resource': request.build_absolute_uri(reverse('dg_core:mcp')),
108
+ 'authorization_servers': [server],
109
+ 'scopes_supported': list(areas.AREAS),
110
+ 'bearer_methods_supported': ['header'],
111
+ 'resource_name': 'DataGroceries',
112
+ }
113
+ )
114
+
115
+
116
+ @receiver(pre_create_historical_record)
117
+ def mark_token_changes(sender, history_instance, **kwargs):
118
+ """Changes made with a token say so in their history: "via Claude Code on my laptop" for a token with a
119
+ name, "via Claude" for an OAuth token, after its app, or "via API" for a token of the project's own.
120
+
121
+ DRF sets the token on the Django request, which datagroceries.core.history keeps for the request.
122
+ """
123
+ request = getattr(HistoricalRecords.context, 'request', None)
124
+ token = getattr(request, 'auth', None)
125
+ if token is not None and not history_instance.history_change_reason:
126
+ application = getattr(token, 'application', None)
127
+ name = getattr(token, 'name', None) or getattr(application, 'name', None) or 'API'
128
+ history_instance.history_change_reason = f'via {name}'
@@ -0,0 +1,40 @@
1
+ """What names something of one app in another app's text: the wiki pages that name a ticket, shown on the
2
+ ticket. An app that keeps text registers a `Source` from its AppConfig.ready(); an app shows what names
3
+ one of its things with `find`, so neither knows the other.
4
+ """
5
+
6
+ import dataclasses
7
+ import typing
8
+
9
+ from datagroceries.core.authentication import opens
10
+
11
+
12
+ @dataclasses.dataclass(frozen=True)
13
+ class Backlink:
14
+ title: str
15
+ url: str
16
+
17
+
18
+ @dataclasses.dataclass(frozen=True)
19
+ class Source:
20
+ """Text of the area `scope`. `find(name)` gives the places in it that name `name` as a word, such as
21
+ "T-123", outside code; `label` says what they are, as "Wiki pages"."""
22
+
23
+ scope: str
24
+ label: str
25
+ find: typing.Callable[[str], list[Backlink]]
26
+
27
+
28
+ SOURCES: list[Source] = []
29
+
30
+
31
+ def register(source: Source):
32
+ if source not in SOURCES:
33
+ SOURCES.append(source)
34
+
35
+
36
+ def find(request, name: str) -> list[tuple[Source, list[Backlink]]]:
37
+ """The places that name `name`, in the areas the request reaches (its user may use them, and its token,
38
+ if any, opens them), by source; sources without any are left out."""
39
+ found = [(source, source.find(name)) for source in SOURCES if opens(request, source.scope)]
40
+ return [(source, places) for source, places in found if places]
@@ -0,0 +1,118 @@
1
+ """System checks: what `manage.py check` tells whoever installs DataGroceries is still missing. Each message says
2
+ what to do."""
3
+
4
+ from django.conf import settings
5
+ from django.contrib.auth.models import Permission
6
+ from django.core import checks
7
+ from django.core.exceptions import ImproperlyConfigured
8
+ from django.db import DatabaseError
9
+ from django.db.models import Q
10
+ from django.shortcuts import resolve_url
11
+ from django.urls import NoReverseMatch, Resolver404, resolve, reverse
12
+
13
+ from datagroceries.core import areas, notifications
14
+
15
+ REQUIRED_APPS = ('rest_framework', 'simple_history')
16
+ CONTEXT_PROCESSORS = (
17
+ 'django.template.context_processors.request',
18
+ 'django.contrib.auth.context_processors.auth',
19
+ 'django.contrib.messages.context_processors.messages',
20
+ )
21
+
22
+
23
+ @checks.register
24
+ def check_apps(app_configs, **kwargs):
25
+ return [
26
+ checks.Error(
27
+ f"DataGroceries needs '{app}' in INSTALLED_APPS.", hint=f"Add '{app}' to INSTALLED_APPS.", id='dg.E001'
28
+ )
29
+ for app in REQUIRED_APPS
30
+ if app not in settings.INSTALLED_APPS
31
+ ]
32
+
33
+
34
+ @checks.register
35
+ def check_templates(app_configs, **kwargs):
36
+ processors = {
37
+ processor
38
+ for engine in settings.TEMPLATES
39
+ if engine['BACKEND'] == 'django.template.backends.django.DjangoTemplates'
40
+ for processor in engine.get('OPTIONS', {}).get('context_processors', [])
41
+ }
42
+ return [
43
+ checks.Error(
44
+ f"The DataGroceries pages need the context processor '{processor}'.",
45
+ hint="Add it to OPTIONS['context_processors'] of the DjangoTemplates engine in TEMPLATES.",
46
+ id='dg.E002',
47
+ )
48
+ for processor in CONTEXT_PROCESSORS
49
+ if processor not in processors
50
+ ]
51
+
52
+
53
+ @checks.register
54
+ def check_urls(app_configs, **kwargs):
55
+ try:
56
+ reverse('dg_core:mcp')
57
+ except NoReverseMatch:
58
+ return [
59
+ checks.Warning(
60
+ "DataGroceries' pages and its MCP endpoint are not in the URLs.",
61
+ hint="Add path('dg/', include('datagroceries.urls')) to the project's urlpatterns.",
62
+ id='dg.W001',
63
+ )
64
+ ]
65
+ try:
66
+ resolve(resolve_url(settings.LOGIN_URL))
67
+ except (Resolver404, NoReverseMatch):
68
+ return [
69
+ checks.Warning(
70
+ f'LOGIN_URL ({settings.LOGIN_URL}) leads nowhere, so people who are not signed in cannot sign in '
71
+ 'to the DataGroceries pages.',
72
+ hint="Set LOGIN_URL to the project's sign-in page, such as 'admin:login'.",
73
+ id='dg.W002',
74
+ )
75
+ ]
76
+ return []
77
+
78
+
79
+ @checks.register
80
+ def check_people(app_configs, **kwargs):
81
+ """An app that only superusers may use was most likely installed a moment ago."""
82
+ found = []
83
+ for area in areas.AREAS.values():
84
+ app_label, codename = area.permission.split('.')
85
+ try:
86
+ granted = (
87
+ Permission.objects.filter(content_type__app_label=app_label, codename=codename)
88
+ .filter(Q(group__isnull=False) | Q(user__isnull=False))
89
+ .exists()
90
+ )
91
+ except DatabaseError: # not migrated yet
92
+ return []
93
+ if not granted:
94
+ found.append(
95
+ checks.Warning(
96
+ f'Only superusers may use {area.label} yet.',
97
+ hint=f'Grant the permission "{area.permission}" to a group in the admin, and put people in it.',
98
+ id='dg.W003',
99
+ )
100
+ )
101
+ return found
102
+
103
+
104
+ @checks.register
105
+ def check_notifications(app_configs, **kwargs):
106
+ try:
107
+ order = notifications.channels()
108
+ except ImproperlyConfigured as error:
109
+ return [checks.Error(str(error), hint="For example ['email'], or ['slack', 'email'].", id='dg.E003')]
110
+ if 'slack' in order and not getattr(settings, 'DG_SLACK_BOT_TOKEN', ''):
111
+ return [
112
+ checks.Error(
113
+ 'DG_NOTIFICATIONS includes Slack, but DG_SLACK_BOT_TOKEN is not set.',
114
+ hint='Set it to the token of a Slack bot with the scopes users:read.email and chat:write.',
115
+ id='dg.E004',
116
+ )
117
+ ]
118
+ return []
@@ -0,0 +1,20 @@
1
+ """Who made a change, for django-simple-history, without its middleware: the DataGroceries views say which
2
+ request they serve, so the project need not add `HistoryRequestMiddleware`. Where it has, that comes
3
+ first and this does nothing."""
4
+
5
+ import contextlib
6
+
7
+ from simple_history.models import HistoricalRecords
8
+
9
+
10
+ @contextlib.contextmanager
11
+ def recording(request):
12
+ """While open, changes are recorded as made by `request.user`, and through its token (request.auth)."""
13
+ if getattr(HistoricalRecords.context, 'request', None) is not None:
14
+ yield
15
+ return
16
+ HistoricalRecords.context.request = request
17
+ try:
18
+ yield
19
+ finally:
20
+ del HistoricalRecords.context.request
@@ -0,0 +1,119 @@
1
+ # SOME DESCRIPTIVE TITLE.
2
+ # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
3
+ # This file is distributed under the same license as the PACKAGE package.
4
+ # FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
5
+ #
6
+ msgid ""
7
+ msgstr ""
8
+ "Project-Id-Version: PACKAGE VERSION\n"
9
+ "Report-Msgid-Bugs-To: \n"
10
+ "POT-Creation-Date: 2026-11-07 12:02+0100\n"
11
+ "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
12
+ "Last-Translator: DataGroceries\n"
13
+ "Language-Team: Dutch\n"
14
+ "Language: nl\n"
15
+ "MIME-Version: 1.0\n"
16
+ "Content-Type: text/plain; charset=UTF-8\n"
17
+ "Content-Transfer-Encoding: 8bit\n"
18
+ "Plural-Forms: nplurals=2; plural=(n != 1);\n"
19
+
20
+ #: mentions.py:56
21
+ msgid "Type the start of a name to mention a colleague, who hears about it."
22
+ msgstr ""
23
+ "Typ het begin van een naam om een collega te noemen; die krijgt er bericht "
24
+ "van."
25
+
26
+ #: templates/dg_core/_markdown_help.html:7
27
+ #: templates/dg_core/_markdown_help.html:8
28
+ msgid "bold"
29
+ msgstr "vet"
30
+
31
+ #: templates/dg_core/_markdown_help.html:7
32
+ #: templates/dg_core/_markdown_help.html:8
33
+ msgid "italic"
34
+ msgstr "cursief"
35
+
36
+ #: templates/dg_core/_markdown_help.html:7
37
+ #: templates/dg_core/_markdown_help.html:8
38
+ msgid "struck"
39
+ msgstr "doorgestreept"
40
+
41
+ #: templates/dg_core/_markdown_help.html:9
42
+ msgid "Heading"
43
+ msgstr "Kop"
44
+
45
+ #: templates/dg_core/_markdown_help.html:10
46
+ msgid "A heading, and a smaller one under it."
47
+ msgstr "Een kop, en een kleinere eronder."
48
+
49
+ #: templates/dg_core/_markdown_help.html:12
50
+ msgid ""
51
+ "A list, or a numbered one. Two spaces before the dash put an item under the "
52
+ "one above."
53
+ msgstr ""
54
+ "Een lijst, of een genummerde. Twee spaties voor het streepje zetten een punt "
55
+ "onder het punt erboven."
56
+
57
+ #: templates/dg_core/_markdown_help.html:13
58
+ msgid "A list of things to do, with checkboxes."
59
+ msgstr "Een lijst van dingen om te doen, met vinkjes."
60
+
61
+ #: templates/dg_core/_markdown_help.html:14
62
+ msgid "A quote."
63
+ msgstr "Een citaat."
64
+
65
+ #: templates/dg_core/_markdown_help.html:16
66
+ msgid ""
67
+ "Code in a sentence; a line of three backticks above and below a block of "
68
+ "code."
69
+ msgstr ""
70
+ "Code in een zin; een regel van drie backticks boven en onder een blok code."
71
+
72
+ #: templates/dg_core/_markdown_help.html:17
73
+ msgid "words"
74
+ msgstr "woorden"
75
+
76
+ #: templates/dg_core/_markdown_help.html:18
77
+ msgid "A link. An address on its own becomes a link too."
78
+ msgstr "Een link. Een los adres wordt ook een link."
79
+
80
+ #: templates/dg_core/_markdown_help.html:19
81
+ msgid "Name"
82
+ msgstr "Naam"
83
+
84
+ #: templates/dg_core/_markdown_help.html:19
85
+ msgid "Role"
86
+ msgstr "Rol"
87
+
88
+ #: templates/dg_core/_markdown_help.html:20
89
+ msgid "A table: a row per line, the second line under the heads."
90
+ msgstr "Een tabel: een rij per regel, de tweede regel onder de koppen."
91
+
92
+ #: templates/dg_core/_markdown_help.html:21
93
+ msgid "description"
94
+ msgstr "beschrijving"
95
+
96
+ #: templates/dg_core/_markdown_help.html:22
97
+ msgid ""
98
+ "An image: paste or drop one into the text, and it is uploaded and put in."
99
+ msgstr ""
100
+ "Een afbeelding: plak of sleep er een in de tekst, dan wordt hij geüpload en "
101
+ "ingevoegd."
102
+
103
+ #: templates/dg_core/_markdown_help.html:23
104
+ msgid "Enter"
105
+ msgstr "Enter"
106
+
107
+ #: templates/dg_core/_markdown_help.html:24
108
+ msgid "A new line stays a new line; an empty line starts a new paragraph."
109
+ msgstr ""
110
+ "Een nieuwe regel blijft een nieuwe regel; een lege regel begint een nieuwe "
111
+ "alinea."
112
+
113
+ #: templates/dg_core/base.html:26
114
+ msgid "Apps"
115
+ msgstr "Apps"
116
+
117
+ #: templates/dg_core/base.html:38
118
+ msgid "Language"
119
+ msgstr "Taal"
@@ -0,0 +1,62 @@
1
+ # SOME DESCRIPTIVE TITLE.
2
+ # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
3
+ # This file is distributed under the same license as the PACKAGE package.
4
+ # FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
5
+ #
6
+ msgid ""
7
+ msgstr ""
8
+ "Project-Id-Version: PACKAGE VERSION\n"
9
+ "Report-Msgid-Bugs-To: \n"
10
+ "POT-Creation-Date: 2026-11-07 10:21+0100\n"
11
+ "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
12
+ "Last-Translator: DataGroceries\n"
13
+ "Language-Team: Dutch\n"
14
+ "Language: nl\n"
15
+ "MIME-Version: 1.0\n"
16
+ "Content-Type: text/plain; charset=UTF-8\n"
17
+ "Content-Transfer-Encoding: 8bit\n"
18
+ "Plural-Forms: nplurals=2; plural=(n != 1);\n"
19
+
20
+ #: static/dg_core/editor.js:187
21
+ msgid "Bold"
22
+ msgstr "Vet"
23
+
24
+ #: static/dg_core/editor.js:188
25
+ msgid "Italic"
26
+ msgstr "Cursief"
27
+
28
+ #: static/dg_core/editor.js:189
29
+ msgid "Heading"
30
+ msgstr "Kop"
31
+
32
+ #: static/dg_core/editor.js:191
33
+ msgid "Quote"
34
+ msgstr "Citaat"
35
+
36
+ #: static/dg_core/editor.js:192
37
+ msgid "List"
38
+ msgstr "Lijst"
39
+
40
+ #: static/dg_core/editor.js:193
41
+ msgid "Numbered list"
42
+ msgstr "Genummerde lijst"
43
+
44
+ #: static/dg_core/editor.js:194
45
+ msgid "Code"
46
+ msgstr "Code"
47
+
48
+ #: static/dg_core/editor.js:195
49
+ msgid "Link"
50
+ msgstr "Link"
51
+
52
+ #: static/dg_core/editor.js:197
53
+ msgid "Image"
54
+ msgstr "Afbeelding"
55
+
56
+ #: static/dg_core/editor.js:200
57
+ msgid "Preview"
58
+ msgstr "Voorbeeld"
59
+
60
+ #: static/dg_core/editor.js:206
61
+ msgid "How to write"
62
+ msgstr "Zo schrijf je"