diff --git a/Pipfile b/Pipfile index 65cf4750..3fccb522 100644 --- a/Pipfile +++ b/Pipfile @@ -22,6 +22,7 @@ python-dotenv = "*" pyyaml = "*" uritemplate = "*" whitenoise = "*" +django-taggit = "==2.1.0" [dev-packages] coveralls = "*" diff --git a/api/serializers.py b/api/serializers.py index eff30f91..73da3e1a 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -6,6 +6,8 @@ from rest_framework.exceptions import ValidationError from django.contrib.auth.models import User from django.utils import timezone +from taggit.serializers import TagListSerializerField, TaggitSerializer + from core import models @@ -125,10 +127,12 @@ class FeedingSerializer(CoreModelWithDurationSerializer): ) -class NoteSerializer(CoreModelSerializer): +class NoteSerializer(TaggitSerializer, CoreModelSerializer): class Meta: model = models.Note - fields = ("id", "child", "note", "time") + fields = ("id", "child", "note", "time", "tags") + + tags = TagListSerializerField(required=False) class SleepSerializer(CoreModelWithDurationSerializer): @@ -196,3 +200,14 @@ class BMISerializer(CoreModelSerializer): class Meta: model = models.BMI fields = ("id", "child", "bmi", "date", "notes") + + +class TagsSerializer(serializers.HyperlinkedModelSerializer): + class Meta: + model = models.Tag + fields = ("slug", "name", "color", "last_used") + extra_kwargs = { + "slug": {"required": False, "read_only": True}, + "color": {"required": False}, + "last_used": {"required": False, "read_only": True}, + } diff --git a/api/tests.py b/api/tests.py index f7f299c7..420b1da8 100644 --- a/api/tests.py +++ b/api/tests.py @@ -251,13 +251,14 @@ class NoteAPITestCase(TestBase.BabyBuddyAPITestCaseBase): def test_get(self): response = self.client.get(self.endpoint) self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual( + self.assertDictEqual( response.data["results"][0], { "id": 1, "child": 1, "note": "Fake note.", "time": "2017-11-17T22:45:00-05:00", + "tags": [], }, ) @@ -551,3 +552,56 @@ class WeightAPITestCase(TestBase.BabyBuddyAPITestCaseBase): ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data, entry) + + +class TagsAPITestCase(TestBase.BabyBuddyAPITestCaseBase): + endpoint = reverse("api:tag-list") + model = models.Tag + + def test_get(self): + response = self.client.get(self.endpoint) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertDictEqual( + dict(response.data["results"][0]), + { + "name": "a name", + "slug": "a-name", + "color": "#FF0000", + "last_used": "2017-11-18T11:00:00-05:00", + }, + ) + + def test_post(self): + data = {"name": "new tag", "color": "#123456"} + response = self.client.post(self.endpoint, data, format="json") + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + response = self.client.get(self.endpoint) + results = response.json()["results"] + results_by_name = {r["name"]: r for r in results} + + tag_data = results_by_name["new tag"] + self.assertDictContainsSubset(data, tag_data) + self.assertEqual(tag_data["slug"], "new-tag") + self.assertTrue(tag_data["last_used"]) + + def test_patch(self): + endpoint = f"{self.endpoint}a-name/" + + modified_data = { + "name": "A different name", + "color": "#567890", + } + response = self.client.patch( + endpoint, + modified_data, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertDictContainsSubset(modified_data, response.data) + + def test_delete(self): + endpoint = f"{self.endpoint}a-name/" + response = self.client.delete(endpoint) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + response = self.client.delete(endpoint) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) diff --git a/api/urls.py b/api/urls.py index e423c7d2..e27c86fa 100644 --- a/api/urls.py +++ b/api/urls.py @@ -18,6 +18,7 @@ router.register(r"weight", views.WeightViewSet) router.register(r"height", views.HeightViewSet) router.register(r"head-circumference", views.HeadCircumferenceViewSet) router.register(r"bmi", views.BMIViewSet) +router.register(r"tags", views.TagsViewSet) app_name = "api" diff --git a/api/views.py b/api/views.py index 5790c950..b65a2c1e 100644 --- a/api/views.py +++ b/api/views.py @@ -92,3 +92,10 @@ class BMIViewSet(viewsets.ModelViewSet): queryset = models.BMI.objects.all() serializer_class = serializers.BMISerializer filterset_fields = ("child", "date") + + +class TagsViewSet(viewsets.ModelViewSet): + queryset = models.Tag.objects.all() + serializer_class = serializers.TagsSerializer + lookup_field = "slug" + filterset_fields = ("last_used", "name") diff --git a/babybuddy/fixtures/tests.json b/babybuddy/fixtures/tests.json index 67cc57cc..183de75d 100644 --- a/babybuddy/fixtures/tests.json +++ b/babybuddy/fixtures/tests.json @@ -462,5 +462,16 @@ "date": "2017-11-18", "notes": "before feed" } + }, + { + "model": "core.tag", + "pk": 1, + "fields": + { + "name": "a name", + "slug": "a-name", + "color": "#FF0000", + "last_used": "2017-11-18T16:00:00Z" + } } -] \ No newline at end of file +] diff --git a/babybuddy/migrations/0021_alter_settings_language.py b/babybuddy/migrations/0021_alter_settings_language.py new file mode 100644 index 00000000..e02543be --- /dev/null +++ b/babybuddy/migrations/0021_alter_settings_language.py @@ -0,0 +1,37 @@ +# Generated by Django 4.0.3 on 2022-03-05 11:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("babybuddy", "0020_update_language_en_to_en_us"), + ] + + operations = [ + migrations.AlterField( + model_name="settings", + name="language", + field=models.CharField( + choices=[ + ("zh-hans", "Chinese (simplified)"), + ("nl", "Dutch"), + ("en-US", "English (US)"), + ("en-GB", "English (UK)"), + ("fr", "French"), + ("fi", "Finnish"), + ("de", "German"), + ("it", "Italian"), + ("pl", "Polish"), + ("pt", "Portuguese"), + ("es", "Spanish"), + ("sv", "Swedish"), + ("tr", "Turkish"), + ], + default="en-US", + max_length=255, + verbose_name="Language", + ), + ), + ] diff --git a/babybuddy/static_src/js/tags_editor.js b/babybuddy/static_src/js/tags_editor.js new file mode 100644 index 00000000..56be2277 --- /dev/null +++ b/babybuddy/static_src/js/tags_editor.js @@ -0,0 +1,354 @@ +(function() { + /** + * Parse a string as hexadecimal number + */ + function hexParse(x) { + return parseInt(x, 16); + } + + /** + * Get the contrasting color for any hex color + * + * Sourced from: https://vanillajstoolkit.com/helpers/getcontrast/ + * - Modified with slightly softer colors + * (c) 2021 Chris Ferdinandi, MIT License, https://gomakethings.com + * Derived from work by Brian Suda, https://24ways.org/2010/calculating-color-contrast/ + * @param {String} A hexcolor value + * @return {String} The contrasting color (black or white) + */ + function computeComplementaryColor(hexcolor) { + + // If a leading # is provided, remove it + if (hexcolor.slice(0, 1) === '#') { + hexcolor = hexcolor.slice(1); + } + + // If a three-character hexcode, make six-character + if (hexcolor.length === 3) { + hexcolor = hexcolor.split('').map(function (hex) { + return hex + hex; + }).join(''); + } + + // Convert to RGB value + let r = parseInt(hexcolor.substr(0,2),16); + let g = parseInt(hexcolor.substr(2,2),16); + let b = parseInt(hexcolor.substr(4,2),16); + + // Get YIQ ratio + let yiq = ((r * 299) + (g * 587) + (b * 114)) / 1000; + + // Check contrast + return (yiq >= 128) ? '#101010' : '#EFEFEF'; + } + + // CSRF token should always be present because it is auto-included with + // every tag-editor widget + const CSRF_TOKEN = document.querySelector('input[name="csrfmiddlewaretoken"]').value; + + function doReq(method, uri, data, success, fail) { + // TODO: prefer jQuery based requests for now + + const req = new XMLHttpRequest(); + req.addEventListener('load', () => { + if ((req.status >= 200) && (req.status < 300)) { + success(req.responseText, req); + } else { + fail(req.responseText, req); + } + }); + for (const name of ["error", "timeout", "abort"]) { + req.addEventListener(name, () => { + fail(req.responseText, req); + }); + } + req.timeout = 20000; + + req.open(method, uri); + req.setRequestHeader("Content-Type", "application/json"); + req.setRequestHeader("Accept", "application/json"); + req.setRequestHeader("X-CSRFTOKEN", CSRF_TOKEN); + req.send(data); + } + + /** + * Base class allowing generic operations on the tag lists, like: + * + * - Adding tags to a tag list + * - Updating or creating new tags with a set name and color + * - Controlling the error modal + */ + class TaggingBase { + constructor(widget) { + this.prototype = widget.querySelector('.prototype-tag'); + this.listeners = []; + + this.modalElement = widget.querySelector('.tag-editor-error-modal'); + this.modalBodyNode = this.modalElement.querySelector('.modal-body'); + + // Clean whitespace text nodes between spans + for (const n of this.modalBodyNode.childNodes) { + if (n.nodeType === Node.TEXT_NODE) { + this.modalBodyNode.removeChild(n); + } + } + } + + showModal(msg) { + const selectedMessage = this.modalBodyNode.querySelector(`span[data-message='${msg}']`); + if (!selectedMessage) { + selectedMessage = this.modalBodyNode.childNodes[0]; + } + + for (const n of this.modalBodyNode.childNodes) { + n.classList.add('d-none'); + } + selectedMessage.classList.remove('d-none'); + + jQuery(this.modalElement).modal('show'); + } + + addTagListUpdatedListener(c) { + this.listeners.push(c); + } + + callTagListUpdatedListeners() { + for (const l of this.listeners) { + l(); + } + } + + updateTag(tag, name, color, actionSymbol) { + const actionTextNode = tag.querySelector('.add-remove-icon').childNodes[0]; + + name = name || tag.getAttribute("data-value"); + color = color || tag.getAttribute("data-color"); + actionSymbol = actionSymbol || actionTextNode.textContent; + + tag.childNodes[0].textContent = name; + tag.setAttribute("data-value", name); + tag.setAttribute("data-color", color); + + const textColor = computeComplementaryColor(color); + tag.setAttribute('style', `background-color: ${color}; color: ${textColor};`); + actionTextNode.textContent = actionSymbol; + } + + createNewTag(name, color, actionSymbol) { + const tag = this.prototype.cloneNode(true); + tag.classList.remove("prototype-tag"); + tag.classList.add("tag"); + this.updateTag(tag, name, color, actionSymbol); + return tag; + } + + insertTag(list, tag) { + list.appendChild(tag); + this.callTagListUpdatedListeners(); + } + }; + + /** + * Handler for the edit field allowing to dynamically create new tags. + * + * Handles user inputs for the editor. Calls the 'onInsertNewTag' callback + * when the creation of a new tag has been requested. All backend handling + * like guareteening that the requested tag exists is handled by this class, + * the only task left is to add the new tag to the tags-list when + * 'onInsertNewTag' is called. + */ + class AddNewTagControl { + /** + * @param widget + * The root DOM element of the widget + * @param taggingBase + * Reference to a common TaggingBase class to be used by this widget + * @param onInsertNewTag + * Callback that is called when a new tag should be added to the + * tags widget. + */ + constructor(widget, taggingBase, onInsertNewTag) { + this.widget = widget; + this.taggingBase = taggingBase; + + this.apiTagsUrl = widget.getAttribute('data-tags-url'); + this.createTagInputs = widget.querySelector('.create-tag-inputs'); + this.addTagInput = this.createTagInputs.querySelector('input[type="text"]'); + this.addTagButton = this.createTagInputs.querySelector('.btn-add-new-tag'); + + this.addTagInput.value = ""; + + this.onInsertNewTag = onInsertNewTag; + + this.addTagButton.addEventListener('click', () => this.onCreateTagClicked()); + this.addTagInput.addEventListener('keydown', (e) => { + const key = e.key.toLowerCase(); + if (key === "enter") { + e.preventDefault(); + this.onCreateTagClicked(); + } + }); + } + + /** + * Callback called when the the "Add" button of the add-tag input is + * clicked or enter is pressed in the editor. + */ + onCreateTagClicked() { + // TODO: Make promise based + + const tagName = this.addTagInput.value.trim(); + const uriTagName = encodeURIComponent(tagName); + + const fail = (msg) => { + this.addTagInput.select(); + this.taggingBase.showModal(msg || "generic"); + }; + + if (!tagName) { + fail('invalid-tag-name'); + return; + } + + const addTag = (name, color) => { + const tag = this.taggingBase.createNewTag(name, color, "-"); + this.addTagInput.value = ""; + this.onInsertNewTag(tag); + }; + + const data = JSON.stringify({ + 'name': this.addTagInput.value + }); + + doReq("GET", `${this.apiTagsUrl}?name=${uriTagName}`, null, + (text) => { + const json = JSON.parse(text); + if (json.count) { + const tagJson = json.results[0]; + addTag(tagJson.name, tagJson.color); + } else { + doReq("POST", this.apiTagsUrl, data, + (text) => { + const tagJson = JSON.parse(text); + addTag(tagJson.name, tagJson.color); + }, () => fail("tag-creation-failed") + ); + } + }, () => fail("tag-checking-failed") + ); + } + }; + + /** + * JavaScript implementation for the tags editor. + * + * This class uses TaggingBase and AddNewTagControl to provide the custom + * tag editor controls. This mainly consists of updating the hidden + * input values with the current list of tags and adding/removing + * tags from the current-tags- or recently-used-lists. + */ + class TagsEditor { + /** + * @param tagEditorRoot + * The root DOM element of the widget. + */ + constructor(tagEditorRoot) { + this.widget = tagEditorRoot; + this.taggingBase = new TaggingBase(this.widget); + this.addTagControl = new AddNewTagControl( + this.widget, this.taggingBase, (t) => this.insertNewTag(t) + ); + + this.currentTags = this.widget.querySelector('.current_tags'); + this.newTags = this.widget.querySelector('.new-tags'); + this.inputElement = this.widget.querySelector('input[type="hidden"]'); + + for (const tag of this.newTags.querySelectorAll(".tag")) { + this.configureAddTag(tag); + } + for (const tag of this.currentTags.querySelectorAll(".tag")) { + this.configureRemoveTag(tag); + } + + this.updateInputList(); + this.taggingBase.addTagListUpdatedListener( + () => this.updateInputList() + ); + } + + /** + * Insert a new tag into the "current tag" list. + * + * Makes sure that no duplicates are present in the widget before adding + * the new tag. If a duplicate is found, the old tag is removed before + * the new one is added. + */ + insertNewTag(tag) { + const name = tag.getAttribute("data-value"); + + const oldTag = this.widget.querySelector(`span[data-value="${name}"]`); + if (oldTag) { + oldTag.parentNode.removeChild(oldTag); + } + + this.taggingBase.insertTag(this.currentTags, tag); + this.configureRemoveTag(tag); + } + + /** + * Registeres a click-callback for a given node. + * + * The callback chain-calls another callback "onClicked" after + * moving the clicked tag from the old tag-list to a new tag list. + */ + registerNewCallback(tag, newParent, onClicked) { + function callback(event) { + tag.parentNode.removeChild(tag); + this.taggingBase.insertTag(newParent, tag); + + tag.removeEventListener('click', callback); + onClicked(tag); + } + tag.addEventListener('click', callback.bind(this)); + } + + /** + * Updates the value of the hidden input element. + * + * Sets the value from the list of tags added to the currentTags + * DOM element. + */ + updateInputList() { + const names = []; + for (const tag of this.currentTags.querySelectorAll(".tag")) { + const name = tag.getAttribute("data-value"); + names.push(`"${name}"`); + } + this.inputElement.value = names.join(","); + } + + /** + * Configure a tag-DOM element as a "add tag" button. + */ + configureAddTag(tag) { + this.taggingBase.updateTag(tag, null, null, "+"); + this.registerNewCallback(tag, this.currentTags, () => this.configureRemoveTag(tag)); + this.updateInputList(); + } + + /** + * Configure a tag-DOM element as a "remove tag" button. + */ + configureRemoveTag(tag) { + this.taggingBase.updateTag(tag, null, null, "-"); + this.registerNewCallback(tag, this.newTags, () => this.configureAddTag(tag)); + this.updateInputList(); + } + }; + + window.addEventListener('load', () => { + for (const el of document.querySelectorAll('.babybuddy-tags-editor')) { + new TagsEditor(el); + } + }); +})(); \ No newline at end of file diff --git a/babybuddy/static_src/scss/global.scss b/babybuddy/static_src/scss/global.scss index 666ab6df..d4e83835 100644 --- a/babybuddy/static_src/scss/global.scss +++ b/babybuddy/static_src/scss/global.scss @@ -52,3 +52,8 @@ .icon-2x { font-size: 1.65em; } + +// All modals +.modal-content { + color: theme-color('dark'); +} \ No newline at end of file diff --git a/babybuddy/templates/babybuddy/form.html b/babybuddy/templates/babybuddy/form.html index 8768a4a8..91ff6ce3 100644 --- a/babybuddy/templates/babybuddy/form.html +++ b/babybuddy/templates/babybuddy/form.html @@ -1,9 +1,12 @@ {% load i18n widget_tweaks %} +{# Load any form-javascript files #} +{{ form.media.js }}
{% csrf_token %} {% for field in form %} + {{ field.widget }}
{% include 'babybuddy/form_field.html' %}
diff --git a/core/admin.py b/core/admin.py index c2d31a65..b137943a 100644 --- a/core/admin.py +++ b/core/admin.py @@ -6,6 +6,7 @@ from import_export import fields, resources from import_export.admin import ImportExportMixin, ExportActionMixin from core import models +from core.forms import TagAdminForm class ImportExportResourceBase(resources.ModelResource): @@ -177,3 +178,17 @@ class WeightAdmin(ImportExportMixin, ExportActionMixin, admin.ModelAdmin): "weight", ) resource_class = WeightImportExportResource + + +class TaggedItemInline(admin.StackedInline): + model = models.Tagged + + +@admin.register(models.Tag) +class TagAdmin(admin.ModelAdmin): + form = TagAdminForm + inlines = [TaggedItemInline] + list_display = ["name", "slug", "color", "last_used"] + ordering = ["name", "slug"] + search_fields = ["name"] + prepopulated_fields = {"slug": ["name"]} diff --git a/core/forms.py b/core/forms.py index 0d528ff8..a04f8495 100644 --- a/core/forms.py +++ b/core/forms.py @@ -1,10 +1,12 @@ # -*- coding: utf-8 -*- from django import forms +from django.forms import widgets from django.conf import settings from django.utils import timezone from django.utils.translation import gettext as _ from core import models +from core.widgets import TagsEditor def set_initial_values(kwargs, form_type): @@ -82,6 +84,7 @@ class CoreModelForm(forms.ModelForm): timer.stop(instance.end) if commit: instance.save() + self.save_m2m() return instance @@ -161,7 +164,7 @@ class FeedingForm(CoreModelForm): class NoteForm(CoreModelForm): class Meta: model = models.Note - fields = ["child", "note", "time"] + fields = ["child", "note", "time", "tags"] widgets = { "time": forms.DateTimeInput( attrs={ @@ -169,6 +172,7 @@ class NoteForm(CoreModelForm): "data-target": "#datetimepicker_time", } ), + "tags": TagsEditor(), } @@ -310,3 +314,8 @@ class BMIForm(CoreModelForm): ), "notes": forms.Textarea(attrs={"rows": 5}), } + + +class TagAdminForm(forms.ModelForm): + class Meta: + widgets = {"color": widgets.TextInput(attrs={"type": "color"})} diff --git a/core/migrations/0019_tag_tagged_note_tags.py b/core/migrations/0019_tag_tagged_note_tags.py new file mode 100644 index 00000000..634afaa9 --- /dev/null +++ b/core/migrations/0019_tag_tagged_note_tags.py @@ -0,0 +1,110 @@ +# Generated by Django 4.0.3 on 2022-03-08 11:51 + +import core.models +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ("contenttypes", "0002_remove_content_type_name"), + ("core", "0018_bmi_headcircumference_height"), + ] + + operations = [ + migrations.CreateModel( + name="Tag", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "name", + models.CharField(max_length=100, unique=True, verbose_name="name"), + ), + ( + "slug", + models.SlugField(max_length=100, unique=True, verbose_name="slug"), + ), + ( + "color", + models.CharField( + default=core.models.random_color, + max_length=32, + validators=[ + django.core.validators.RegexValidator("^#[0-9a-fA-F]{6}$") + ], + verbose_name="Color", + ), + ), + ( + "last_used", + models.DateTimeField( + default=django.utils.timezone.now, verbose_name="Last used" + ), + ), + ], + options={ + "verbose_name": "Tags", + }, + ), + migrations.CreateModel( + name="Tagged", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "object_id", + models.IntegerField(db_index=True, verbose_name="object ID"), + ), + ( + "content_type", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="%(app_label)s_%(class)s_tagged_items", + to="contenttypes.contenttype", + verbose_name="content type", + ), + ), + ( + "tag", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="%(app_label)s_%(class)s_items", + to="core.tag", + verbose_name="Tag", + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.AddField( + model_name="note", + name="tags", + field=core.models.TaggableManager( + blank=True, + help_text="Click on the tags to add (+) or remove (-) tags or use the text editor to create new tags.", + through="core.Tagged", + to="core.Tag", + verbose_name="Tags", + ), + ), + ] diff --git a/core/models.py b/core/models.py index 443f28e4..e45ef5d4 100644 --- a/core/models.py +++ b/core/models.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- from datetime import timedelta +from typing import Iterable, Optional from django.conf import settings from django.core.cache import cache @@ -9,6 +10,14 @@ from django.utils.text import slugify from django.utils import timezone from django.utils.text import format_lazy from django.utils.translation import gettext_lazy as _ +from django.core.validators import RegexValidator + +import random + +from taggit.managers import TaggableManager as TaggitTaggableManager +from taggit.models import TagBase, GenericTaggedItemBase + +random.seed() def validate_date(date, field_name): @@ -71,6 +80,78 @@ def validate_time(time, field_name): ) +def random_color(): + TAG_COLORS = [ + "#ff0000", + "#00ff00", + "#0000ff", + "#ff00ff", + "#ffff00", + "#00ffff", + "#ff7f7f", + "#7fff7f", + "#7f7fff", + "#ff7fff", + "#ffff7f", + "#7fffff", + "#7f0000", + "#007f00", + "#00007f", + "#7f007f", + "#7f7f00", + "#007f7f", + ] + return TAG_COLORS[random.randrange(0, len(TAG_COLORS))] + + +class Tag(TagBase): + class Meta: + verbose_name = _("Tags") + + color = models.CharField( + verbose_name=_("Color"), + max_length=32, + default=random_color, + validators=[RegexValidator(r"^#[0-9a-fA-F]{6}$")], + ) + + last_used = models.DateTimeField( + verbose_name=_("Last used"), + default=timezone.now, + blank=False, + ) + + +class Tagged(GenericTaggedItemBase): + tag = models.ForeignKey( + Tag, + verbose_name=_("Tag"), + on_delete=models.CASCADE, + related_name="%(app_label)s_%(class)s_items", + ) + + def save_base(self, *args, **kwargs): + """ + Update last_used of the used tag, whenever it is used in a + save-operation. + """ + self.tag.last_used = timezone.now() + self.tag.save() + return super().save_base(*args, **kwargs) + + +class TaggableManager(TaggitTaggableManager): + """ + Replace the default help_text with + """ + + def __init__(self, *args, **kwargs): + kwargs["help_text"] = _( + "Click on the tags to add (+) or remove (-) tags or use the text editor to create new tags." + ) + super().__init__(*args, **kwargs) + + class Child(models.Model): model_name = "child" first_name = models.CharField(max_length=255, verbose_name=_("First name")) @@ -251,6 +332,7 @@ class Note(models.Model): time = models.DateTimeField( default=timezone.now, blank=False, verbose_name=_("Time") ) + tags = TaggableManager(blank=True, through=Tagged) objects = models.Manager() diff --git a/core/templates/core/widget_tag_editor.html b/core/templates/core/widget_tag_editor.html new file mode 100644 index 00000000..3d988bd6 --- /dev/null +++ b/core/templates/core/widget_tag_editor.html @@ -0,0 +1,64 @@ +{% load i18n %} + +
+ {% csrf_token %} + +
+ {% for t in widget.value %} + + {{ t.name }} + - + + {% endfor %} +
+
+
+ +
+ +
+
+ {% trans "Recently used:" %} + {% for t in widget.tag_suggestions.quick %} + + {{ t.name }} + + + + {% endfor %} +
+ + + +
diff --git a/core/tests/tests_forms.py b/core/tests/tests_forms.py index b426edf1..84c2ce7c 100644 --- a/core/tests/tests_forms.py +++ b/core/tests/tests_forms.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +from datetime import datetime from django.contrib.auth.models import User from django.core.management import call_command from django.test import TestCase @@ -602,3 +603,105 @@ class WeightFormsTest(FormsTestCaseBase): page = self.c.post("/weight/{}/delete/".format(self.weight.id), follow=True) self.assertEqual(page.status_code, 200) self.assertContains(page, "Weight entry deleted") + + +class NotesFormsTest(FormsTestCaseBase): + """ + Piggy-backs a bunch of tests for the tags-logic. + """ + + @classmethod + def setUpClass(cls): + super(NotesFormsTest, cls).setUpClass() + + cls.note = models.Note.objects.create( + child=cls.child, + note="Setup note", + time=timezone.now() - timezone.timedelta(days=2), + ) + cls.note.tags.add("oldtag") + cls.oldtag = models.Tag.objects.filter(slug="oldtag").first() + + def test_add_no_tags(self): + params = { + "child": self.child.id, + "note": "note with no tags", + "time": (timezone.now() - timezone.timedelta(minutes=1)).isoformat(), + } + + page = self.c.post("/notes/add/", params, follow=True) + self.assertEqual(page.status_code, 200) + self.assertContains(page, "note with no tags") + + def test_add_with_tags(self): + params = { + "child": self.child.id, + "note": "this note has tags", + "time": (timezone.now() - timezone.timedelta(minutes=1)).isoformat(), + "tags": 'A,B,"setup tag"', + } + + old_notes = list(models.Note.objects.all()) + + page = self.c.post("/notes/add/", params, follow=True) + self.assertEqual(page.status_code, 200) + self.assertContains(page, "this note has tags") + + new_notes = list(models.Note.objects.all()) + + # Find the new tag and extract its tags + old_pks = [n.pk for n in old_notes] + new_note = [n for n in new_notes if n.pk not in old_pks][0] + new_note_tag_names = [t.name for t in new_note.tags.all()] + + self.assertSetEqual(set(new_note_tag_names), {"A", "B", "setup tag"}) + + def test_edit(self): + old_tag_last_used = self.oldtag.last_used + + params = { + "child": self.note.child.id, + "note": "Edited note", + "time": self.localdate_string(), + "tags": "oldtag,newtag", + } + page = self.c.post("/notes/{}/".format(self.note.id), params, follow=True) + self.assertEqual(page.status_code, 200) + + self.note.refresh_from_db() + self.oldtag.refresh_from_db() + self.assertEqual(self.note.note, params["note"]) + self.assertContains( + page, "Note entry for {} updated".format(str(self.note.child)) + ) + + self.assertSetEqual( + set(t.name for t in self.note.tags.all()), {"oldtag", "newtag"} + ) + + # Old tag remains old, because it was not added + self.assertEqual(old_tag_last_used, self.oldtag.last_used) + + # Second phase: Remove all tags then add "oldtag" through posting + # which should update the last_used tag + self.note.tags.clear() + self.note.save() + + params = { + "child": self.note.child.id, + "note": "Edited note (2)", + "time": self.localdate_string(), + "tags": "oldtag", + } + page = self.c.post("/notes/{}/".format(self.note.id), params, follow=True) + self.assertEqual(page.status_code, 200) + + self.note.refresh_from_db() + self.oldtag.refresh_from_db() + + self.assertLess(old_tag_last_used, self.oldtag.last_used) + + def test_delete(self): + page = self.c.post("/notes/{}/delete/".format(self.note.id), follow=True) + self.assertEqual(page.status_code, 200) + self.assertContains(page, "Note entry deleted") diff --git a/core/widgets.py b/core/widgets.py new file mode 100644 index 00000000..1c21865e --- /dev/null +++ b/core/widgets.py @@ -0,0 +1,83 @@ +from django.forms import Media +from typing import Any, Dict, Optional +from django.forms import Widget + +from . import models + + +class TagsEditor(Widget): + """ + Custom widget that provides an alternative editor for tags provided by the + taggit library. + + The widget makes use of bootstrap v4 and its badge/pill feature and renders + a list of tags as badges that can be clicked to remove or add a tag to + the list of set tags. In addition, a user can dynamically add new, custom + tags, using a text editor. + """ + + class Media: + js = ("babybuddy/js/tags_editor.js",) + + input_type = "hidden" + template_name = "core/widget_tag_editor.html" + + @staticmethod + def __unpack_tag(tag: models.Tag): + """ + Tiny utility function used to translate a tag to a serializable + dictionary of strings. + """ + return {"name": tag.name, "color": tag.color} + + def format_value(self, value: Any) -> Optional[str]: + """ + Override format_value to provide a list of dictionaries rather than + a flat, comma-separated list of tags. This allows for the more + complex rendering of tags provided by this plugin. + """ + if value is not None and not isinstance(value, str): + value = [self.__unpack_tag(tag) for tag in value] + return value + + def build_attrs(self, base_attrs, extra_attrs=None): + """ + Bootstrap integration adds form-control to the classes of the widget. + This works only for "plain" input-based widgets however. In addition, + we need to add a custom class "babybuddy-tags-editor" for the javascript + file to detect the widget and take control of its contents. + """ + attrs = super().build_attrs(base_attrs, extra_attrs) + class_string = attrs.get("class", "") + class_string = class_string.replace("form-control", "") + attrs["class"] = class_string + " babybuddy-tags-editor" + return attrs + + def get_context(self, name: str, value: Any, attrs) -> Dict[str, Any]: + """ + Adds extra information to the payload provided to the widget's template. + + Specifically: + - Query a list if "recently used" tags (max 256 to not cause + DoS issues) from the database to be used for auto-completion. ("most") + - Query a smaller list of 5 tags to be made available from a quick + selection widget ("quick"). + """ + most_tags = models.Tag.objects.order_by("-last_used").all()[:256] + + result = super().get_context(name, value, attrs) + + tag_names = set( + x["name"] for x in (result.get("widget", {}).get("value", None) or []) + ) + quick_suggestion_tags = [t for t in most_tags if t.name not in tag_names][:5] + + result["widget"]["tag_suggestions"] = { + "quick": [ + self.__unpack_tag(t) + for t in quick_suggestion_tags + if t.name not in tag_names + ], + "most": [self.__unpack_tag(t) for t in most_tags], + } + return result diff --git a/gulpfile.config.js b/gulpfile.config.js index fdb57728..14120508 100644 --- a/gulpfile.config.js +++ b/gulpfile.config.js @@ -68,6 +68,9 @@ module.exports = { 'api/static_src/js/*.js', 'core/static_src/js/*.js', 'dashboard/static_src/js/*.js' + ], + tags_editor: [ + 'babybuddy/static_src/js/tags_editor.js' ] }, stylesConfig: { diff --git a/gulpfile.js b/gulpfile.js index 69eda716..80e2f473 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -199,6 +199,12 @@ function scripts(cb) { concat('app.js'), gulp.dest(config.scriptsConfig.dest) ], cb); + + pump([ + gulp.src(config.scriptsConfig.tags_editor), + concat('tags_editor.js'), + gulp.dest(config.scriptsConfig.dest) + ], cb); } /** diff --git a/locale/de/LC_MESSAGES/django.mo b/locale/de/LC_MESSAGES/django.mo index 4b612474..7a2fb445 100644 Binary files a/locale/de/LC_MESSAGES/django.mo and b/locale/de/LC_MESSAGES/django.mo differ diff --git a/locale/de/LC_MESSAGES/django.po b/locale/de/LC_MESSAGES/django.po index d93f9492..bd1dbf4c 100644 --- a/locale/de/LC_MESSAGES/django.po +++ b/locale/de/LC_MESSAGES/django.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: Baby Buddy\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2022-02-25 05:14+0000\n" +"POT-Creation-Date: 2022-03-02 21:15+0000\n" "Language: de\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -121,59 +121,59 @@ msgstr "Zeitzone" msgid "{user}'s Settings" msgstr "{user} Einstellungen" -#: babybuddy/settings/base.py:166 +#: babybuddy/settings/base.py:167 msgid "Chinese (simplified)" msgstr "" -#: babybuddy/settings/base.py:167 +#: babybuddy/settings/base.py:168 msgid "Dutch" msgstr "" -#: babybuddy/settings/base.py:168 +#: babybuddy/settings/base.py:169 #, fuzzy #| msgid "English" msgid "English (US)" msgstr "Englisch" -#: babybuddy/settings/base.py:169 +#: babybuddy/settings/base.py:170 #, fuzzy #| msgid "English" msgid "English (UK)" msgstr "Englisch" -#: babybuddy/settings/base.py:170 +#: babybuddy/settings/base.py:171 msgid "French" msgstr "Französisch" -#: babybuddy/settings/base.py:171 +#: babybuddy/settings/base.py:172 msgid "Finnish" msgstr "" -#: babybuddy/settings/base.py:172 +#: babybuddy/settings/base.py:173 msgid "German" msgstr "Deutsch" -#: babybuddy/settings/base.py:173 +#: babybuddy/settings/base.py:174 msgid "Italian" msgstr "" -#: babybuddy/settings/base.py:174 +#: babybuddy/settings/base.py:175 msgid "Polish" msgstr "" -#: babybuddy/settings/base.py:175 +#: babybuddy/settings/base.py:176 msgid "Portuguese" msgstr "" -#: babybuddy/settings/base.py:176 +#: babybuddy/settings/base.py:177 msgid "Spanish" msgstr "Spanisch" -#: babybuddy/settings/base.py:177 +#: babybuddy/settings/base.py:178 msgid "Swedish" msgstr "Schwedisch" -#: babybuddy/settings/base.py:178 +#: babybuddy/settings/base.py:179 msgid "Turkish" msgstr "Türkisch" @@ -199,7 +199,7 @@ msgstr "Reset" msgid "Filters" msgstr "Filter" -#: babybuddy/templates/babybuddy/form.html:11 +#: babybuddy/templates/babybuddy/form.html:14 #: babybuddy/templates/babybuddy/user_settings_form.html:89 msgid "Submit" msgstr "Senden" @@ -222,27 +222,27 @@ msgstr "" msgid "Quick Start Timer" msgstr "Quick-Start Timer" -#: babybuddy/templates/babybuddy/nav-dropdown.html:51 core/models.py:158 -#: core/models.py:162 +#: babybuddy/templates/babybuddy/nav-dropdown.html:51 core/models.py:232 +#: core/models.py:236 msgid "Diaper Change" msgstr "Windeln wechseln" #: babybuddy/templates/babybuddy/nav-dropdown.html:57 -#: babybuddy/templates/babybuddy/nav-dropdown.html:274 core/models.py:227 -#: core/models.py:231 core/templates/core/timer_detail.html:43 +#: babybuddy/templates/babybuddy/nav-dropdown.html:274 core/models.py:301 +#: core/models.py:305 core/templates/core/timer_detail.html:43 msgid "Feeding" msgstr "Mahlzeit" #: babybuddy/templates/babybuddy/nav-dropdown.html:63 -#: babybuddy/templates/babybuddy/nav-dropdown.html:150 core/models.py:250 -#: core/models.py:260 core/models.py:264 core/templates/core/note_list.html:29 +#: babybuddy/templates/babybuddy/nav-dropdown.html:150 core/models.py:324 +#: core/models.py:335 core/models.py:339 core/templates/core/note_list.html:29 msgid "Note" msgstr "Notiz" #: babybuddy/templates/babybuddy/nav-dropdown.html:69 #: babybuddy/templates/babybuddy/nav-dropdown.html:281 -#: babybuddy/templates/babybuddy/welcome.html:42 core/models.py:292 -#: core/models.py:293 core/models.py:296 +#: babybuddy/templates/babybuddy/welcome.html:42 core/models.py:367 +#: core/models.py:368 core/models.py:371 #: core/templates/core/sleep_confirm_delete.html:7 #: core/templates/core/sleep_form.html:13 core/templates/core/sleep_list.html:4 #: core/templates/core/sleep_list.html:7 core/templates/core/sleep_list.html:12 @@ -251,8 +251,8 @@ msgid "Sleep" msgstr "Schlafen" #: babybuddy/templates/babybuddy/nav-dropdown.html:75 -#: babybuddy/templates/babybuddy/nav-dropdown.html:172 core/models.py:331 -#: core/models.py:341 core/models.py:342 core/models.py:345 +#: babybuddy/templates/babybuddy/nav-dropdown.html:172 core/models.py:406 +#: core/models.py:416 core/models.py:417 core/models.py:420 #: core/templates/core/temperature_confirm_delete.html:7 #: core/templates/core/temperature_form.html:13 #: core/templates/core/temperature_list.html:4 @@ -264,8 +264,8 @@ msgstr "Temperatur" #: babybuddy/templates/babybuddy/nav-dropdown.html:81 #: babybuddy/templates/babybuddy/nav-dropdown.html:294 -#: babybuddy/templates/babybuddy/welcome.html:50 core/models.py:468 -#: core/models.py:469 core/models.py:472 +#: babybuddy/templates/babybuddy/welcome.html:50 core/models.py:543 +#: core/models.py:544 core/models.py:547 #: core/templates/core/timer_detail.html:59 #: core/templates/core/tummytime_confirm_delete.html:7 #: core/templates/core/tummytime_form.html:13 @@ -276,16 +276,16 @@ msgid "Tummy Time" msgstr "Bauchzeit" #: babybuddy/templates/babybuddy/nav-dropdown.html:87 -#: babybuddy/templates/babybuddy/nav-dropdown.html:186 core/models.py:494 -#: core/models.py:503 core/models.py:504 core/models.py:507 +#: babybuddy/templates/babybuddy/nav-dropdown.html:186 core/models.py:569 +#: core/models.py:578 core/models.py:579 core/models.py:582 #: core/templates/core/weight_confirm_delete.html:7 #: core/templates/core/weight_form.html:13 #: core/templates/core/weight_list.html:4 #: core/templates/core/weight_list.html:7 #: core/templates/core/weight_list.html:12 -#: core/templates/core/weight_list.html:29 -#: dashboard/templates/dashboard/child_button_group.html:31 -#: reports/graphs/weight_weight.py:19 reports/graphs/weight_weight.py:30 +#: core/templates/core/weight_list.html:29 reports/graphs/weight_weight.py:19 +#: reports/graphs/weight_weight.py:30 +#: reports/templates/reports/report_list.html:21 #: reports/templates/reports/weight_change.html:4 #: reports/templates/reports/weight_change.html:8 msgid "Weight" @@ -299,20 +299,20 @@ msgid "Timeline" msgstr "" #: babybuddy/templates/babybuddy/nav-dropdown.html:122 -#: babybuddy/templates/babybuddy/nav-dropdown.html:130 core/models.py:101 +#: babybuddy/templates/babybuddy/nav-dropdown.html:130 core/models.py:175 #: core/templates/core/child_confirm_delete.html:7 #: core/templates/core/child_detail.html:7 #: core/templates/core/child_form.html:13 core/templates/core/child_list.html:4 #: core/templates/core/child_list.html:7 core/templates/core/child_list.html:12 #: dashboard/templates/dashboard/child.html:7 -#: reports/templates/reports/report_base.html:7 +#: reports/templates/reports/base.html:7 msgid "Children" msgstr "Kinder" -#: babybuddy/templates/babybuddy/nav-dropdown.html:136 core/models.py:100 -#: core/models.py:134 core/models.py:190 core/models.py:248 core/models.py:276 -#: core/models.py:328 core/models.py:359 core/models.py:452 core/models.py:492 -#: core/models.py:519 core/models.py:546 core/models.py:572 +#: babybuddy/templates/babybuddy/nav-dropdown.html:136 core/models.py:174 +#: core/models.py:208 core/models.py:264 core/models.py:322 core/models.py:351 +#: core/models.py:403 core/models.py:434 core/models.py:527 core/models.py:567 +#: core/models.py:594 core/models.py:621 core/models.py:647 #: core/templates/core/bmi_list.html:27 #: core/templates/core/diaperchange_list.html:27 #: core/templates/core/feeding_list.html:27 @@ -326,9 +326,9 @@ msgstr "Kinder" msgid "Child" msgstr "Kind" -#: babybuddy/templates/babybuddy/nav-dropdown.html:144 core/models.py:151 -#: core/models.py:220 core/models.py:261 core/models.py:284 core/models.py:334 -#: core/models.py:496 core/models.py:523 core/models.py:552 core/models.py:576 +#: babybuddy/templates/babybuddy/nav-dropdown.html:144 core/models.py:225 +#: core/models.py:294 core/models.py:336 core/models.py:359 core/models.py:409 +#: core/models.py:571 core/models.py:598 core/models.py:627 core/models.py:651 #: core/templates/core/note_confirm_delete.html:7 #: core/templates/core/note_form.html:13 core/templates/core/note_list.html:4 #: core/templates/core/note_list.html:7 core/templates/core/note_list.html:12 @@ -347,18 +347,18 @@ msgstr "Temperatur Messung" msgid "Weight entry" msgstr "Gewichtseintrag" -#: babybuddy/templates/babybuddy/nav-dropdown.html:200 core/models.py:521 -#: core/models.py:530 core/models.py:531 core/models.py:534 +#: babybuddy/templates/babybuddy/nav-dropdown.html:200 core/models.py:596 +#: core/models.py:605 core/models.py:606 core/models.py:609 #: core/templates/core/height_confirm_delete.html:7 #: core/templates/core/height_form.html:13 #: core/templates/core/height_list.html:4 #: core/templates/core/height_list.html:7 #: core/templates/core/height_list.html:12 -#: core/templates/core/height_list.html:29 -#: dashboard/templates/dashboard/child_button_group.html:32 -#: reports/graphs/height_height.py:19 reports/graphs/height_height.py:30 +#: core/templates/core/height_list.html:29 reports/graphs/height_height.py:19 +#: reports/graphs/height_height.py:30 #: reports/templates/reports/height_change.html:4 #: reports/templates/reports/height_change.html:8 +#: reports/templates/reports/report_list.html:17 #, fuzzy #| msgid "Weight" msgid "Height" @@ -370,38 +370,36 @@ msgstr "Gewicht" msgid "Height entry" msgstr "Gewichtseintrag" -#: babybuddy/templates/babybuddy/nav-dropdown.html:214 core/models.py:549 -#: core/models.py:559 core/models.py:560 core/models.py:563 +#: babybuddy/templates/babybuddy/nav-dropdown.html:214 core/models.py:624 +#: core/models.py:634 core/models.py:635 core/models.py:638 #: core/templates/core/head_circumference_confirm_delete.html:7 #: core/templates/core/head_circumference_form.html:13 #: core/templates/core/head_circumference_list.html:4 #: core/templates/core/head_circumference_list.html:7 #: core/templates/core/head_circumference_list.html:12 #: core/templates/core/head_circumference_list.html:29 -#: dashboard/templates/dashboard/child_button_group.html:33 #: reports/graphs/head_circumference_head_circumference.py:19 #: reports/graphs/head_circumference_head_circumference.py:30 #: reports/templates/reports/head_circumference_change.html:4 #: reports/templates/reports/head_circumference_change.html:8 +#: reports/templates/reports/report_list.html:16 msgid "Head Circumference" -msgstr "" +msgstr "Kopfumfang" #: babybuddy/templates/babybuddy/nav-dropdown.html:220 msgid "Head Circumference entry" -msgstr "" +msgstr "Kopfumfang Eintrag" -#: babybuddy/templates/babybuddy/nav-dropdown.html:228 core/models.py:574 -#: core/models.py:583 core/models.py:584 core/models.py:587 +#: babybuddy/templates/babybuddy/nav-dropdown.html:228 core/models.py:649 +#: core/models.py:658 core/models.py:659 core/models.py:662 #: core/templates/core/bmi_confirm_delete.html:7 #: core/templates/core/bmi_form.html:13 core/templates/core/bmi_list.html:4 #: core/templates/core/bmi_list.html:7 core/templates/core/bmi_list.html:12 -#: core/templates/core/bmi_list.html:29 -#: dashboard/templates/dashboard/child_button_group.html:34 -#: reports/graphs/bmi_bmi.py:19 reports/graphs/bmi_bmi.py:30 -#: reports/templates/reports/bmi_change.html:4 +#: core/templates/core/bmi_list.html:29 reports/graphs/bmi_bmi.py:19 +#: reports/graphs/bmi_bmi.py:30 reports/templates/reports/bmi_change.html:4 #: reports/templates/reports/bmi_change.html:8 msgid "BMI" -msgstr "" +msgstr "BMI" #: babybuddy/templates/babybuddy/nav-dropdown.html:234 #, fuzzy @@ -423,7 +421,7 @@ msgid "Change" msgstr "Wechsel" #: babybuddy/templates/babybuddy/nav-dropdown.html:268 -#: babybuddy/templates/babybuddy/welcome.html:34 core/models.py:228 +#: babybuddy/templates/babybuddy/welcome.html:34 core/models.py:302 #: core/templates/core/feeding_confirm_delete.html:7 #: core/templates/core/feeding_form.html:13 #: core/templates/core/feeding_list.html:4 @@ -443,7 +441,7 @@ msgstr "Bauchzeit-Eintrag" #: babybuddy/templates/babybuddy/nav-dropdown.html:325 #: babybuddy/templates/babybuddy/user_list.html:17 #: babybuddy/templates/babybuddy/user_password_form.html:7 -#: babybuddy/templates/babybuddy/user_settings_form.html:7 core/models.py:378 +#: babybuddy/templates/babybuddy/user_settings_form.html:7 core/models.py:453 #: core/templates/core/timer_list.html:32 msgid "User" msgstr "Benutzer" @@ -535,7 +533,7 @@ msgstr "Benutzer löschen" #: core/templates/core/tummytime_confirm_delete.html:17 #: core/templates/core/weight_confirm_delete.html:8 #: core/templates/core/weight_confirm_delete.html:17 -#: dashboard/templates/dashboard/child_button_group.html:48 +#: dashboard/templates/dashboard/child_button_group.html:27 msgid "Delete" msgstr "löschen" @@ -631,7 +629,7 @@ msgstr "E-Mail" msgid "Staff" msgstr "Angestellte" -#: babybuddy/templates/babybuddy/user_list.html:22 core/models.py:373 +#: babybuddy/templates/babybuddy/user_list.html:22 core/models.py:448 #: core/templates/core/timer_list.html:31 msgid "Active" msgstr "Aktiv" @@ -708,7 +706,7 @@ msgstr "" "Lerne und sehe die Bedürfnisse deines Babys voraus, ohne (allzu viel)Spekulation indem du Baby Buddy verwendest —" -#: babybuddy/templates/babybuddy/welcome.html:26 core/models.py:159 +#: babybuddy/templates/babybuddy/welcome.html:26 core/models.py:233 #: core/templates/core/diaperchange_confirm_delete.html:7 #: core/templates/core/diaperchange_form.html:13 #: core/templates/core/diaperchange_list.html:4 @@ -894,105 +892,113 @@ msgstr "User API-Key neu generiert." msgid "Settings saved!" msgstr "Einstellungen gespeichert!" -#: core/forms.py:115 +#: core/forms.py:117 msgid "Name does not match child name." msgstr "Name entspricht nicht dem Kindernamen." -#: core/models.py:23 +#: core/models.py:32 msgid "Date can not be in the future." msgstr "Datum darf nicht in der Zukunft liegen." -#: core/models.py:37 +#: core/models.py:46 msgid "Start time must come before end time." msgstr "Startzeit muss vor Endzeit sein." -#: core/models.py:40 +#: core/models.py:49 msgid "Duration too long." msgstr "Dauer zu lange." -#: core/models.py:56 +#: core/models.py:65 msgid "Another entry intersects the specified time period." msgstr "Ein anderer Eintrag schneidet sich mit der angegebenen Zeitperiode." -#: core/models.py:70 +#: core/models.py:79 msgid "Date/time can not be in the future." msgstr "Datum/Zeit darf nicht in der Zukunft liegen." -#: core/models.py:76 +#: core/models.py:109 +msgid "Tag" +msgstr "" + +#: core/models.py:110 +msgid "Tags" +msgstr "" + +#: core/models.py:150 msgid "First name" msgstr "Vorname" -#: core/models.py:78 +#: core/models.py:152 msgid "Last name" msgstr "Nachname" -#: core/models.py:80 +#: core/models.py:154 msgid "Birth date" msgstr "Geburtsdatum" -#: core/models.py:87 +#: core/models.py:161 msgid "Slug" msgstr "Slug" -#: core/models.py:90 +#: core/models.py:164 msgid "Picture" msgstr "Bild" -#: core/models.py:136 core/models.py:252 core/models.py:333 +#: core/models.py:210 core/models.py:326 core/models.py:408 #: core/templates/core/diaperchange_list.html:25 #: core/templates/core/note_list.html:25 #: core/templates/core/temperature_list.html:25 msgid "Time" msgstr "Zeit" -#: core/models.py:137 core/templates/core/diaperchange_list.html:60 +#: core/models.py:211 core/templates/core/diaperchange_list.html:60 #: reports/graphs/diaperchange_types.py:36 msgid "Wet" msgstr "Nass" -#: core/models.py:138 core/templates/core/diaperchange_list.html:61 +#: core/models.py:212 core/templates/core/diaperchange_list.html:61 #: reports/graphs/diaperchange_types.py:30 msgid "Solid" msgstr "Fest" -#: core/models.py:142 +#: core/models.py:216 msgid "Black" msgstr "Schwarz" -#: core/models.py:143 +#: core/models.py:217 msgid "Brown" msgstr "Braun" -#: core/models.py:144 +#: core/models.py:218 msgid "Green" msgstr "Grün" -#: core/models.py:145 +#: core/models.py:219 msgid "Yellow" msgstr "Gelb" -#: core/models.py:148 core/templates/core/diaperchange_list.html:30 +#: core/models.py:222 core/templates/core/diaperchange_list.html:30 msgid "Color" msgstr "Farbe" -#: core/models.py:150 core/models.py:219 +#: core/models.py:224 core/models.py:293 #: core/templates/core/diaperchange_list.html:31 msgid "Amount" msgstr "Menge" -#: core/models.py:180 +#: core/models.py:254 msgid "Wet and/or solid is required." msgstr "Nass und/oder fest wird benötigt." -#: core/models.py:192 core/models.py:279 core/models.py:365 core/models.py:454 +#: core/models.py:266 core/models.py:354 core/models.py:440 core/models.py:529 msgid "Start time" msgstr "Startzeit" -#: core/models.py:193 core/models.py:280 core/models.py:368 core/models.py:455 +#: core/models.py:267 core/models.py:355 core/models.py:443 core/models.py:530 msgid "End time" msgstr "Endzeit" -#: core/models.py:195 core/models.py:282 core/models.py:371 core/models.py:457 +#: core/models.py:269 core/models.py:357 core/models.py:446 core/models.py:532 #: core/templates/core/feeding_list.html:34 #: core/templates/core/sleep_list.html:30 #: core/templates/core/timer_list.html:29 @@ -1000,67 +1006,67 @@ msgstr "Endzeit" msgid "Duration" msgstr "Dauer" -#: core/models.py:199 +#: core/models.py:273 msgid "Breast milk" msgstr "Brustmilch" -#: core/models.py:200 +#: core/models.py:274 msgid "Formula" msgstr "Formel" -#: core/models.py:201 +#: core/models.py:275 msgid "Fortified breast milk" msgstr "Angereicherte Brustmilch" -#: core/models.py:202 +#: core/models.py:276 msgid "Solid food" msgstr "" -#: core/models.py:205 core/templates/core/feeding_list.html:30 +#: core/models.py:279 core/templates/core/feeding_list.html:30 msgid "Type" msgstr "Typ" -#: core/models.py:209 +#: core/models.py:283 msgid "Bottle" msgstr "Fläschchen" -#: core/models.py:210 +#: core/models.py:284 msgid "Left breast" msgstr "Linke Brust" -#: core/models.py:211 +#: core/models.py:285 msgid "Right breast" msgstr "Rechte Brust" -#: core/models.py:212 +#: core/models.py:286 msgid "Both breasts" msgstr "Beide Brüste" -#: core/models.py:213 +#: core/models.py:287 msgid "Parent fed" msgstr "" -#: core/models.py:214 +#: core/models.py:288 msgid "Self fed" msgstr "" -#: core/models.py:217 core/templates/core/feeding_list.html:29 +#: core/models.py:291 core/templates/core/feeding_list.html:29 msgid "Method" msgstr "Methode" -#: core/models.py:278 +#: core/models.py:353 msgid "Napping" msgstr "" -#: core/models.py:362 core/templates/core/timer_list.html:25 +#: core/models.py:437 core/templates/core/timer_list.html:25 msgid "Name" msgstr "Name" -#: core/models.py:386 core/templates/core/timer_form.html:4 +#: core/models.py:461 core/templates/core/timer_form.html:4 msgid "Timer" msgstr "Timer" -#: core/models.py:387 core/templates/core/timer_confirm_delete.html:9 +#: core/models.py:462 core/templates/core/timer_confirm_delete.html:9 #: core/templates/core/timer_confirm_delete_inactive.html:9 #: core/templates/core/timer_detail.html:8 #: core/templates/core/timer_form.html:7 core/templates/core/timer_list.html:4 @@ -1069,16 +1075,16 @@ msgstr "Timer" msgid "Timers" msgstr "Timer" -#: core/models.py:390 +#: core/models.py:465 #, python-brace-format msgid "Timer #{id}" msgstr "Timer #{id}" -#: core/models.py:460 core/templates/core/tummytime_list.html:30 +#: core/models.py:535 core/templates/core/tummytime_list.html:30 msgid "Milestone" msgstr "Meilenstein" -#: core/models.py:495 core/models.py:522 core/models.py:551 core/models.py:575 +#: core/models.py:570 core/models.py:597 core/models.py:626 core/models.py:650 #: core/templates/core/bmi_list.html:25 #: core/templates/core/feeding_list.html:25 #: core/templates/core/head_circumference_list.html:25 @@ -1098,24 +1104,22 @@ msgstr "Datum" #, fuzzy #| msgid "Delete a Sleep Entry" msgid "Delete a BMI Entry" -msgstr "Einen Schlaf-Eintrag löschen" +msgstr "Einen BMI-Wert löschen" #: core/templates/core/bmi_form.html:8 core/templates/core/bmi_form.html:17 #: core/templates/core/bmi_form.html:27 -#, fuzzy -#| msgid "Add a Sleep Entry" msgid "Add a BMI Entry" -msgstr "Schlaf-Eintrag hinzufügen" +msgstr "BMI-Wert hinzufügen" #: core/templates/core/bmi_list.html:15 msgid "Add BMI" -msgstr "" +msgstr "BMI Wert hinzufügen" #: core/templates/core/bmi_list.html:66 #, fuzzy #| msgid "No timer entries found." msgid "No bmi entries found." -msgstr "Keine Timer-Einträge gefunden." +msgstr "Keine BMI-Einträge gefunden." #: core/templates/core/child_confirm_delete.html:4 msgid "Delete a Child" @@ -1165,6 +1169,7 @@ msgstr "Windelwechsel hinzufügen" #: core/templates/core/feeding_form.html:17 #: core/templates/core/note_form.html:17 core/templates/core/sleep_form.html:17 #: core/templates/core/tummytime_form.html:17 +#: core/templates/core/widget_tag_editor.html:24 msgid "Add" msgstr "Hinzufügen" @@ -1206,34 +1211,28 @@ msgid "No feedings found." msgstr "Keine Mahlzeit gefunden." #: core/templates/core/head_circumference_confirm_delete.html:4 -#, fuzzy -#| msgid "Delete a Tummy Time Entry" msgid "Delete a Head Circumference Entry" -msgstr "Bauchzeit-Eintrag löschen" +msgstr "Kopfumfang löschen" #: core/templates/core/head_circumference_form.html:8 #: core/templates/core/head_circumference_form.html:17 #: core/templates/core/head_circumference_form.html:27 -#, fuzzy -#| msgid "Add a Temperature Entry" msgid "Add a Head Circumference Entry" -msgstr "Temperaturmessung hinzufügen" +msgstr "Kopfumfang hinzufügen" #: core/templates/core/head_circumference_list.html:15 msgid "Add Head Circumference" msgstr "" #: core/templates/core/head_circumference_list.html:66 -#, fuzzy -#| msgid "No timer entries found." msgid "No head circumference entries found." -msgstr "Keine Timer-Einträge gefunden." +msgstr "Keine Kopfumfang-Einträge gefunden." #: core/templates/core/height_confirm_delete.html:4 #, fuzzy #| msgid "Delete a Weight Entry" msgid "Delete a Height Entry" -msgstr "Gewichts-Eintrag löschen" +msgstr "Größen-Eintrag löschen" #: core/templates/core/height_form.html:8 #: core/templates/core/height_form.html:17 @@ -1241,19 +1240,17 @@ msgstr "Gewichts-Eintrag löschen" #, fuzzy #| msgid "Add a Weight Entry" msgid "Add a Height Entry" -msgstr "Gewichts-Eintrag hinzufügen" +msgstr "Größen-Eintrag hinzufügen" #: core/templates/core/height_list.html:15 #, fuzzy #| msgid "Add Weight" msgid "Add Height" -msgstr "Gewicht hinzufügen" +msgstr "Größe hinzufügen" #: core/templates/core/height_list.html:66 -#, fuzzy -#| msgid "No weight entries found." msgid "No height entries found." -msgstr "Keine Gewichts-Einträge gefunden." +msgstr "Keine Größen-Einträge gefunden." #: core/templates/core/note_confirm_delete.html:4 msgid "Delete a Note" @@ -1450,6 +1447,44 @@ msgstr "Gewicht hinzufügen" msgid "No weight entries found." msgstr "Keine Gewichts-Einträge gefunden." +#: core/templates/core/widget_tag_editor.html:22 +msgid "Tag name" +msgstr "Tag-Name" + +#: core/templates/core/widget_tag_editor.html:27 +msgid "Recently used:" +msgstr "Kürzlich verwendet:" + +#: core/templates/core/widget_tag_editor.html:45 +msgctxt "Error modal" +msgid "Error" +msgstr "Fehler" + +#: core/templates/core/widget_tag_editor.html:50 +msgctxt "Error modal" +msgid "An error ocurred." +msgstr "Ein Fehler ist aufgetreten." + +#: core/templates/core/widget_tag_editor.html:51 +msgctxt "Error modal" +msgid "Invalid tag name." +msgstr "Ungültiger Tag-Name." + +#: core/templates/core/widget_tag_editor.html:52 +msgctxt "Error modal" +msgid "Failed to create tag." +msgstr "Fehler bein erzeugen des tags." + +#: core/templates/core/widget_tag_editor.html:53 +msgctxt "Error modal" +msgid "Failed to obtain tag data." +msgstr "Konnte Tag nicht laden." + +#: core/templates/core/widget_tag_editor.html:58 +msgctxt "Error modal" +msgid "Close" +msgstr "Schließen" + #: core/templates/timeline/_timeline.html:33 #, python-format msgid "%(since)s ago (%(time)s)" @@ -1467,7 +1502,7 @@ msgid "%(since)s since previous" msgstr "" #: core/templates/timeline/_timeline.html:56 -#: dashboard/templates/dashboard/child_button_group.html:41 +#: dashboard/templates/dashboard/child_button_group.html:20 msgid "Edit" msgstr "" @@ -1730,53 +1765,12 @@ msgstr "Nie" msgid "Child actions" msgstr "Aktionen des Kindes" -#: dashboard/templates/dashboard/child_button_group.html:17 -#: reports/templates/reports/report_base.html:9 +#: dashboard/templates/dashboard/child_button_group.html:12 +#: reports/templates/reports/base.html:9 +#: reports/templates/reports/report_list.html:4 msgid "Reports" msgstr "Reports" -#: dashboard/templates/dashboard/child_button_group.html:23 -msgid "Diaper Change Amounts" -msgstr "Windelwechsel Mengen" - -#: dashboard/templates/dashboard/child_button_group.html:24 -#: reports/templates/reports/diaperchange_types.html:4 -#: reports/templates/reports/diaperchange_types.html:8 -msgid "Diaper Change Types" -msgstr "Windewechsel Typen" - -#: dashboard/templates/dashboard/child_button_group.html:25 -#: reports/templates/reports/diaperchange_lifetimes.html:4 -#: reports/templates/reports/diaperchange_lifetimes.html:8 -msgid "Diaper Lifetimes" -msgstr "Windel-Lebensdauer" - -#: dashboard/templates/dashboard/child_button_group.html:26 -#: reports/templates/reports/feeding_amounts.html:4 -#: reports/templates/reports/feeding_amounts.html:8 -msgid "Feeding Amounts" -msgstr "Mahlzeiten" - -#: dashboard/templates/dashboard/child_button_group.html:27 -msgid "Feeding Durations (Average)" -msgstr "Mahlzeit Dauer (Durschschnitt)" - -#: dashboard/templates/dashboard/child_button_group.html:28 -#: reports/templates/reports/sleep_pattern.html:4 -#: reports/templates/reports/sleep_pattern.html:8 -msgid "Sleep Pattern" -msgstr "Schlafrhythmus" - -#: dashboard/templates/dashboard/child_button_group.html:29 -#: reports/templates/reports/sleep_totals.html:4 -#: reports/templates/reports/sleep_totals.html:8 -msgid "Sleep Totals" -msgstr "Schlaf Total" - -#: dashboard/templates/dashboard/child_button_group.html:30 -msgid "Tummy Time Durations (Sum)" -msgstr "" - #: dashboard/templatetags/cards.py:288 msgid "Diaper change frequency" msgstr "Frequenz Windelwechsel" @@ -1817,7 +1811,7 @@ msgstr "Gewichtsänderung pro Woche" #, fuzzy #| msgid "Weight change per week" msgid "BMI change per week" -msgstr "Gewichtsänderung pro Woche" +msgstr "BMI-änderung pro Woche" #: dashboard/templatetags/cards.py:418 msgid "Feeding frequency (past 3 days)" @@ -1835,7 +1829,7 @@ msgstr "Freuqenz Mahlzeiten" #, fuzzy #| msgid "Weight" msgid "BMI" -msgstr "Gewicht" +msgstr "BMI" #: reports/graphs/diaperchange_amounts.py:27 msgid "Diaper change amount" @@ -1955,15 +1949,61 @@ msgstr "Gewicht" msgid "Diaper Amounts" msgstr "Windel Mengen" +#: reports/templates/reports/diaperchange_lifetimes.html:4 +#: reports/templates/reports/diaperchange_lifetimes.html:8 +#: reports/templates/reports/report_list.html:13 +msgid "Diaper Lifetimes" +msgstr "Windel-Lebensdauer" + +#: reports/templates/reports/diaperchange_types.html:4 +#: reports/templates/reports/diaperchange_types.html:8 +#: reports/templates/reports/report_list.html:12 +msgid "Diaper Change Types" +msgstr "Windewechsel Typen" + +#: reports/templates/reports/feeding_amounts.html:4 +#: reports/templates/reports/feeding_amounts.html:8 +#: reports/templates/reports/report_list.html:14 +msgid "Feeding Amounts" +msgstr "Mahlzeiten" + #: reports/templates/reports/feeding_duration.html:4 #: reports/templates/reports/feeding_duration.html:8 msgid "Average Feeding Durations" msgstr "Durchschnittliche Mahlzeitendauer" -#: reports/templates/reports/report_base.html:19 +#: reports/templates/reports/report_base.html:17 msgid "There is not enough data to generate this report." msgstr "Es gibt nicht genügend Daten um diesen Report zu generieren." +#: reports/templates/reports/report_list.html:10 +msgid "Body Mass Index (BMI)" +msgstr "Body Mass Index (BMI)" + +#: reports/templates/reports/report_list.html:11 +msgid "Diaper Change Amounts" +msgstr "Windelwechsel Mengen" + +#: reports/templates/reports/report_list.html:15 +msgid "Feeding Durations (Average)" +msgstr "Mahlzeit Dauer (Durschschnitt)" + +#: reports/templates/reports/report_list.html:18 +#: reports/templates/reports/sleep_pattern.html:4 +#: reports/templates/reports/sleep_pattern.html:8 +msgid "Sleep Pattern" +msgstr "Schlafrhythmus" + +#: reports/templates/reports/report_list.html:19 +#: reports/templates/reports/sleep_totals.html:4 +#: reports/templates/reports/sleep_totals.html:8 +msgid "Sleep Totals" +msgstr "Schlaf Total" + +#: reports/templates/reports/report_list.html:20 +msgid "Tummy Time Durations (Sum)" +msgstr "" + #: reports/templates/reports/tummytime_duration.html:4 #: reports/templates/reports/tummytime_duration.html:8 msgid "Total Tummy Time Durations" diff --git a/requirements.txt b/requirements.txt index 4cb54d44..0e410596 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,8 +7,9 @@ -i https://pypi.python.org/simple asgiref==3.5.0; python_version >= '3.7' -boto3==1.20.52 -botocore==1.23.52; python_version >= '3.6' +backports.zoneinfo==0.2.1; python_version < '3.9' +boto3==1.21.11 +botocore==1.24.11; python_version >= '3.6' defusedxml==0.7.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' diff-match-patch==20200713; python_version >= '2.7' dj-database-url==0.5.0 @@ -19,11 +20,12 @@ django-imagekit==4.1.0 django-import-export==2.7.1 django-ipware==4.0.2; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5' django-storages==1.12.3 +django-taggit==2.1.0 django-widget-tweaks==1.4.12 -django==4.0.2 +django==4.0.3 djangorestframework==3.13.1 et-xmlfile==1.1.0; python_version >= '3.6' -faker==12.2.0 +faker==13.3.0 gunicorn==20.1.0 jmespath==0.10.0; python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3' markuppy==1.14 @@ -37,8 +39,8 @@ python-dateutil==2.8.2; python_version >= '2.7' and python_version not in '3.0, python-dotenv==0.19.2 pytz==2021.3 pyyaml==6.0 -s3transfer==0.5.1; python_version >= '3.6' -setuptools==60.8.2; python_version >= '3.7' +s3transfer==0.5.2; python_version >= '3.6' +setuptools==60.9.3; python_version >= '3.7' six==1.16.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' sqlparse==0.4.2; python_version >= '3.5' tablib[html,ods,xls,xlsx,yaml]==3.2.0; python_version >= '3.7' diff --git a/static/babybuddy/css/app.ce8ee645797d.css b/static/babybuddy/css/app.4a965921898b.css similarity index 99% rename from static/babybuddy/css/app.ce8ee645797d.css rename to static/babybuddy/css/app.4a965921898b.css index 5f69436a..816621e2 100644 --- a/static/babybuddy/css/app.ce8ee645797d.css +++ b/static/babybuddy/css/app.4a965921898b.css @@ -10586,6 +10586,10 @@ h3 { font-size: 1.65em; } +.modal-content { + color: #343a40; +} + #view-core\:child .child-photo { max-width: 150px; } diff --git a/static/babybuddy/css/app.ce8ee645797d.css.gz b/static/babybuddy/css/app.4a965921898b.css.gz similarity index 92% rename from static/babybuddy/css/app.ce8ee645797d.css.gz rename to static/babybuddy/css/app.4a965921898b.css.gz index 0d2e21f6..3dd1c2c5 100644 Binary files a/static/babybuddy/css/app.ce8ee645797d.css.gz and b/static/babybuddy/css/app.4a965921898b.css.gz differ diff --git a/static/babybuddy/css/app.css b/static/babybuddy/css/app.css index a63b55d8..0977403e 100644 --- a/static/babybuddy/css/app.css +++ b/static/babybuddy/css/app.css @@ -10586,6 +10586,10 @@ h3 { font-size: 1.65em; } +.modal-content { + color: #343a40; +} + #view-core\:child .child-photo { max-width: 150px; } diff --git a/static/babybuddy/css/app.css.gz b/static/babybuddy/css/app.css.gz index 0914e597..72e07123 100644 Binary files a/static/babybuddy/css/app.css.gz and b/static/babybuddy/css/app.css.gz differ diff --git a/static/babybuddy/js/graph.0a43abb3165d.js b/static/babybuddy/js/graph.0a43abb3165d.js index cdb782b6..09bdf61a 100644 --- a/static/babybuddy/js/graph.0a43abb3165d.js +++ b/static/babybuddy/js/graph.0a43abb3165d.js @@ -13248,46 +13248,46 @@ function transpose(out, a) { }; },{}],64:[function(_dereq_,module,exports){ (function (global){(function (){ -'use strict' - -var isBrowser = _dereq_('is-browser') -var hasHover - -if (typeof global.matchMedia === 'function') { - hasHover = !global.matchMedia('(hover: none)').matches -} -else { - hasHover = isBrowser -} - -module.exports = hasHover +'use strict' + +var isBrowser = _dereq_('is-browser') +var hasHover + +if (typeof global.matchMedia === 'function') { + hasHover = !global.matchMedia('(hover: none)').matches +} +else { + hasHover = isBrowser +} + +module.exports = hasHover }).call(this)}).call(this,typeof global !== "undefined" ? global : typeof self !== "undefined" ? self : typeof window !== "undefined" ? window : {}) },{"is-browser":68}],65:[function(_dereq_,module,exports){ -'use strict' - -var isBrowser = _dereq_('is-browser') - -function detect() { - var supported = false - - try { - var opts = Object.defineProperty({}, 'passive', { - get: function() { - supported = true - } - }) - - window.addEventListener('test', null, opts) - window.removeEventListener('test', null, opts) - } catch(e) { - supported = false - } - - return supported -} - -module.exports = isBrowser && detect() +'use strict' + +var isBrowser = _dereq_('is-browser') + +function detect() { + var supported = false + + try { + var opts = Object.defineProperty({}, 'passive', { + get: function() { + supported = true + } + }) + + window.addEventListener('test', null, opts) + window.removeEventListener('test', null, opts) + } catch(e) { + supported = false + } + + return supported +} + +module.exports = isBrowser && detect() },{"is-browser":68}],66:[function(_dereq_,module,exports){ exports.read = function (buffer, offset, isLE, mLen, nBytes) { @@ -13407,78 +13407,78 @@ if (typeof Object.create === 'function') { },{}],68:[function(_dereq_,module,exports){ module.exports = true; },{}],69:[function(_dereq_,module,exports){ -'use strict' - -module.exports = isMobile -module.exports.isMobile = isMobile -module.exports.default = isMobile - -var mobileRE = /(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series[46]0|symbian|treo|up\.(browser|link)|vodafone|wap|windows (ce|phone)|xda|xiino/i - -var tabletRE = /(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series[46]0|symbian|treo|up\.(browser|link)|vodafone|wap|windows (ce|phone)|xda|xiino|android|ipad|playbook|silk/i - -function isMobile (opts) { - if (!opts) opts = {} - var ua = opts.ua - if (!ua && typeof navigator !== 'undefined') ua = navigator.userAgent - if (ua && ua.headers && typeof ua.headers['user-agent'] === 'string') { - ua = ua.headers['user-agent'] - } - if (typeof ua !== 'string') return false - - var result = opts.tablet ? tabletRE.test(ua) : mobileRE.test(ua) - - if ( - !result && - opts.tablet && - opts.featureDetect && - navigator && - navigator.maxTouchPoints > 1 && - ua.indexOf('Macintosh') !== -1 && - ua.indexOf('Safari') !== -1 - ) { - result = true - } - - return result -} +'use strict' + +module.exports = isMobile +module.exports.isMobile = isMobile +module.exports.default = isMobile + +var mobileRE = /(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series[46]0|symbian|treo|up\.(browser|link)|vodafone|wap|windows (ce|phone)|xda|xiino/i + +var tabletRE = /(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series[46]0|symbian|treo|up\.(browser|link)|vodafone|wap|windows (ce|phone)|xda|xiino|android|ipad|playbook|silk/i + +function isMobile (opts) { + if (!opts) opts = {} + var ua = opts.ua + if (!ua && typeof navigator !== 'undefined') ua = navigator.userAgent + if (ua && ua.headers && typeof ua.headers['user-agent'] === 'string') { + ua = ua.headers['user-agent'] + } + if (typeof ua !== 'string') return false + + var result = opts.tablet ? tabletRE.test(ua) : mobileRE.test(ua) + + if ( + !result && + opts.tablet && + opts.featureDetect && + navigator && + navigator.maxTouchPoints > 1 && + ua.indexOf('Macintosh') !== -1 && + ua.indexOf('Safari') !== -1 + ) { + result = true + } + + return result +} },{}],70:[function(_dereq_,module,exports){ -'use strict'; - -/** - * Is this string all whitespace? - * This solution kind of makes my brain hurt, but it's significantly faster - * than !str.trim() or any other solution I could find. - * - * whitespace codes from: http://en.wikipedia.org/wiki/Whitespace_character - * and verified with: - * - * for(var i = 0; i < 65536; i++) { - * var s = String.fromCharCode(i); - * if(+s===0 && !s.trim()) console.log(i, s); - * } - * - * which counts a couple of these as *not* whitespace, but finds nothing else - * that *is* whitespace. Note that charCodeAt stops at 16 bits, but it appears - * that there are no whitespace characters above this, and code points above - * this do not map onto white space characters. - */ - -module.exports = function(str){ - var l = str.length, - a; - for(var i = 0; i < l; i++) { - a = str.charCodeAt(i); - if((a < 9 || a > 13) && (a !== 32) && (a !== 133) && (a !== 160) && - (a !== 5760) && (a !== 6158) && (a < 8192 || a > 8205) && - (a !== 8232) && (a !== 8233) && (a !== 8239) && (a !== 8287) && - (a !== 8288) && (a !== 12288) && (a !== 65279)) { - return false; - } - } - return true; -} +'use strict'; + +/** + * Is this string all whitespace? + * This solution kind of makes my brain hurt, but it's significantly faster + * than !str.trim() or any other solution I could find. + * + * whitespace codes from: http://en.wikipedia.org/wiki/Whitespace_character + * and verified with: + * + * for(var i = 0; i < 65536; i++) { + * var s = String.fromCharCode(i); + * if(+s===0 && !s.trim()) console.log(i, s); + * } + * + * which counts a couple of these as *not* whitespace, but finds nothing else + * that *is* whitespace. Note that charCodeAt stops at 16 bits, but it appears + * that there are no whitespace characters above this, and code points above + * this do not map onto white space characters. + */ + +module.exports = function(str){ + var l = str.length, + a; + for(var i = 0; i < l; i++) { + a = str.charCodeAt(i); + if((a < 9 || a > 13) && (a !== 32) && (a !== 133) && (a !== 160) && + (a !== 5760) && (a !== 6158) && (a < 8192 || a > 8205) && + (a !== 8232) && (a !== 8233) && (a !== 8239) && (a !== 8287) && + (a !== 8288) && (a !== 12288) && (a !== 65279)) { + return false; + } + } + return true; +} },{}],71:[function(_dereq_,module,exports){ var rootPosition = { left: 0, top: 0 } diff --git a/static/babybuddy/js/tags_editor.6fc8d69c680c.js b/static/babybuddy/js/tags_editor.6fc8d69c680c.js new file mode 100644 index 00000000..56be2277 --- /dev/null +++ b/static/babybuddy/js/tags_editor.6fc8d69c680c.js @@ -0,0 +1,354 @@ +(function() { + /** + * Parse a string as hexadecimal number + */ + function hexParse(x) { + return parseInt(x, 16); + } + + /** + * Get the contrasting color for any hex color + * + * Sourced from: https://vanillajstoolkit.com/helpers/getcontrast/ + * - Modified with slightly softer colors + * (c) 2021 Chris Ferdinandi, MIT License, https://gomakethings.com + * Derived from work by Brian Suda, https://24ways.org/2010/calculating-color-contrast/ + * @param {String} A hexcolor value + * @return {String} The contrasting color (black or white) + */ + function computeComplementaryColor(hexcolor) { + + // If a leading # is provided, remove it + if (hexcolor.slice(0, 1) === '#') { + hexcolor = hexcolor.slice(1); + } + + // If a three-character hexcode, make six-character + if (hexcolor.length === 3) { + hexcolor = hexcolor.split('').map(function (hex) { + return hex + hex; + }).join(''); + } + + // Convert to RGB value + let r = parseInt(hexcolor.substr(0,2),16); + let g = parseInt(hexcolor.substr(2,2),16); + let b = parseInt(hexcolor.substr(4,2),16); + + // Get YIQ ratio + let yiq = ((r * 299) + (g * 587) + (b * 114)) / 1000; + + // Check contrast + return (yiq >= 128) ? '#101010' : '#EFEFEF'; + } + + // CSRF token should always be present because it is auto-included with + // every tag-editor widget + const CSRF_TOKEN = document.querySelector('input[name="csrfmiddlewaretoken"]').value; + + function doReq(method, uri, data, success, fail) { + // TODO: prefer jQuery based requests for now + + const req = new XMLHttpRequest(); + req.addEventListener('load', () => { + if ((req.status >= 200) && (req.status < 300)) { + success(req.responseText, req); + } else { + fail(req.responseText, req); + } + }); + for (const name of ["error", "timeout", "abort"]) { + req.addEventListener(name, () => { + fail(req.responseText, req); + }); + } + req.timeout = 20000; + + req.open(method, uri); + req.setRequestHeader("Content-Type", "application/json"); + req.setRequestHeader("Accept", "application/json"); + req.setRequestHeader("X-CSRFTOKEN", CSRF_TOKEN); + req.send(data); + } + + /** + * Base class allowing generic operations on the tag lists, like: + * + * - Adding tags to a tag list + * - Updating or creating new tags with a set name and color + * - Controlling the error modal + */ + class TaggingBase { + constructor(widget) { + this.prototype = widget.querySelector('.prototype-tag'); + this.listeners = []; + + this.modalElement = widget.querySelector('.tag-editor-error-modal'); + this.modalBodyNode = this.modalElement.querySelector('.modal-body'); + + // Clean whitespace text nodes between spans + for (const n of this.modalBodyNode.childNodes) { + if (n.nodeType === Node.TEXT_NODE) { + this.modalBodyNode.removeChild(n); + } + } + } + + showModal(msg) { + const selectedMessage = this.modalBodyNode.querySelector(`span[data-message='${msg}']`); + if (!selectedMessage) { + selectedMessage = this.modalBodyNode.childNodes[0]; + } + + for (const n of this.modalBodyNode.childNodes) { + n.classList.add('d-none'); + } + selectedMessage.classList.remove('d-none'); + + jQuery(this.modalElement).modal('show'); + } + + addTagListUpdatedListener(c) { + this.listeners.push(c); + } + + callTagListUpdatedListeners() { + for (const l of this.listeners) { + l(); + } + } + + updateTag(tag, name, color, actionSymbol) { + const actionTextNode = tag.querySelector('.add-remove-icon').childNodes[0]; + + name = name || tag.getAttribute("data-value"); + color = color || tag.getAttribute("data-color"); + actionSymbol = actionSymbol || actionTextNode.textContent; + + tag.childNodes[0].textContent = name; + tag.setAttribute("data-value", name); + tag.setAttribute("data-color", color); + + const textColor = computeComplementaryColor(color); + tag.setAttribute('style', `background-color: ${color}; color: ${textColor};`); + actionTextNode.textContent = actionSymbol; + } + + createNewTag(name, color, actionSymbol) { + const tag = this.prototype.cloneNode(true); + tag.classList.remove("prototype-tag"); + tag.classList.add("tag"); + this.updateTag(tag, name, color, actionSymbol); + return tag; + } + + insertTag(list, tag) { + list.appendChild(tag); + this.callTagListUpdatedListeners(); + } + }; + + /** + * Handler for the edit field allowing to dynamically create new tags. + * + * Handles user inputs for the editor. Calls the 'onInsertNewTag' callback + * when the creation of a new tag has been requested. All backend handling + * like guareteening that the requested tag exists is handled by this class, + * the only task left is to add the new tag to the tags-list when + * 'onInsertNewTag' is called. + */ + class AddNewTagControl { + /** + * @param widget + * The root DOM element of the widget + * @param taggingBase + * Reference to a common TaggingBase class to be used by this widget + * @param onInsertNewTag + * Callback that is called when a new tag should be added to the + * tags widget. + */ + constructor(widget, taggingBase, onInsertNewTag) { + this.widget = widget; + this.taggingBase = taggingBase; + + this.apiTagsUrl = widget.getAttribute('data-tags-url'); + this.createTagInputs = widget.querySelector('.create-tag-inputs'); + this.addTagInput = this.createTagInputs.querySelector('input[type="text"]'); + this.addTagButton = this.createTagInputs.querySelector('.btn-add-new-tag'); + + this.addTagInput.value = ""; + + this.onInsertNewTag = onInsertNewTag; + + this.addTagButton.addEventListener('click', () => this.onCreateTagClicked()); + this.addTagInput.addEventListener('keydown', (e) => { + const key = e.key.toLowerCase(); + if (key === "enter") { + e.preventDefault(); + this.onCreateTagClicked(); + } + }); + } + + /** + * Callback called when the the "Add" button of the add-tag input is + * clicked or enter is pressed in the editor. + */ + onCreateTagClicked() { + // TODO: Make promise based + + const tagName = this.addTagInput.value.trim(); + const uriTagName = encodeURIComponent(tagName); + + const fail = (msg) => { + this.addTagInput.select(); + this.taggingBase.showModal(msg || "generic"); + }; + + if (!tagName) { + fail('invalid-tag-name'); + return; + } + + const addTag = (name, color) => { + const tag = this.taggingBase.createNewTag(name, color, "-"); + this.addTagInput.value = ""; + this.onInsertNewTag(tag); + }; + + const data = JSON.stringify({ + 'name': this.addTagInput.value + }); + + doReq("GET", `${this.apiTagsUrl}?name=${uriTagName}`, null, + (text) => { + const json = JSON.parse(text); + if (json.count) { + const tagJson = json.results[0]; + addTag(tagJson.name, tagJson.color); + } else { + doReq("POST", this.apiTagsUrl, data, + (text) => { + const tagJson = JSON.parse(text); + addTag(tagJson.name, tagJson.color); + }, () => fail("tag-creation-failed") + ); + } + }, () => fail("tag-checking-failed") + ); + } + }; + + /** + * JavaScript implementation for the tags editor. + * + * This class uses TaggingBase and AddNewTagControl to provide the custom + * tag editor controls. This mainly consists of updating the hidden + * input values with the current list of tags and adding/removing + * tags from the current-tags- or recently-used-lists. + */ + class TagsEditor { + /** + * @param tagEditorRoot + * The root DOM element of the widget. + */ + constructor(tagEditorRoot) { + this.widget = tagEditorRoot; + this.taggingBase = new TaggingBase(this.widget); + this.addTagControl = new AddNewTagControl( + this.widget, this.taggingBase, (t) => this.insertNewTag(t) + ); + + this.currentTags = this.widget.querySelector('.current_tags'); + this.newTags = this.widget.querySelector('.new-tags'); + this.inputElement = this.widget.querySelector('input[type="hidden"]'); + + for (const tag of this.newTags.querySelectorAll(".tag")) { + this.configureAddTag(tag); + } + for (const tag of this.currentTags.querySelectorAll(".tag")) { + this.configureRemoveTag(tag); + } + + this.updateInputList(); + this.taggingBase.addTagListUpdatedListener( + () => this.updateInputList() + ); + } + + /** + * Insert a new tag into the "current tag" list. + * + * Makes sure that no duplicates are present in the widget before adding + * the new tag. If a duplicate is found, the old tag is removed before + * the new one is added. + */ + insertNewTag(tag) { + const name = tag.getAttribute("data-value"); + + const oldTag = this.widget.querySelector(`span[data-value="${name}"]`); + if (oldTag) { + oldTag.parentNode.removeChild(oldTag); + } + + this.taggingBase.insertTag(this.currentTags, tag); + this.configureRemoveTag(tag); + } + + /** + * Registeres a click-callback for a given node. + * + * The callback chain-calls another callback "onClicked" after + * moving the clicked tag from the old tag-list to a new tag list. + */ + registerNewCallback(tag, newParent, onClicked) { + function callback(event) { + tag.parentNode.removeChild(tag); + this.taggingBase.insertTag(newParent, tag); + + tag.removeEventListener('click', callback); + onClicked(tag); + } + tag.addEventListener('click', callback.bind(this)); + } + + /** + * Updates the value of the hidden input element. + * + * Sets the value from the list of tags added to the currentTags + * DOM element. + */ + updateInputList() { + const names = []; + for (const tag of this.currentTags.querySelectorAll(".tag")) { + const name = tag.getAttribute("data-value"); + names.push(`"${name}"`); + } + this.inputElement.value = names.join(","); + } + + /** + * Configure a tag-DOM element as a "add tag" button. + */ + configureAddTag(tag) { + this.taggingBase.updateTag(tag, null, null, "+"); + this.registerNewCallback(tag, this.currentTags, () => this.configureRemoveTag(tag)); + this.updateInputList(); + } + + /** + * Configure a tag-DOM element as a "remove tag" button. + */ + configureRemoveTag(tag) { + this.taggingBase.updateTag(tag, null, null, "-"); + this.registerNewCallback(tag, this.newTags, () => this.configureAddTag(tag)); + this.updateInputList(); + } + }; + + window.addEventListener('load', () => { + for (const el of document.querySelectorAll('.babybuddy-tags-editor')) { + new TagsEditor(el); + } + }); +})(); \ No newline at end of file diff --git a/static/babybuddy/js/tags_editor.6fc8d69c680c.js.gz b/static/babybuddy/js/tags_editor.6fc8d69c680c.js.gz new file mode 100644 index 00000000..9fb4f030 Binary files /dev/null and b/static/babybuddy/js/tags_editor.6fc8d69c680c.js.gz differ diff --git a/static/babybuddy/js/tags_editor.js b/static/babybuddy/js/tags_editor.js new file mode 100644 index 00000000..56be2277 --- /dev/null +++ b/static/babybuddy/js/tags_editor.js @@ -0,0 +1,354 @@ +(function() { + /** + * Parse a string as hexadecimal number + */ + function hexParse(x) { + return parseInt(x, 16); + } + + /** + * Get the contrasting color for any hex color + * + * Sourced from: https://vanillajstoolkit.com/helpers/getcontrast/ + * - Modified with slightly softer colors + * (c) 2021 Chris Ferdinandi, MIT License, https://gomakethings.com + * Derived from work by Brian Suda, https://24ways.org/2010/calculating-color-contrast/ + * @param {String} A hexcolor value + * @return {String} The contrasting color (black or white) + */ + function computeComplementaryColor(hexcolor) { + + // If a leading # is provided, remove it + if (hexcolor.slice(0, 1) === '#') { + hexcolor = hexcolor.slice(1); + } + + // If a three-character hexcode, make six-character + if (hexcolor.length === 3) { + hexcolor = hexcolor.split('').map(function (hex) { + return hex + hex; + }).join(''); + } + + // Convert to RGB value + let r = parseInt(hexcolor.substr(0,2),16); + let g = parseInt(hexcolor.substr(2,2),16); + let b = parseInt(hexcolor.substr(4,2),16); + + // Get YIQ ratio + let yiq = ((r * 299) + (g * 587) + (b * 114)) / 1000; + + // Check contrast + return (yiq >= 128) ? '#101010' : '#EFEFEF'; + } + + // CSRF token should always be present because it is auto-included with + // every tag-editor widget + const CSRF_TOKEN = document.querySelector('input[name="csrfmiddlewaretoken"]').value; + + function doReq(method, uri, data, success, fail) { + // TODO: prefer jQuery based requests for now + + const req = new XMLHttpRequest(); + req.addEventListener('load', () => { + if ((req.status >= 200) && (req.status < 300)) { + success(req.responseText, req); + } else { + fail(req.responseText, req); + } + }); + for (const name of ["error", "timeout", "abort"]) { + req.addEventListener(name, () => { + fail(req.responseText, req); + }); + } + req.timeout = 20000; + + req.open(method, uri); + req.setRequestHeader("Content-Type", "application/json"); + req.setRequestHeader("Accept", "application/json"); + req.setRequestHeader("X-CSRFTOKEN", CSRF_TOKEN); + req.send(data); + } + + /** + * Base class allowing generic operations on the tag lists, like: + * + * - Adding tags to a tag list + * - Updating or creating new tags with a set name and color + * - Controlling the error modal + */ + class TaggingBase { + constructor(widget) { + this.prototype = widget.querySelector('.prototype-tag'); + this.listeners = []; + + this.modalElement = widget.querySelector('.tag-editor-error-modal'); + this.modalBodyNode = this.modalElement.querySelector('.modal-body'); + + // Clean whitespace text nodes between spans + for (const n of this.modalBodyNode.childNodes) { + if (n.nodeType === Node.TEXT_NODE) { + this.modalBodyNode.removeChild(n); + } + } + } + + showModal(msg) { + const selectedMessage = this.modalBodyNode.querySelector(`span[data-message='${msg}']`); + if (!selectedMessage) { + selectedMessage = this.modalBodyNode.childNodes[0]; + } + + for (const n of this.modalBodyNode.childNodes) { + n.classList.add('d-none'); + } + selectedMessage.classList.remove('d-none'); + + jQuery(this.modalElement).modal('show'); + } + + addTagListUpdatedListener(c) { + this.listeners.push(c); + } + + callTagListUpdatedListeners() { + for (const l of this.listeners) { + l(); + } + } + + updateTag(tag, name, color, actionSymbol) { + const actionTextNode = tag.querySelector('.add-remove-icon').childNodes[0]; + + name = name || tag.getAttribute("data-value"); + color = color || tag.getAttribute("data-color"); + actionSymbol = actionSymbol || actionTextNode.textContent; + + tag.childNodes[0].textContent = name; + tag.setAttribute("data-value", name); + tag.setAttribute("data-color", color); + + const textColor = computeComplementaryColor(color); + tag.setAttribute('style', `background-color: ${color}; color: ${textColor};`); + actionTextNode.textContent = actionSymbol; + } + + createNewTag(name, color, actionSymbol) { + const tag = this.prototype.cloneNode(true); + tag.classList.remove("prototype-tag"); + tag.classList.add("tag"); + this.updateTag(tag, name, color, actionSymbol); + return tag; + } + + insertTag(list, tag) { + list.appendChild(tag); + this.callTagListUpdatedListeners(); + } + }; + + /** + * Handler for the edit field allowing to dynamically create new tags. + * + * Handles user inputs for the editor. Calls the 'onInsertNewTag' callback + * when the creation of a new tag has been requested. All backend handling + * like guareteening that the requested tag exists is handled by this class, + * the only task left is to add the new tag to the tags-list when + * 'onInsertNewTag' is called. + */ + class AddNewTagControl { + /** + * @param widget + * The root DOM element of the widget + * @param taggingBase + * Reference to a common TaggingBase class to be used by this widget + * @param onInsertNewTag + * Callback that is called when a new tag should be added to the + * tags widget. + */ + constructor(widget, taggingBase, onInsertNewTag) { + this.widget = widget; + this.taggingBase = taggingBase; + + this.apiTagsUrl = widget.getAttribute('data-tags-url'); + this.createTagInputs = widget.querySelector('.create-tag-inputs'); + this.addTagInput = this.createTagInputs.querySelector('input[type="text"]'); + this.addTagButton = this.createTagInputs.querySelector('.btn-add-new-tag'); + + this.addTagInput.value = ""; + + this.onInsertNewTag = onInsertNewTag; + + this.addTagButton.addEventListener('click', () => this.onCreateTagClicked()); + this.addTagInput.addEventListener('keydown', (e) => { + const key = e.key.toLowerCase(); + if (key === "enter") { + e.preventDefault(); + this.onCreateTagClicked(); + } + }); + } + + /** + * Callback called when the the "Add" button of the add-tag input is + * clicked or enter is pressed in the editor. + */ + onCreateTagClicked() { + // TODO: Make promise based + + const tagName = this.addTagInput.value.trim(); + const uriTagName = encodeURIComponent(tagName); + + const fail = (msg) => { + this.addTagInput.select(); + this.taggingBase.showModal(msg || "generic"); + }; + + if (!tagName) { + fail('invalid-tag-name'); + return; + } + + const addTag = (name, color) => { + const tag = this.taggingBase.createNewTag(name, color, "-"); + this.addTagInput.value = ""; + this.onInsertNewTag(tag); + }; + + const data = JSON.stringify({ + 'name': this.addTagInput.value + }); + + doReq("GET", `${this.apiTagsUrl}?name=${uriTagName}`, null, + (text) => { + const json = JSON.parse(text); + if (json.count) { + const tagJson = json.results[0]; + addTag(tagJson.name, tagJson.color); + } else { + doReq("POST", this.apiTagsUrl, data, + (text) => { + const tagJson = JSON.parse(text); + addTag(tagJson.name, tagJson.color); + }, () => fail("tag-creation-failed") + ); + } + }, () => fail("tag-checking-failed") + ); + } + }; + + /** + * JavaScript implementation for the tags editor. + * + * This class uses TaggingBase and AddNewTagControl to provide the custom + * tag editor controls. This mainly consists of updating the hidden + * input values with the current list of tags and adding/removing + * tags from the current-tags- or recently-used-lists. + */ + class TagsEditor { + /** + * @param tagEditorRoot + * The root DOM element of the widget. + */ + constructor(tagEditorRoot) { + this.widget = tagEditorRoot; + this.taggingBase = new TaggingBase(this.widget); + this.addTagControl = new AddNewTagControl( + this.widget, this.taggingBase, (t) => this.insertNewTag(t) + ); + + this.currentTags = this.widget.querySelector('.current_tags'); + this.newTags = this.widget.querySelector('.new-tags'); + this.inputElement = this.widget.querySelector('input[type="hidden"]'); + + for (const tag of this.newTags.querySelectorAll(".tag")) { + this.configureAddTag(tag); + } + for (const tag of this.currentTags.querySelectorAll(".tag")) { + this.configureRemoveTag(tag); + } + + this.updateInputList(); + this.taggingBase.addTagListUpdatedListener( + () => this.updateInputList() + ); + } + + /** + * Insert a new tag into the "current tag" list. + * + * Makes sure that no duplicates are present in the widget before adding + * the new tag. If a duplicate is found, the old tag is removed before + * the new one is added. + */ + insertNewTag(tag) { + const name = tag.getAttribute("data-value"); + + const oldTag = this.widget.querySelector(`span[data-value="${name}"]`); + if (oldTag) { + oldTag.parentNode.removeChild(oldTag); + } + + this.taggingBase.insertTag(this.currentTags, tag); + this.configureRemoveTag(tag); + } + + /** + * Registeres a click-callback for a given node. + * + * The callback chain-calls another callback "onClicked" after + * moving the clicked tag from the old tag-list to a new tag list. + */ + registerNewCallback(tag, newParent, onClicked) { + function callback(event) { + tag.parentNode.removeChild(tag); + this.taggingBase.insertTag(newParent, tag); + + tag.removeEventListener('click', callback); + onClicked(tag); + } + tag.addEventListener('click', callback.bind(this)); + } + + /** + * Updates the value of the hidden input element. + * + * Sets the value from the list of tags added to the currentTags + * DOM element. + */ + updateInputList() { + const names = []; + for (const tag of this.currentTags.querySelectorAll(".tag")) { + const name = tag.getAttribute("data-value"); + names.push(`"${name}"`); + } + this.inputElement.value = names.join(","); + } + + /** + * Configure a tag-DOM element as a "add tag" button. + */ + configureAddTag(tag) { + this.taggingBase.updateTag(tag, null, null, "+"); + this.registerNewCallback(tag, this.currentTags, () => this.configureRemoveTag(tag)); + this.updateInputList(); + } + + /** + * Configure a tag-DOM element as a "remove tag" button. + */ + configureRemoveTag(tag) { + this.taggingBase.updateTag(tag, null, null, "-"); + this.registerNewCallback(tag, this.newTags, () => this.configureAddTag(tag)); + this.updateInputList(); + } + }; + + window.addEventListener('load', () => { + for (const el of document.querySelectorAll('.babybuddy-tags-editor')) { + new TagsEditor(el); + } + }); +})(); \ No newline at end of file diff --git a/static/babybuddy/js/tags_editor.js.gz b/static/babybuddy/js/tags_editor.js.gz new file mode 100644 index 00000000..9fb4f030 Binary files /dev/null and b/static/babybuddy/js/tags_editor.js.gz differ diff --git a/static/babybuddy/js/vendor.74d5967d22f1.js b/static/babybuddy/js/vendor.74d5967d22f1.js index a8b0b8aa..21da32ce 100644 --- a/static/babybuddy/js/vendor.74d5967d22f1.js +++ b/static/babybuddy/js/vendor.74d5967d22f1.js @@ -26822,35 +26822,35 @@ return Popper; return moment; })); -/*@preserve - * Tempus Dominus Bootstrap4 v5.1.2 (https://tempusdominus.github.io/bootstrap-4/) - * Copyright 2016-2018 Jonathan Peterson - * Licensed under MIT (https://github.com/tempusdominus/bootstrap-3/blob/master/LICENSE) - */ - -if (typeof jQuery === 'undefined') { - throw new Error('Tempus Dominus Bootstrap4\'s requires jQuery. jQuery must be included before Tempus Dominus Bootstrap4\'s JavaScript.'); -} - -+function ($) { - var version = $.fn.jquery.split(' ')[0].split('.'); - if ((version[0] < 2 && version[1] < 9) || (version[0] === 1 && version[1] === 9 && version[2] < 1) || (version[0] >= 4)) { - throw new Error('Tempus Dominus Bootstrap4\'s requires at least jQuery v3.0.0 but less than v4.0.0'); - } -}(jQuery); - - -if (typeof moment === 'undefined') { - throw new Error('Tempus Dominus Bootstrap4\'s requires moment.js. Moment.js must be included before Tempus Dominus Bootstrap4\'s JavaScript.'); -} - -var version = moment.version.split('.') -if ((version[0] <= 2 && version[1] < 17) || (version[0] >= 3)) { - throw new Error('Tempus Dominus Bootstrap4\'s requires at least moment.js v2.17.0 but less than v3.0.0'); -} - -+function () { - +/*@preserve + * Tempus Dominus Bootstrap4 v5.1.2 (https://tempusdominus.github.io/bootstrap-4/) + * Copyright 2016-2018 Jonathan Peterson + * Licensed under MIT (https://github.com/tempusdominus/bootstrap-3/blob/master/LICENSE) + */ + +if (typeof jQuery === 'undefined') { + throw new Error('Tempus Dominus Bootstrap4\'s requires jQuery. jQuery must be included before Tempus Dominus Bootstrap4\'s JavaScript.'); +} + ++function ($) { + var version = $.fn.jquery.split(' ')[0].split('.'); + if ((version[0] < 2 && version[1] < 9) || (version[0] === 1 && version[1] === 9 && version[2] < 1) || (version[0] >= 4)) { + throw new Error('Tempus Dominus Bootstrap4\'s requires at least jQuery v3.0.0 but less than v4.0.0'); + } +}(jQuery); + + +if (typeof moment === 'undefined') { + throw new Error('Tempus Dominus Bootstrap4\'s requires moment.js. Moment.js must be included before Tempus Dominus Bootstrap4\'s JavaScript.'); +} + +var version = moment.version.split('.') +if ((version[0] <= 2 && version[1] < 17) || (version[0] >= 3)) { + throw new Error('Tempus Dominus Bootstrap4\'s requires at least moment.js v2.17.0 but less than v3.0.0'); +} + ++function () { + var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function (obj) { return typeof obj; } : function (obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }(); @@ -27179,8 +27179,8 @@ var DateTimePicker = function ($, moment) { this._int(); } - /** - * @return {string} + /** + * @return {string} */ @@ -28330,8 +28330,8 @@ var DateTimePicker = function ($, moment) { return NAME; } - /** - * @return {string} + /** + * @return {string} */ }, { @@ -28340,8 +28340,8 @@ var DateTimePicker = function ($, moment) { return DATA_KEY; } - /** - * @return {string} + /** + * @return {string} */ }, { @@ -28350,8 +28350,8 @@ var DateTimePicker = function ($, moment) { return EVENT_KEY; } - /** - * @return {string} + /** + * @return {string} */ }, { @@ -29537,10 +29537,10 @@ var TempusDominusBootstrap4 = function ($) { return TempusDominusBootstrap4; }(DateTimePicker); - /** - * ------------------------------------------------------------------------ - * jQuery - * ------------------------------------------------------------------------ + /** + * ------------------------------------------------------------------------ + * jQuery + * ------------------------------------------------------------------------ */ @@ -29598,6 +29598,6 @@ var TempusDominusBootstrap4 = function ($) { }; return TempusDominusBootstrap4; -}(jQuery); - -}(); +}(jQuery); + +}(); diff --git a/static/staticfiles.json b/static/staticfiles.json index 87653f8a..f28e19d8 100644 --- a/static/staticfiles.json +++ b/static/staticfiles.json @@ -1 +1 @@ -{"paths": {"admin/js/vendor/select2/i18n/cs.js": "admin/js/vendor/select2/i18n/cs.4f43e8e7d33a.js", "admin/js/vendor/select2/i18n/en.js": "admin/js/vendor/select2/i18n/en.cf932ba09a98.js", "admin/js/vendor/select2/i18n/ko.js": "admin/js/vendor/select2/i18n/ko.e7be6c20e673.js", "admin/js/vendor/select2/i18n/dsb.js": "admin/js/vendor/select2/i18n/dsb.56372c92d2f1.js", "admin/js/vendor/select2/i18n/lv.js": "admin/js/vendor/select2/i18n/lv.08e62128eac1.js", "admin/js/vendor/select2/i18n/hsb.js": "admin/js/vendor/select2/i18n/hsb.fa3b55265efe.js", "admin/js/vendor/select2/i18n/km.js": "admin/js/vendor/select2/i18n/km.c23089cb06ca.js", "admin/js/vendor/select2/i18n/pl.js": "admin/js/vendor/select2/i18n/pl.6031b4f16452.js", "admin/js/vendor/select2/i18n/de.js": "admin/js/vendor/select2/i18n/de.8a1c222b0204.js", "admin/js/vendor/select2/i18n/sr.js": "admin/js/vendor/select2/i18n/sr.5ed85a48f483.js", "admin/js/vendor/select2/i18n/ru.js": "admin/js/vendor/select2/i18n/ru.934aa95f5b5f.js", "admin/js/vendor/select2/i18n/he.js": "admin/js/vendor/select2/i18n/he.e420ff6cd3ed.js", "admin/js/vendor/select2/i18n/el.js": "admin/js/vendor/select2/i18n/el.27097f071856.js", "admin/js/vendor/select2/i18n/pt-BR.js": "admin/js/vendor/select2/i18n/pt-BR.e1b294433e7f.js", "admin/js/vendor/select2/i18n/uk.js": "admin/js/vendor/select2/i18n/uk.8cede7f4803c.js", "admin/js/vendor/select2/i18n/nl.js": "admin/js/vendor/select2/i18n/nl.997868a37ed8.js", "admin/js/vendor/select2/i18n/sv.js": "admin/js/vendor/select2/i18n/sv.7a9c2f71e777.js", "admin/js/vendor/select2/i18n/mk.js": "admin/js/vendor/select2/i18n/mk.dabbb9087130.js", "admin/js/vendor/select2/i18n/bg.js": "admin/js/vendor/select2/i18n/bg.39b8be30d4f0.js", "admin/js/vendor/select2/i18n/zh-CN.js": "admin/js/vendor/select2/i18n/zh-CN.2cff662ec5f9.js", "admin/js/vendor/select2/i18n/vi.js": "admin/js/vendor/select2/i18n/vi.097a5b75b3e1.js", "admin/js/vendor/select2/i18n/tr.js": "admin/js/vendor/select2/i18n/tr.b5a0643d1545.js", "admin/js/vendor/select2/i18n/tk.js": "admin/js/vendor/select2/i18n/tk.7c572a68c78f.js", "admin/js/vendor/select2/i18n/fr.js": "admin/js/vendor/select2/i18n/fr.05e0542fcfe6.js", "admin/js/vendor/select2/i18n/gl.js": "admin/js/vendor/select2/i18n/gl.d99b1fedaa86.js", "admin/js/vendor/select2/i18n/ps.js": "admin/js/vendor/select2/i18n/ps.38dfa47af9e0.js", "admin/js/vendor/select2/i18n/hr.js": "admin/js/vendor/select2/i18n/hr.a2b092cc1147.js", "admin/js/vendor/select2/i18n/eu.js": "admin/js/vendor/select2/i18n/eu.adfe5c97b72c.js", "admin/js/vendor/select2/i18n/ar.js": "admin/js/vendor/select2/i18n/ar.65aa8e36bf5d.js", "admin/js/vendor/select2/i18n/af.js": "admin/js/vendor/select2/i18n/af.4f6fcd73488c.js", "admin/js/vendor/select2/i18n/zh-TW.js": "admin/js/vendor/select2/i18n/zh-TW.04554a227c2b.js", "admin/js/vendor/select2/i18n/ka.js": "admin/js/vendor/select2/i18n/ka.2083264a54f0.js", "admin/js/vendor/select2/i18n/th.js": "admin/js/vendor/select2/i18n/th.f38c20b0221b.js", "admin/js/vendor/select2/i18n/sr-Cyrl.js": "admin/js/vendor/select2/i18n/sr-Cyrl.f254bb8c4c7c.js", "admin/js/vendor/select2/i18n/lt.js": "admin/js/vendor/select2/i18n/lt.23c7ce903300.js", "admin/js/vendor/select2/i18n/hy.js": "admin/js/vendor/select2/i18n/hy.c7babaeef5a6.js", "admin/js/vendor/select2/i18n/sq.js": "admin/js/vendor/select2/i18n/sq.5636b60d29c9.js", "admin/js/vendor/select2/i18n/et.js": "admin/js/vendor/select2/i18n/et.2b96fd98289d.js", "admin/js/vendor/select2/i18n/sl.js": "admin/js/vendor/select2/i18n/sl.131a78bc0752.js", "admin/js/vendor/select2/i18n/bs.js": "admin/js/vendor/select2/i18n/bs.91624382358e.js", "admin/js/vendor/select2/i18n/fi.js": "admin/js/vendor/select2/i18n/fi.614ec42aa9ba.js", "admin/js/vendor/select2/i18n/hi.js": "admin/js/vendor/select2/i18n/hi.70640d41628f.js", "admin/js/vendor/select2/i18n/ja.js": "admin/js/vendor/select2/i18n/ja.170ae885d74f.js", "admin/js/vendor/select2/i18n/ne.js": "admin/js/vendor/select2/i18n/ne.3d79fd3f08db.js", "admin/js/vendor/select2/i18n/sk.js": "admin/js/vendor/select2/i18n/sk.33d02cef8d11.js", "admin/js/vendor/select2/i18n/da.js": "admin/js/vendor/select2/i18n/da.766346afe4dd.js", "admin/js/vendor/select2/i18n/id.js": "admin/js/vendor/select2/i18n/id.04debded514d.js", "admin/js/vendor/select2/i18n/es.js": "admin/js/vendor/select2/i18n/es.66dbc2652fb1.js", "admin/js/vendor/select2/i18n/it.js": "admin/js/vendor/select2/i18n/it.be4fe8d365b5.js", "admin/js/vendor/select2/i18n/hu.js": "admin/js/vendor/select2/i18n/hu.6ec6039cb8a3.js", "admin/js/vendor/select2/i18n/bn.js": "admin/js/vendor/select2/i18n/bn.6d42b4dd5665.js", "admin/js/vendor/select2/i18n/ms.js": "admin/js/vendor/select2/i18n/ms.4ba82c9a51ce.js", "admin/js/vendor/select2/i18n/fa.js": "admin/js/vendor/select2/i18n/fa.3b5bd1961cfd.js", "admin/js/vendor/select2/i18n/nb.js": "admin/js/vendor/select2/i18n/nb.da2fce143f27.js", "admin/js/vendor/select2/i18n/az.js": "admin/js/vendor/select2/i18n/az.270c257daf81.js", "admin/js/vendor/select2/i18n/ca.js": "admin/js/vendor/select2/i18n/ca.a166b745933a.js", "admin/js/vendor/select2/i18n/ro.js": "admin/js/vendor/select2/i18n/ro.f75cb460ec3b.js", "admin/js/vendor/select2/i18n/pt.js": "admin/js/vendor/select2/i18n/pt.33b4a3b44d43.js", "admin/js/vendor/select2/i18n/is.js": "admin/js/vendor/select2/i18n/is.3ddd9a6a97e9.js", "admin/js/vendor/xregexp/LICENSE.txt": "admin/js/vendor/xregexp/LICENSE.bf79e414957a.txt", "admin/js/vendor/xregexp/xregexp.min.js": "admin/js/vendor/xregexp/xregexp.min.b0439563a5d3.js", "admin/js/vendor/xregexp/xregexp.js": "admin/js/vendor/xregexp/xregexp.efda034b9537.js", "admin/js/vendor/select2/LICENSE.md": "admin/js/vendor/select2/LICENSE.f94142512c91.md", "admin/js/vendor/select2/select2.full.js": "admin/js/vendor/select2/select2.full.c2afdeda3058.js", "admin/js/vendor/select2/select2.full.min.js": "admin/js/vendor/select2/select2.full.min.fcd7500d8e13.js", "admin/js/vendor/jquery/jquery.js": "admin/js/vendor/jquery/jquery.2849239b95f5.js", "admin/js/vendor/jquery/LICENSE.txt": "admin/js/vendor/jquery/LICENSE.de877aa6d744.txt", "admin/js/vendor/jquery/jquery.min.js": "admin/js/vendor/jquery/jquery.min.8fb8fee4fcc3.js", "admin/css/vendor/select2/select2.min.css": "admin/css/vendor/select2/select2.min.9f54e6414f87.css", "admin/css/vendor/select2/select2.css": "admin/css/vendor/select2/select2.a2194c262648.css", "admin/css/vendor/select2/LICENSE-SELECT2.md": "admin/css/vendor/select2/LICENSE-SELECT2.f94142512c91.md", "babybuddy/img/core/child-placeholder.png": "babybuddy/img/core/child-placeholder.7c0a81f0d7f0.png", "rest_framework/docs/js/jquery.json-view.min.js": "rest_framework/docs/js/jquery.json-view.min.b7c2d6981377.js", "rest_framework/docs/js/highlight.pack.js": "rest_framework/docs/js/highlight.pack.479b5f21dcba.js", "rest_framework/docs/js/api.js": "rest_framework/docs/js/api.c9743eab7a4f.js", "rest_framework/docs/css/base.css": "rest_framework/docs/css/base.e630f8f4990e.css", "rest_framework/docs/css/jquery.json-view.min.css": "rest_framework/docs/css/jquery.json-view.min.a2e6beeb6710.css", "rest_framework/docs/css/highlight.css": "rest_framework/docs/css/highlight.e0e4d973c6d7.css", "rest_framework/docs/img/grid.png": "rest_framework/docs/img/grid.a4b938cf382b.png", "rest_framework/docs/img/favicon.ico": "rest_framework/docs/img/favicon.5195b4d0f3eb.ico", "admin/js/admin/DateTimeShortcuts.js": "admin/js/admin/DateTimeShortcuts.5548f99471bf.js", "admin/js/admin/RelatedObjectLookups.js": "admin/js/admin/RelatedObjectLookups.b4d76b6aaf0b.js", "admin/img/gis/move_vertex_on.svg": "admin/img/gis/move_vertex_on.0047eba25b67.svg", "admin/img/gis/move_vertex_off.svg": "admin/img/gis/move_vertex_off.7a23bf31ef8a.svg", "babybuddy/logo/icon-brand.png": "babybuddy/logo/icon-brand.32cbedf6aee3.png", "babybuddy/logo/logo-sad.png": "babybuddy/logo/logo-sad.47c3d5c2d397.png", "babybuddy/logo/logo.png": "babybuddy/logo/logo.62870041cc83.png", "babybuddy/logo/icon.png": "babybuddy/logo/icon.df80640f0465.png", "babybuddy/js/app.js": "babybuddy/js/app.e8f1f5e0f058.js", "babybuddy/js/graph.js": "babybuddy/js/graph.0a43abb3165d.js", "babybuddy/js/vendor.js": "babybuddy/js/vendor.74d5967d22f1.js", "babybuddy/css/app.css": "babybuddy/css/app.ce8ee645797d.css", "babybuddy/root/site.webmanifest": "babybuddy/root/site.c6c4158e40df.webmanifest", "babybuddy/root/favicon.ico": "babybuddy/root/favicon.ee5ebcd40fb9.ico", "babybuddy/root/apple-touch-startup-image.png": "babybuddy/root/apple-touch-startup-image.749726217484.png", "babybuddy/root/favicon.svg": "babybuddy/root/favicon.12fe726d0bac.svg", "babybuddy/root/android-chrome-512x512.png": "babybuddy/root/android-chrome-512x512.e1fd38ad828c.png", "babybuddy/root/mstile-150x150.png": "babybuddy/root/mstile-150x150.08524a406cf2.png", "babybuddy/root/apple-touch-icon.png": "babybuddy/root/apple-touch-icon.bdc75cec89fa.png", "babybuddy/root/safari-pinned-tab.svg": "babybuddy/root/safari-pinned-tab.e8c8ac2f55f5.svg", "babybuddy/root/android-chrome-192x192.png": "babybuddy/root/android-chrome-192x192.ac7d2baba4df.png", "babybuddy/root/browserconfig.xml": "babybuddy/root/browserconfig.84708aade0e5.xml", "babybuddy/font/babybuddy.ttf": "babybuddy/font/babybuddy.6a28258108bf.ttf", "babybuddy/font/babybuddy.eot": "babybuddy/font/babybuddy.3f3aedcf5bcc.eot", "babybuddy/font/babybuddy.svg": "babybuddy/font/babybuddy.6fefdbe0c0bb.svg", "babybuddy/font/babybuddy.woff2": "babybuddy/font/babybuddy.1d7ccc385d88.woff2", "babybuddy/font/babybuddy.woff": "babybuddy/font/babybuddy.3825bc26641d.woff", "rest_framework/js/prettify-min.js": "rest_framework/js/prettify-min.709bfcc456c6.js", "rest_framework/js/default.js": "rest_framework/js/default.5b08897dbdc3.js", "rest_framework/js/jquery-3.5.1.min.js": "rest_framework/js/jquery-3.5.1.min.dc5e7f18c8d3.js", "rest_framework/js/csrf.js": "rest_framework/js/csrf.969930007329.js", "rest_framework/js/bootstrap.min.js": "rest_framework/js/bootstrap.min.2f34b630ffe3.js", "rest_framework/js/ajax-form.js": "rest_framework/js/ajax-form.0ea6e6052ab5.js", "rest_framework/js/coreapi-0.1.1.js": "rest_framework/js/coreapi-0.1.1.e580e3854595.js", "rest_framework/css/prettify.css": "rest_framework/css/prettify.a987f72342ee.css", "rest_framework/css/font-awesome-4.0.3.css": "rest_framework/css/font-awesome-4.0.3.c1e1ea213abf.css", "rest_framework/css/bootstrap-tweaks.css": "rest_framework/css/bootstrap-tweaks.46ed116b0edd.css", "rest_framework/css/default.css": "rest_framework/css/default.789dfb5732d7.css", "rest_framework/css/bootstrap-theme.min.css": "rest_framework/css/bootstrap-theme.min.66b84a04375e.css", "rest_framework/css/bootstrap.min.css": "rest_framework/css/bootstrap.min.77017a69879a.css", "rest_framework/fonts/fontawesome-webfont.eot": "rest_framework/fonts/fontawesome-webfont.8b27bc96115c.eot", "rest_framework/fonts/glyphicons-halflings-regular.svg": "rest_framework/fonts/glyphicons-halflings-regular.08eda92397ae.svg", "rest_framework/fonts/glyphicons-halflings-regular.woff2": "rest_framework/fonts/glyphicons-halflings-regular.448c34a56d69.woff2", "rest_framework/fonts/glyphicons-halflings-regular.ttf": "rest_framework/fonts/glyphicons-halflings-regular.e18bbf611f2a.ttf", "rest_framework/fonts/fontawesome-webfont.ttf": "rest_framework/fonts/fontawesome-webfont.dcb26c7239d8.ttf", "rest_framework/fonts/fontawesome-webfont.svg": "rest_framework/fonts/fontawesome-webfont.83e37a11f9d7.svg", "rest_framework/fonts/glyphicons-halflings-regular.eot": "rest_framework/fonts/glyphicons-halflings-regular.f4769f9bdb74.eot", "rest_framework/fonts/glyphicons-halflings-regular.woff": "rest_framework/fonts/glyphicons-halflings-regular.fa2772327f55.woff", "rest_framework/fonts/fontawesome-webfont.woff": "rest_framework/fonts/fontawesome-webfont.3293616ec0c6.woff", "rest_framework/img/grid.png": "rest_framework/img/grid.a4b938cf382b.png", "rest_framework/img/glyphicons-halflings.png": "rest_framework/img/glyphicons-halflings.90233c9067e9.png", "rest_framework/img/glyphicons-halflings-white.png": "rest_framework/img/glyphicons-halflings-white.9bbc6e960299.png", "admin/js/calendar.js": "admin/js/calendar.f8a5d055eb33.js", "admin/js/SelectBox.js": "admin/js/SelectBox.8161741c7647.js", "admin/js/urlify.js": "admin/js/urlify.25cc3eac8123.js", "admin/js/popup_response.js": "admin/js/popup_response.c6cc78ea5551.js", "admin/js/autocomplete.js": "admin/js/autocomplete.c508b167ab61.js", "admin/js/collapse.js": "admin/js/collapse.f84e7410290f.js", "admin/js/change_form.js": "admin/js/change_form.9d8ca4f96b75.js", "admin/js/jquery.init.js": "admin/js/jquery.init.b7781a0897fc.js", "admin/js/actions.js": "admin/js/actions.eac7e3441574.js", "admin/js/prepopulate_init.js": "admin/js/prepopulate_init.e056047b7a7e.js", "admin/js/inlines.js": "admin/js/inlines.fb1617228dbe.js", "admin/js/prepopulate.js": "admin/js/prepopulate.bd2361dfd64d.js", "admin/js/cancel.js": "admin/js/cancel.ecc4c5ca7b32.js", "admin/js/SelectFilter2.js": "admin/js/SelectFilter2.d250dcb52a9a.js", "admin/js/nav_sidebar.js": "admin/js/nav_sidebar.3535caba9444.js", "admin/js/core.js": "admin/js/core.5d6b384a08b5.js", "admin/css/changelists.css": "admin/css/changelists.cd4dd90ae1a1.css", "admin/css/login.css": "admin/css/login.8b76a9f7cbf6.css", "admin/css/fonts.css": "admin/css/fonts.168bab448fee.css", "admin/css/responsive.css": "admin/css/responsive.b9e1565b3609.css", "admin/css/nav_sidebar.css": "admin/css/nav_sidebar.e32d345464bd.css", "admin/css/autocomplete.css": "admin/css/autocomplete.4a81fc4242d0.css", "admin/css/base.css": "admin/css/base.1f418065fc2c.css", "admin/css/widgets.css": "admin/css/widgets.694d845b2cb1.css", "admin/css/forms.css": "admin/css/forms.332ab41432e2.css", "admin/css/dashboard.css": "admin/css/dashboard.be83f13e4369.css", "admin/css/rtl.css": "admin/css/rtl.4bc23eb90919.css", "admin/css/responsive_rtl.css": "admin/css/responsive_rtl.e13ae754cceb.css", "admin/fonts/LICENSE.txt": "admin/fonts/LICENSE.d273d63619c9.txt", "admin/fonts/Roboto-Bold-webfont.woff": "admin/fonts/Roboto-Bold-webfont.50d75e48e0a3.woff", "admin/fonts/Roboto-Light-webfont.woff": "admin/fonts/Roboto-Light-webfont.c73eb1ceba33.woff", "admin/fonts/README.txt": "admin/fonts/README.ab99e6b541ea.txt", "admin/fonts/Roboto-Regular-webfont.woff": "admin/fonts/Roboto-Regular-webfont.35b07eb2f871.woff", "admin/img/icon-changelink.svg": "admin/img/icon-changelink.18d2fd706348.svg", "admin/img/icon-no.svg": "admin/img/icon-no.439e821418cd.svg", "admin/img/selector-icons.svg": "admin/img/selector-icons.b4555096cea2.svg", "admin/img/search.svg": "admin/img/search.7cf54ff789c6.svg", "admin/img/icon-yes.svg": "admin/img/icon-yes.d2f9f035226a.svg", "admin/img/icon-addlink.svg": "admin/img/icon-addlink.d519b3bab011.svg", "admin/img/icon-clock.svg": "admin/img/icon-clock.e1d4dfac3f2b.svg", "admin/img/LICENSE": "admin/img/LICENSE.2c54f4e1ca1c", "admin/img/icon-viewlink.svg": "admin/img/icon-viewlink.41eb31f7826e.svg", "admin/img/icon-unknown.svg": "admin/img/icon-unknown.a18cb4398978.svg", "admin/img/README.txt": "admin/img/README.a70711a38d87.txt", "admin/img/icon-calendar.svg": "admin/img/icon-calendar.ac7aea671bea.svg", "admin/img/sorting-icons.svg": "admin/img/sorting-icons.3a097b59f104.svg", "admin/img/tooltag-arrowright.svg": "admin/img/tooltag-arrowright.bbfb788a849e.svg", "admin/img/icon-deletelink.svg": "admin/img/icon-deletelink.564ef9dc3854.svg", "admin/img/tooltag-add.svg": "admin/img/tooltag-add.e59d620a9742.svg", "admin/img/inline-delete.svg": "admin/img/inline-delete.fec1b761f254.svg", "admin/img/icon-alert.svg": "admin/img/icon-alert.034cc7d8a67f.svg", "admin/img/icon-unknown-alt.svg": "admin/img/icon-unknown-alt.81536e128bb6.svg", "admin/img/calendar-icons.svg": "admin/img/calendar-icons.39b290681a8b.svg", "import_export/action_formats.js": "import_export/action_formats.11c3e817b80a.js", "import_export/import.css": "import_export/import.358144dd8713.css"}, "version": "1.0"} \ No newline at end of file +{"paths": {"admin/js/vendor/select2/i18n/ko.js": "admin/js/vendor/select2/i18n/ko.e7be6c20e673.js", "admin/js/vendor/select2/i18n/ne.js": "admin/js/vendor/select2/i18n/ne.3d79fd3f08db.js", "admin/js/vendor/select2/i18n/cs.js": "admin/js/vendor/select2/i18n/cs.4f43e8e7d33a.js", "admin/js/vendor/select2/i18n/sq.js": "admin/js/vendor/select2/i18n/sq.5636b60d29c9.js", "admin/js/vendor/select2/i18n/km.js": "admin/js/vendor/select2/i18n/km.c23089cb06ca.js", "admin/js/vendor/select2/i18n/tk.js": "admin/js/vendor/select2/i18n/tk.7c572a68c78f.js", "admin/js/vendor/select2/i18n/gl.js": "admin/js/vendor/select2/i18n/gl.d99b1fedaa86.js", "admin/js/vendor/select2/i18n/he.js": "admin/js/vendor/select2/i18n/he.e420ff6cd3ed.js", "admin/js/vendor/select2/i18n/zh-CN.js": "admin/js/vendor/select2/i18n/zh-CN.2cff662ec5f9.js", "admin/js/vendor/select2/i18n/ja.js": "admin/js/vendor/select2/i18n/ja.170ae885d74f.js", "admin/js/vendor/select2/i18n/lv.js": "admin/js/vendor/select2/i18n/lv.08e62128eac1.js", "admin/js/vendor/select2/i18n/eu.js": "admin/js/vendor/select2/i18n/eu.adfe5c97b72c.js", "admin/js/vendor/select2/i18n/et.js": "admin/js/vendor/select2/i18n/et.2b96fd98289d.js", "admin/js/vendor/select2/i18n/af.js": "admin/js/vendor/select2/i18n/af.4f6fcd73488c.js", "admin/js/vendor/select2/i18n/ka.js": "admin/js/vendor/select2/i18n/ka.2083264a54f0.js", "admin/js/vendor/select2/i18n/nl.js": "admin/js/vendor/select2/i18n/nl.997868a37ed8.js", "admin/js/vendor/select2/i18n/id.js": "admin/js/vendor/select2/i18n/id.04debded514d.js", "admin/js/vendor/select2/i18n/pl.js": "admin/js/vendor/select2/i18n/pl.6031b4f16452.js", "admin/js/vendor/select2/i18n/sv.js": "admin/js/vendor/select2/i18n/sv.7a9c2f71e777.js", "admin/js/vendor/select2/i18n/az.js": "admin/js/vendor/select2/i18n/az.270c257daf81.js", "admin/js/vendor/select2/i18n/bs.js": "admin/js/vendor/select2/i18n/bs.91624382358e.js", "admin/js/vendor/select2/i18n/mk.js": "admin/js/vendor/select2/i18n/mk.dabbb9087130.js", "admin/js/vendor/select2/i18n/pt.js": "admin/js/vendor/select2/i18n/pt.33b4a3b44d43.js", "admin/js/vendor/select2/i18n/vi.js": "admin/js/vendor/select2/i18n/vi.097a5b75b3e1.js", "admin/js/vendor/select2/i18n/tr.js": "admin/js/vendor/select2/i18n/tr.b5a0643d1545.js", "admin/js/vendor/select2/i18n/es.js": "admin/js/vendor/select2/i18n/es.66dbc2652fb1.js", "admin/js/vendor/select2/i18n/ca.js": "admin/js/vendor/select2/i18n/ca.a166b745933a.js", "admin/js/vendor/select2/i18n/en.js": "admin/js/vendor/select2/i18n/en.cf932ba09a98.js", "admin/js/vendor/select2/i18n/it.js": "admin/js/vendor/select2/i18n/it.be4fe8d365b5.js", "admin/js/vendor/select2/i18n/hu.js": "admin/js/vendor/select2/i18n/hu.6ec6039cb8a3.js", "admin/js/vendor/select2/i18n/ps.js": "admin/js/vendor/select2/i18n/ps.38dfa47af9e0.js", "admin/js/vendor/select2/i18n/is.js": "admin/js/vendor/select2/i18n/is.3ddd9a6a97e9.js", "admin/js/vendor/select2/i18n/sr-Cyrl.js": "admin/js/vendor/select2/i18n/sr-Cyrl.f254bb8c4c7c.js", "admin/js/vendor/select2/i18n/th.js": "admin/js/vendor/select2/i18n/th.f38c20b0221b.js", "admin/js/vendor/select2/i18n/hsb.js": "admin/js/vendor/select2/i18n/hsb.fa3b55265efe.js", "admin/js/vendor/select2/i18n/fa.js": "admin/js/vendor/select2/i18n/fa.3b5bd1961cfd.js", "admin/js/vendor/select2/i18n/ru.js": "admin/js/vendor/select2/i18n/ru.934aa95f5b5f.js", "admin/js/vendor/select2/i18n/hr.js": "admin/js/vendor/select2/i18n/hr.a2b092cc1147.js", "admin/js/vendor/select2/i18n/sl.js": "admin/js/vendor/select2/i18n/sl.131a78bc0752.js", "admin/js/vendor/select2/i18n/hy.js": "admin/js/vendor/select2/i18n/hy.c7babaeef5a6.js", "admin/js/vendor/select2/i18n/pt-BR.js": "admin/js/vendor/select2/i18n/pt-BR.e1b294433e7f.js", "admin/js/vendor/select2/i18n/zh-TW.js": "admin/js/vendor/select2/i18n/zh-TW.04554a227c2b.js", "admin/js/vendor/select2/i18n/da.js": "admin/js/vendor/select2/i18n/da.766346afe4dd.js", "admin/js/vendor/select2/i18n/dsb.js": "admin/js/vendor/select2/i18n/dsb.56372c92d2f1.js", "admin/js/vendor/select2/i18n/sr.js": "admin/js/vendor/select2/i18n/sr.5ed85a48f483.js", "admin/js/vendor/select2/i18n/bg.js": "admin/js/vendor/select2/i18n/bg.39b8be30d4f0.js", "admin/js/vendor/select2/i18n/bn.js": "admin/js/vendor/select2/i18n/bn.6d42b4dd5665.js", "admin/js/vendor/select2/i18n/hi.js": "admin/js/vendor/select2/i18n/hi.70640d41628f.js", "admin/js/vendor/select2/i18n/ro.js": "admin/js/vendor/select2/i18n/ro.f75cb460ec3b.js", "admin/js/vendor/select2/i18n/de.js": "admin/js/vendor/select2/i18n/de.8a1c222b0204.js", "admin/js/vendor/select2/i18n/uk.js": "admin/js/vendor/select2/i18n/uk.8cede7f4803c.js", "admin/js/vendor/select2/i18n/lt.js": "admin/js/vendor/select2/i18n/lt.23c7ce903300.js", "admin/js/vendor/select2/i18n/ar.js": "admin/js/vendor/select2/i18n/ar.65aa8e36bf5d.js", "admin/js/vendor/select2/i18n/ms.js": "admin/js/vendor/select2/i18n/ms.4ba82c9a51ce.js", "admin/js/vendor/select2/i18n/fr.js": "admin/js/vendor/select2/i18n/fr.05e0542fcfe6.js", "admin/js/vendor/select2/i18n/el.js": "admin/js/vendor/select2/i18n/el.27097f071856.js", "admin/js/vendor/select2/i18n/nb.js": "admin/js/vendor/select2/i18n/nb.da2fce143f27.js", "admin/js/vendor/select2/i18n/sk.js": "admin/js/vendor/select2/i18n/sk.33d02cef8d11.js", "admin/js/vendor/select2/i18n/fi.js": "admin/js/vendor/select2/i18n/fi.614ec42aa9ba.js", "admin/css/vendor/select2/select2.css": "admin/css/vendor/select2/select2.a2194c262648.css", "admin/css/vendor/select2/LICENSE-SELECT2.md": "admin/css/vendor/select2/LICENSE-SELECT2.f94142512c91.md", "admin/css/vendor/select2/select2.min.css": "admin/css/vendor/select2/select2.min.9f54e6414f87.css", "admin/js/vendor/jquery/jquery.js": "admin/js/vendor/jquery/jquery.2849239b95f5.js", "admin/js/vendor/jquery/LICENSE.txt": "admin/js/vendor/jquery/LICENSE.de877aa6d744.txt", "admin/js/vendor/jquery/jquery.min.js": "admin/js/vendor/jquery/jquery.min.8fb8fee4fcc3.js", "admin/js/vendor/xregexp/xregexp.min.js": "admin/js/vendor/xregexp/xregexp.min.b0439563a5d3.js", "admin/js/vendor/xregexp/LICENSE.txt": "admin/js/vendor/xregexp/LICENSE.bf79e414957a.txt", "admin/js/vendor/xregexp/xregexp.js": "admin/js/vendor/xregexp/xregexp.efda034b9537.js", "admin/js/vendor/select2/select2.full.min.js": "admin/js/vendor/select2/select2.full.min.fcd7500d8e13.js", "admin/js/vendor/select2/select2.full.js": "admin/js/vendor/select2/select2.full.c2afdeda3058.js", "admin/js/vendor/select2/LICENSE.md": "admin/js/vendor/select2/LICENSE.f94142512c91.md", "babybuddy/img/core/child-placeholder.png": "babybuddy/img/core/child-placeholder.7c0a81f0d7f0.png", "rest_framework/docs/img/favicon.ico": "rest_framework/docs/img/favicon.5195b4d0f3eb.ico", "rest_framework/docs/img/grid.png": "rest_framework/docs/img/grid.a4b938cf382b.png", "rest_framework/docs/css/jquery.json-view.min.css": "rest_framework/docs/css/jquery.json-view.min.a2e6beeb6710.css", "rest_framework/docs/css/base.css": "rest_framework/docs/css/base.e630f8f4990e.css", "rest_framework/docs/css/highlight.css": "rest_framework/docs/css/highlight.e0e4d973c6d7.css", "rest_framework/docs/js/highlight.pack.js": "rest_framework/docs/js/highlight.pack.479b5f21dcba.js", "rest_framework/docs/js/api.js": "rest_framework/docs/js/api.c9743eab7a4f.js", "rest_framework/docs/js/jquery.json-view.min.js": "rest_framework/docs/js/jquery.json-view.min.b7c2d6981377.js", "admin/img/gis/move_vertex_off.svg": "admin/img/gis/move_vertex_off.7a23bf31ef8a.svg", "admin/img/gis/move_vertex_on.svg": "admin/img/gis/move_vertex_on.0047eba25b67.svg", "admin/js/admin/DateTimeShortcuts.js": "admin/js/admin/DateTimeShortcuts.5548f99471bf.js", "admin/js/admin/RelatedObjectLookups.js": "admin/js/admin/RelatedObjectLookups.b4d76b6aaf0b.js", "babybuddy/css/app.css": "babybuddy/css/app.4a965921898b.css", "babybuddy/logo/logo.png": "babybuddy/logo/logo.62870041cc83.png", "babybuddy/logo/icon.png": "babybuddy/logo/icon.df80640f0465.png", "babybuddy/logo/logo-sad.png": "babybuddy/logo/logo-sad.47c3d5c2d397.png", "babybuddy/logo/icon-brand.png": "babybuddy/logo/icon-brand.32cbedf6aee3.png", "babybuddy/font/babybuddy.woff2": "babybuddy/font/babybuddy.1d7ccc385d88.woff2", "babybuddy/font/babybuddy.woff": "babybuddy/font/babybuddy.3825bc26641d.woff", "babybuddy/font/babybuddy.svg": "babybuddy/font/babybuddy.6fefdbe0c0bb.svg", "babybuddy/font/babybuddy.eot": "babybuddy/font/babybuddy.3f3aedcf5bcc.eot", "babybuddy/font/babybuddy.ttf": "babybuddy/font/babybuddy.6a28258108bf.ttf", "babybuddy/js/vendor.js": "babybuddy/js/vendor.74d5967d22f1.js", "babybuddy/js/app.js": "babybuddy/js/app.e8f1f5e0f058.js", "babybuddy/js/graph.js": "babybuddy/js/graph.0a43abb3165d.js", "babybuddy/js/tags_editor.js": "babybuddy/js/tags_editor.6fc8d69c680c.js", "babybuddy/root/android-chrome-192x192.png": "babybuddy/root/android-chrome-192x192.ac7d2baba4df.png", "babybuddy/root/android-chrome-512x512.png": "babybuddy/root/android-chrome-512x512.e1fd38ad828c.png", "babybuddy/root/browserconfig.xml": "babybuddy/root/browserconfig.84708aade0e5.xml", "babybuddy/root/favicon.svg": "babybuddy/root/favicon.12fe726d0bac.svg", "babybuddy/root/apple-touch-icon.png": "babybuddy/root/apple-touch-icon.bdc75cec89fa.png", "babybuddy/root/favicon.ico": "babybuddy/root/favicon.ee5ebcd40fb9.ico", "babybuddy/root/mstile-150x150.png": "babybuddy/root/mstile-150x150.08524a406cf2.png", "babybuddy/root/safari-pinned-tab.svg": "babybuddy/root/safari-pinned-tab.e8c8ac2f55f5.svg", "babybuddy/root/apple-touch-startup-image.png": "babybuddy/root/apple-touch-startup-image.749726217484.png", "babybuddy/root/site.webmanifest": "babybuddy/root/site.c6c4158e40df.webmanifest", "rest_framework/fonts/fontawesome-webfont.ttf": "rest_framework/fonts/fontawesome-webfont.dcb26c7239d8.ttf", "rest_framework/fonts/fontawesome-webfont.svg": "rest_framework/fonts/fontawesome-webfont.83e37a11f9d7.svg", "rest_framework/fonts/fontawesome-webfont.eot": "rest_framework/fonts/fontawesome-webfont.8b27bc96115c.eot", "rest_framework/fonts/fontawesome-webfont.woff": "rest_framework/fonts/fontawesome-webfont.3293616ec0c6.woff", "rest_framework/fonts/glyphicons-halflings-regular.woff": "rest_framework/fonts/glyphicons-halflings-regular.fa2772327f55.woff", "rest_framework/fonts/glyphicons-halflings-regular.svg": "rest_framework/fonts/glyphicons-halflings-regular.08eda92397ae.svg", "rest_framework/fonts/glyphicons-halflings-regular.woff2": "rest_framework/fonts/glyphicons-halflings-regular.448c34a56d69.woff2", "rest_framework/fonts/glyphicons-halflings-regular.eot": "rest_framework/fonts/glyphicons-halflings-regular.f4769f9bdb74.eot", "rest_framework/fonts/glyphicons-halflings-regular.ttf": "rest_framework/fonts/glyphicons-halflings-regular.e18bbf611f2a.ttf", "rest_framework/img/glyphicons-halflings.png": "rest_framework/img/glyphicons-halflings.90233c9067e9.png", "rest_framework/img/grid.png": "rest_framework/img/grid.a4b938cf382b.png", "rest_framework/img/glyphicons-halflings-white.png": "rest_framework/img/glyphicons-halflings-white.9bbc6e960299.png", "rest_framework/css/bootstrap.min.css": "rest_framework/css/bootstrap.min.77017a69879a.css", "rest_framework/css/font-awesome-4.0.3.css": "rest_framework/css/font-awesome-4.0.3.c1e1ea213abf.css", "rest_framework/css/default.css": "rest_framework/css/default.789dfb5732d7.css", "rest_framework/css/prettify.css": "rest_framework/css/prettify.a987f72342ee.css", "rest_framework/css/bootstrap-theme.min.css": "rest_framework/css/bootstrap-theme.min.66b84a04375e.css", "rest_framework/css/bootstrap-tweaks.css": "rest_framework/css/bootstrap-tweaks.46ed116b0edd.css", "rest_framework/js/coreapi-0.1.1.js": "rest_framework/js/coreapi-0.1.1.e580e3854595.js", "rest_framework/js/jquery-3.5.1.min.js": "rest_framework/js/jquery-3.5.1.min.dc5e7f18c8d3.js", "rest_framework/js/default.js": "rest_framework/js/default.5b08897dbdc3.js", "rest_framework/js/bootstrap.min.js": "rest_framework/js/bootstrap.min.2f34b630ffe3.js", "rest_framework/js/prettify-min.js": "rest_framework/js/prettify-min.709bfcc456c6.js", "rest_framework/js/csrf.js": "rest_framework/js/csrf.969930007329.js", "rest_framework/js/ajax-form.js": "rest_framework/js/ajax-form.0ea6e6052ab5.js", "admin/fonts/Roboto-Bold-webfont.woff": "admin/fonts/Roboto-Bold-webfont.50d75e48e0a3.woff", "admin/fonts/Roboto-Light-webfont.woff": "admin/fonts/Roboto-Light-webfont.c73eb1ceba33.woff", "admin/fonts/Roboto-Regular-webfont.woff": "admin/fonts/Roboto-Regular-webfont.35b07eb2f871.woff", "admin/fonts/LICENSE.txt": "admin/fonts/LICENSE.d273d63619c9.txt", "admin/fonts/README.txt": "admin/fonts/README.ab99e6b541ea.txt", "admin/img/selector-icons.svg": "admin/img/selector-icons.b4555096cea2.svg", "admin/img/search.svg": "admin/img/search.7cf54ff789c6.svg", "admin/img/tooltag-arrowright.svg": "admin/img/tooltag-arrowright.bbfb788a849e.svg", "admin/img/LICENSE": "admin/img/LICENSE.2c54f4e1ca1c", "admin/img/icon-calendar.svg": "admin/img/icon-calendar.ac7aea671bea.svg", "admin/img/icon-no.svg": "admin/img/icon-no.439e821418cd.svg", "admin/img/calendar-icons.svg": "admin/img/calendar-icons.39b290681a8b.svg", "admin/img/icon-unknown.svg": "admin/img/icon-unknown.a18cb4398978.svg", "admin/img/icon-unknown-alt.svg": "admin/img/icon-unknown-alt.81536e128bb6.svg", "admin/img/icon-deletelink.svg": "admin/img/icon-deletelink.564ef9dc3854.svg", "admin/img/icon-yes.svg": "admin/img/icon-yes.d2f9f035226a.svg", "admin/img/icon-changelink.svg": "admin/img/icon-changelink.18d2fd706348.svg", "admin/img/inline-delete.svg": "admin/img/inline-delete.fec1b761f254.svg", "admin/img/README.txt": "admin/img/README.a70711a38d87.txt", "admin/img/sorting-icons.svg": "admin/img/sorting-icons.3a097b59f104.svg", "admin/img/icon-viewlink.svg": "admin/img/icon-viewlink.41eb31f7826e.svg", "admin/img/icon-alert.svg": "admin/img/icon-alert.034cc7d8a67f.svg", "admin/img/tooltag-add.svg": "admin/img/tooltag-add.e59d620a9742.svg", "admin/img/icon-clock.svg": "admin/img/icon-clock.e1d4dfac3f2b.svg", "admin/img/icon-addlink.svg": "admin/img/icon-addlink.d519b3bab011.svg", "admin/css/changelists.css": "admin/css/changelists.cd4dd90ae1a1.css", "admin/css/responsive_rtl.css": "admin/css/responsive_rtl.e13ae754cceb.css", "admin/css/fonts.css": "admin/css/fonts.168bab448fee.css", "admin/css/login.css": "admin/css/login.8b76a9f7cbf6.css", "admin/css/forms.css": "admin/css/forms.332ab41432e2.css", "admin/css/widgets.css": "admin/css/widgets.694d845b2cb1.css", "admin/css/dashboard.css": "admin/css/dashboard.be83f13e4369.css", "admin/css/base.css": "admin/css/base.1f418065fc2c.css", "admin/css/autocomplete.css": "admin/css/autocomplete.4a81fc4242d0.css", "admin/css/nav_sidebar.css": "admin/css/nav_sidebar.e32d345464bd.css", "admin/css/rtl.css": "admin/css/rtl.4bc23eb90919.css", "admin/css/responsive.css": "admin/css/responsive.b9e1565b3609.css", "admin/js/popup_response.js": "admin/js/popup_response.c6cc78ea5551.js", "admin/js/actions.js": "admin/js/actions.eac7e3441574.js", "admin/js/autocomplete.js": "admin/js/autocomplete.c508b167ab61.js", "admin/js/urlify.js": "admin/js/urlify.25cc3eac8123.js", "admin/js/nav_sidebar.js": "admin/js/nav_sidebar.3535caba9444.js", "admin/js/prepopulate_init.js": "admin/js/prepopulate_init.e056047b7a7e.js", "admin/js/change_form.js": "admin/js/change_form.9d8ca4f96b75.js", "admin/js/collapse.js": "admin/js/collapse.f84e7410290f.js", "admin/js/core.js": "admin/js/core.5d6b384a08b5.js", "admin/js/jquery.init.js": "admin/js/jquery.init.b7781a0897fc.js", "admin/js/calendar.js": "admin/js/calendar.f8a5d055eb33.js", "admin/js/SelectBox.js": "admin/js/SelectBox.8161741c7647.js", "admin/js/cancel.js": "admin/js/cancel.ecc4c5ca7b32.js", "admin/js/SelectFilter2.js": "admin/js/SelectFilter2.d250dcb52a9a.js", "admin/js/prepopulate.js": "admin/js/prepopulate.bd2361dfd64d.js", "admin/js/inlines.js": "admin/js/inlines.fb1617228dbe.js", "import_export/action_formats.js": "import_export/action_formats.11c3e817b80a.js", "import_export/import.css": "import_export/import.358144dd8713.css"}, "version": "1.0"} \ No newline at end of file