7 Commits

19 changed files with 80 additions and 157 deletions

View File

@ -0,0 +1,3 @@
import { polyfillCountryFlagEmojis } from "country-flag-emoji-polyfill";
polyfillCountryFlagEmojis();

View File

@ -14,7 +14,6 @@ export interface PaginatedRequest {
// biome-ignore lint/style/useNamingConvention: api is in snake_case // biome-ignore lint/style/useNamingConvention: api is in snake_case
page_size?: number; page_size?: number;
}; };
url: string;
} }
type PaginatedEndpoint<T> = <ThrowOnError extends boolean = false>( type PaginatedEndpoint<T> = <ThrowOnError extends boolean = false>(
@ -31,7 +30,7 @@ export const paginated = async <T>(
options?: PaginatedRequest, options?: PaginatedRequest,
): Promise<T[]> => { ): Promise<T[]> => {
const maxPerPage = 199; const maxPerPage = 199;
const queryParams = options ?? ({} as PaginatedRequest); const queryParams = options ?? {};
queryParams.query = queryParams.query ?? {}; queryParams.query = queryParams.query ?? {};
queryParams.query.page_size = maxPerPage; queryParams.query.page_size = maxPerPage;
queryParams.query.page = 1; queryParams.query.page = 1;
@ -56,7 +55,6 @@ export const paginated = async <T>(
interface Request { interface Request {
client?: Client; client?: Client;
url: string;
} }
interface InterceptorOptions { interface InterceptorOptions {

View File

@ -106,6 +106,7 @@ $hovered-red-text-color: #ff4d4d;
color: $text-color; color: $text-color;
font-weight: normal; font-weight: normal;
line-height: 1.3em; line-height: 1.3em;
font-family: "Twemoji Country Flags", sans-serif;
&:hover { &:hover {
background-color: $background-color-hovered; background-color: $background-color-hovered;

View File

@ -23,6 +23,7 @@
<script type="module" src={{ static("bundled/core/components/include-index.ts") }}></script> <script type="module" src={{ static("bundled/core/components/include-index.ts") }}></script>
<script type="module" src="{{ static('bundled/alpine-index.js') }}"></script> <script type="module" src="{{ static('bundled/alpine-index.js') }}"></script>
<script type="module" src="{{ static('bundled/htmx-index.js') }}"></script> <script type="module" src="{{ static('bundled/htmx-index.js') }}"></script>
<script type="module" src="{{ static('bundled/country-flags-index.ts') }}"></script>
<!-- Jquery declared here to be accessible in every django widgets --> <!-- Jquery declared here to be accessible in every django widgets -->
<script src="{{ static('bundled/vendored/jquery.min.js') }}"></script> <script src="{{ static('bundled/vendored/jquery.min.js') }}"></script>

View File

@ -6,9 +6,7 @@ import { defineConfig } from "@hey-api/openapi-ts";
export default defineConfig({ export default defineConfig({
input: resolve(__dirname, "./staticfiles/generated/openapi/schema.json"), input: resolve(__dirname, "./staticfiles/generated/openapi/schema.json"),
output: { output: {
lint: "biome", path: resolve(__dirname, "./staticfiles/generated/openapi/client"),
format: "biome",
path: resolve(__dirname, "./staticfiles/generated/openapi"),
}, },
plugins: [ plugins: [
{ {

7
package-lock.json generated
View File

@ -22,6 +22,7 @@
"3d-force-graph": "^1.73.4", "3d-force-graph": "^1.73.4",
"alpinejs": "^3.14.7", "alpinejs": "^3.14.7",
"chart.js": "^4.4.4", "chart.js": "^4.4.4",
"country-flag-emoji-polyfill": "^0.1.8",
"cytoscape": "^3.30.2", "cytoscape": "^3.30.2",
"cytoscape-cxtmenu": "^3.5.0", "cytoscape-cxtmenu": "^3.5.0",
"cytoscape-klay": "^3.1.4", "cytoscape-klay": "^3.1.4",
@ -3379,6 +3380,12 @@
"url": "https://opencollective.com/core-js" "url": "https://opencollective.com/core-js"
} }
}, },
"node_modules/country-flag-emoji-polyfill": {
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/country-flag-emoji-polyfill/-/country-flag-emoji-polyfill-0.1.8.tgz",
"integrity": "sha512-Mbah52sADS3gshUYhK5142gtUuJpHYOXlXtLFI3Ly4RqgkmPMvhX9kMZSTqDM8P7UqtSW99eHKFphhQSGXA3Cg==",
"license": "MIT"
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",

View File

@ -7,6 +7,7 @@
"compile": "vite build --mode production", "compile": "vite build --mode production",
"compile-dev": "vite build --mode development", "compile-dev": "vite build --mode development",
"serve": "vite build --mode development --watch --minify false", "serve": "vite build --mode development --watch --minify false",
"openapi": "openapi-ts",
"analyse-dev": "vite-bundle-visualizer --mode development", "analyse-dev": "vite-bundle-visualizer --mode development",
"analyse-prod": "vite-bundle-visualizer --mode production", "analyse-prod": "vite-bundle-visualizer --mode production",
"check": "biome check --write" "check": "biome check --write"
@ -16,7 +17,7 @@
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"sideEffects": [".css"], "sideEffects": [".css"],
"imports": { "imports": {
"#openapi": "./staticfiles/generated/openapi/index.ts", "#openapi": "./staticfiles/generated/openapi/client/index.ts",
"#core:*": "./core/static/bundled/*", "#core:*": "./core/static/bundled/*",
"#pedagogy:*": "./pedagogy/static/bundled/*", "#pedagogy:*": "./pedagogy/static/bundled/*",
"#counter:*": "./counter/static/bundled/*", "#counter:*": "./counter/static/bundled/*",
@ -48,6 +49,7 @@
"3d-force-graph": "^1.73.4", "3d-force-graph": "^1.73.4",
"alpinejs": "^3.14.7", "alpinejs": "^3.14.7",
"chart.js": "^4.4.4", "chart.js": "^4.4.4",
"country-flag-emoji-polyfill": "^0.1.8",
"cytoscape": "^3.30.2", "cytoscape": "^3.30.2",
"cytoscape-cxtmenu": "^3.5.0", "cytoscape-cxtmenu": "^3.5.0",
"cytoscape-klay": "^3.1.4", "cytoscape-klay": "^3.1.4",

View File

@ -1,3 +1,6 @@
from typing import Annotated
from annotated_types import MinLen
from django.conf import settings from django.conf import settings
from django.db.models import F from django.db.models import F
from django.urls import reverse from django.urls import reverse
@ -13,8 +16,6 @@ from core.auth.api_permissions import CanAccessLookup, CanView, IsInGroup, IsRoo
from core.models import Notification, User from core.models import Notification, User
from sas.models import Album, PeoplePictureRelation, Picture from sas.models import Album, PeoplePictureRelation, Picture
from sas.schemas import ( from sas.schemas import (
AlbumAutocompleteSchema,
AlbumFilterSchema,
AlbumSchema, AlbumSchema,
IdentifiedUserSchema, IdentifiedUserSchema,
ModerationRequestSchema, ModerationRequestSchema,
@ -30,30 +31,11 @@ class AlbumController(ControllerBase):
@route.get( @route.get(
"/search", "/search",
response=PaginatedResponseSchema[AlbumSchema], response=PaginatedResponseSchema[AlbumSchema],
permissions=[IsAuthenticated],
url_name="search-album",
)
@paginate(PageNumberPaginationExtra, page_size=50)
def fetch_album(self, filters: Query[AlbumFilterSchema]):
"""General-purpose album search."""
return filters.filter(Album.objects.viewable_by(self.context.request.user))
@route.get(
"/autocomplete-search",
response=PaginatedResponseSchema[AlbumAutocompleteSchema],
permissions=[CanAccessLookup], permissions=[CanAccessLookup],
) )
@paginate(PageNumberPaginationExtra, page_size=50) @paginate(PageNumberPaginationExtra, page_size=50)
def autocomplete_album(self, filters: Query[AlbumFilterSchema]): def search_album(self, search: Annotated[str, MinLen(1)]):
"""Search route to use exclusively on autocomplete input fields. return Album.objects.filter(name__icontains=search)
This route is separated from `GET /sas/album/search` because
getting the path of an album may need an absurd amount of db queries.
If you don't need the path of the albums,
do NOT use this route.
"""
return filters.filter(Album.objects.viewable_by(self.context.request.user))
@api_controller("/sas/picture") @api_controller("/sas/picture")

View File

@ -1,8 +1,6 @@
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Annotated
from annotated_types import MinLen
from django.urls import reverse from django.urls import reverse
from ninja import FilterSchema, ModelSchema, Schema from ninja import FilterSchema, ModelSchema, Schema
from pydantic import Field, NonNegativeInt from pydantic import Field, NonNegativeInt
@ -11,37 +9,7 @@ from core.schemas import SimpleUserSchema, UserProfileSchema
from sas.models import Album, Picture, PictureModerationRequest from sas.models import Album, Picture, PictureModerationRequest
class AlbumFilterSchema(FilterSchema):
search: Annotated[str, MinLen(1)] | None = Field(None, q="name__icontains")
before_date: datetime | None = Field(None, q="event_date__lte")
after_date: datetime | None = Field(None, q="event_date__gte")
parent_id: int | None = Field(None, q="parent_id")
class AlbumSchema(ModelSchema): class AlbumSchema(ModelSchema):
class Meta:
model = Album
fields = ["id", "name", "is_moderated"]
thumbnail: str | None
sas_url: str
@staticmethod
def resolve_thumbnail(obj: Album) -> str | None:
# Album thumbnails aren't stored in `Album.thumbnail` but in `Album.file`
# Don't ask me why.
if not obj.file:
return None
return obj.get_download_url()
@staticmethod
def resolve_sas_url(obj: Album) -> str:
return obj.get_absolute_url()
class AlbumAutocompleteSchema(ModelSchema):
"""Schema to use on album autocomplete input field."""
class Meta: class Meta:
model = Album model = Album
fields = ["id", "name"] fields = ["id", "name"]

View File

@ -1,37 +1,28 @@
import { paginated } from "#core:utils/api"; import { paginated } from "#core:utils/api";
import { History, initialUrlParams, updateQueryString } from "#core:utils/history"; import { History, initialUrlParams, updateQueryString } from "#core:utils/history";
import { import {
type AlbumFetchAlbumData,
type AlbumSchema,
type PictureSchema, type PictureSchema,
type PicturesFetchPicturesData, type PicturesFetchPicturesData,
albumFetchAlbum,
picturesFetchPictures, picturesFetchPictures,
} from "#openapi"; } from "#openapi";
interface AlbumPicturesConfig { interface AlbumConfig {
albumId: number; albumId: number;
maxPageSize: number; maxPageSize: number;
} }
interface SubAlbumsConfig {
parentId: number;
}
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
Alpine.data("pictures", (config: AlbumPicturesConfig) => ({ Alpine.data("pictures", (config: AlbumConfig) => ({
pictures: [] as PictureSchema[], pictures: [] as PictureSchema[],
page: Number.parseInt(initialUrlParams.get("page")) || 1, page: Number.parseInt(initialUrlParams.get("page")) || 1,
pushstate: History.Push /* Used to avoid pushing a state on a back action */, pushstate: History.Push /* Used to avoid pushing a state on a back action */,
loading: false, loading: false,
config: config,
async init() { async init() {
await this.fetchPictures(); await this.fetchPictures();
this.$watch("page", () => { this.$watch("page", () => {
updateQueryString("page", this.page === 1 ? null : this.page, this.pushstate); updateQueryString("page", this.page === 1 ? null : this.page, this.pushstate);
this.pushstate = History.Push; this.pushstate = History.Push;
this.fetchPictures();
}); });
window.addEventListener("popstate", () => { window.addEventListener("popstate", () => {
@ -39,6 +30,7 @@ document.addEventListener("alpine:init", () => {
this.page = this.page =
Number.parseInt(new URLSearchParams(window.location.search).get("page")) || 1; Number.parseInt(new URLSearchParams(window.location.search).get("page")) || 1;
}); });
this.config = config;
}, },
getPage(page: number) { getPage(page: number) {
@ -63,23 +55,4 @@ document.addEventListener("alpine:init", () => {
return Math.ceil(this.pictures.length / config.maxPageSize); return Math.ceil(this.pictures.length / config.maxPageSize);
}, },
})); }));
Alpine.data("albums", (config: SubAlbumsConfig) => ({
albums: [] as AlbumSchema[],
config: config,
loading: false,
async init() {
await this.fetchAlbums();
},
async fetchAlbums() {
this.loading = true;
this.albums = await paginated(albumFetchAlbum, {
// biome-ignore lint/style/useNamingConvention: API is snake_case
query: { parent_id: this.config.parentId },
} as AlbumFetchAlbumData);
this.loading = false;
},
}));
}); });

View File

@ -2,7 +2,7 @@ import { AjaxSelect } from "#core:core/components/ajax-select-base";
import { registerComponent } from "#core:utils/web-components"; import { registerComponent } from "#core:utils/web-components";
import type { TomOption } from "tom-select/dist/types/types"; import type { TomOption } from "tom-select/dist/types/types";
import type { escape_html } from "tom-select/dist/types/utils"; import type { escape_html } from "tom-select/dist/types/utils";
import { type AlbumAutocompleteSchema, albumAutocompleteAlbum } from "#openapi"; import { type AlbumSchema, albumSearchAlbum } from "#openapi";
@registerComponent("album-ajax-select") @registerComponent("album-ajax-select")
export class AlbumAjaxSelect extends AjaxSelect { export class AlbumAjaxSelect extends AjaxSelect {
@ -11,20 +11,20 @@ export class AlbumAjaxSelect extends AjaxSelect {
protected searchField = ["path", "name"]; protected searchField = ["path", "name"];
protected async search(query: string): Promise<TomOption[]> { protected async search(query: string): Promise<TomOption[]> {
const resp = await albumAutocompleteAlbum({ query: { search: query } }); const resp = await albumSearchAlbum({ query: { search: query } });
if (resp.data) { if (resp.data) {
return resp.data.results; return resp.data.results;
} }
return []; return [];
} }
protected renderOption(item: AlbumAutocompleteSchema, sanitize: typeof escape_html) { protected renderOption(item: AlbumSchema, sanitize: typeof escape_html) {
return `<div class="select-item"> return `<div class="select-item">
<span class="select-item-text">${sanitize(item.path)}</span> <span class="select-item-text">${sanitize(item.path)}</span>
</div>`; </div>`;
} }
protected renderItem(item: AlbumAutocompleteSchema, sanitize: typeof escape_html) { protected renderItem(item: AlbumSchema, sanitize: typeof escape_html) {
return `<span>${sanitize(item.path)}</span>`; return `<span>${sanitize(item.path)}</span>`;
} }
} }

View File

@ -53,43 +53,32 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
{% if show_albums %} {% if children_albums|length > 0 %}
<div x-data="albums({ parentId: {{ album.id }} })" class="margin-bottom"> <h4>{% trans %}Albums{% endtrans %}</h4>
<h4>{% trans %}Albums{% endtrans %}</h4> <div class="albums">
<div class="albums" :aria-busy="loading"> {% for a in children_albums %}
<template x-for="album in albums" :key="album.id"> {{ display_album(a, is_sas_admin) }}
<a :href="album.sas_url"> {% endfor %}
<div
x-data="{thumbUrl: album.thumbnail || '{{ static("core/img/sas.jpg") }}'}"
class="album"
:class="{not_moderated: !album.is_moderated}"
>
<img :src="thumbUrl" :alt="album.name" loading="lazy" />
<template x-if="album.is_moderated">
<div class="text" x-text="album.name"></div>
</template>
<template x-if="!album.is_moderated">
<div class="overlay">&nbsp;</div>
<div class="text">{% trans %}To be moderated{% endtrans %}</div>
</template>
</div>
{% if edit_mode %}
<input type="checkbox" name="file_list" :value="album.id">
{% endif %}
</a>
</template>
</div>
</div> </div>
<br>
{% endif %} {% endif %}
<div x-data="pictures({ albumId: {{ album.id }}, maxPageSize: {{ settings.SITH_SAS_IMAGES_PER_PAGE }} })"> <div x-data="pictures({
<h4>{% trans %}Pictures{% endtrans %}</h4> albumId: {{ album.id }},
<br> maxPageSize: {{ settings.SITH_SAS_IMAGES_PER_PAGE }},
})">
{{ download_button(_("Download album")) }} {{ download_button(_("Download album")) }}
<h4>{% trans %}Pictures{% endtrans %}</h4>
<div class="photos" :aria-busy="loading"> <div class="photos" :aria-busy="loading">
<template x-for="picture in getPage(page)"> <template x-for="picture in getPage(page)">
<a :href="picture.sas_url"> <a :href="picture.sas_url">
<div class="photo" :class="{not_moderated: !picture.is_moderated}"> <div
class="photo"
:class="{not_moderated: !picture.is_moderated}"
>
<img :src="picture.thumb_url" :alt="picture.name" loading="lazy" /> <img :src="picture.thumb_url" :alt="picture.name" loading="lazy" />
<template x-if="!picture.is_moderated"> <template x-if="!picture.is_moderated">
<div class="overlay">&nbsp;</div> <div class="overlay">&nbsp;</div>
@ -105,7 +94,7 @@
</a> </a>
</template> </template>
</div> </div>
{{ paginate_alpine("page", "nbPages()") }} {{ paginate_alpine("page", "nbPages()") }}
</div> </div>
{% if is_sas_admin %} {% if is_sas_admin %}

View File

@ -228,16 +228,3 @@ class TestPictureModeration(TestSas):
assert res.status_code == 200 assert res.status_code == 200
assert len(res.json()) == 1 assert len(res.json()) == 1
assert res.json()[0]["author"]["id"] == self.user_a.id assert res.json()[0]["author"]["id"] == self.user_a.id
class TestAlbumSearch(TestSas):
def test_num_queries(self):
"""Check the number of queries is stable"""
self.client.force_login(subscriber_user.make())
cache.clear()
with self.assertNumQueries(7):
# - 2 for authentication
# - 3 to check permissions
# - 1 for pagination
# - 1 for the actual results
self.client.get(reverse("api:search-album"))

View File

@ -186,10 +186,10 @@ class AlbumView(CanViewMixin, DetailView, FormMixin):
kwargs["clipboard"] = SithFile.objects.filter( kwargs["clipboard"] = SithFile.objects.filter(
id__in=self.request.session["clipboard"] id__in=self.request.session["clipboard"]
) )
kwargs["show_albums"] = ( kwargs["children_albums"] = list(
Album.objects.viewable_by(self.request.user) Album.objects.viewable_by(self.request.user)
.filter(parent_id=self.object.id) .filter(parent_id=self.object.id)
.exists() .order_by("-date")
) )
return kwargs return kwargs

View File

@ -2,7 +2,7 @@ from pydantic import TypeAdapter
from core.views.widgets.select import AutoCompleteSelect, AutoCompleteSelectMultiple from core.views.widgets.select import AutoCompleteSelect, AutoCompleteSelectMultiple
from sas.models import Album from sas.models import Album
from sas.schemas import AlbumAutocompleteSchema from sas.schemas import AlbumSchema
_js = ["bundled/sas/components/ajax-select-index.ts"] _js = ["bundled/sas/components/ajax-select-index.ts"]
@ -10,7 +10,7 @@ _js = ["bundled/sas/components/ajax-select-index.ts"]
class AutoCompleteSelectAlbum(AutoCompleteSelect): class AutoCompleteSelectAlbum(AutoCompleteSelect):
component_name = "album-ajax-select" component_name = "album-ajax-select"
model = Album model = Album
adapter = TypeAdapter(list[AlbumAutocompleteSchema]) adapter = TypeAdapter(list[AlbumSchema])
js = _js js = _js
@ -18,6 +18,6 @@ class AutoCompleteSelectAlbum(AutoCompleteSelect):
class AutoCompleteSelectMultipleAlbum(AutoCompleteSelectMultiple): class AutoCompleteSelectMultipleAlbum(AutoCompleteSelectMultiple):
component_name = "album-ajax-select" component_name = "album-ajax-select"
model = Album model = Album
adapter = TypeAdapter(list[AlbumAutocompleteSchema]) adapter = TypeAdapter(list[AlbumSchema])
js = _js js = _js

View File

@ -50,7 +50,13 @@ class Command(CollectStatic):
return Path(location) return Path(location)
Scss.compile(self.collect_scss()) Scss.compile(self.collect_scss())
OpenApi.compile() # This needs to be prior to javascript bundling openapi = OpenApi.compile() # This needs to be prior to javascript bundling
if openapi is not None:
_ = openapi.wait()
if openapi.returncode:
raise RuntimeError(
f"Openapi generation failed with returncode {openapi.returncode}"
)
JSBundler.compile() JSBundler.compile()
collected = super().collect() collected = super().collect()

View File

@ -15,11 +15,18 @@ class Command(Runserver):
"""Light wrapper around default runserver that integrates javascirpt auto bundling.""" """Light wrapper around default runserver that integrates javascirpt auto bundling."""
def run(self, **options): def run(self, **options):
OpenApi.compile() is_django_reload = os.environ.get(DJANGO_AUTORELOAD_ENV) is not None
if (
os.environ.get(DJANGO_AUTORELOAD_ENV) is None proc = OpenApi.compile()
and settings.PROCFILE_STATIC is not None # Ensure that the first runserver launch creates openapi files
): # before the bundler starts so that it detects them
# When django is reloaded, we can keep this process in background
# to reduce reload time
if proc is not None and not is_django_reload:
_ = proc.wait()
if not is_django_reload and settings.PROCFILE_STATIC is not None:
start_composer(settings.PROCFILE_STATIC) start_composer(settings.PROCFILE_STATIC)
_ = atexit.register(stop_composer, procfile=settings.PROCFILE_STATIC) _ = atexit.register(stop_composer, procfile=settings.PROCFILE_STATIC)
super().run(**options) super().run(**options)

View File

@ -95,7 +95,7 @@ class JSBundler:
def compile(): def compile():
"""Bundle js files with the javascript bundler for production.""" """Bundle js files with the javascript bundler for production."""
process = subprocess.Popen(["npm", "run", "compile"]) process = subprocess.Popen(["npm", "run", "compile"])
process.wait() _ = process.wait()
if process.returncode: if process.returncode:
raise RuntimeError(f"Bundler failed with returncode {process.returncode}") raise RuntimeError(f"Bundler failed with returncode {process.returncode}")
@ -163,7 +163,7 @@ class OpenApi:
OPENAPI_DIR = GENERATED_ROOT / "openapi" OPENAPI_DIR = GENERATED_ROOT / "openapi"
@classmethod @classmethod
def compile(cls): def compile(cls) -> subprocess.Popen[bytes] | None:
"""Compile a TS client for the sith API. Only generates it if it changed.""" """Compile a TS client for the sith API. Only generates it if it changed."""
logging.getLogger("django").info("Compiling open api typescript client") logging.getLogger("django").info("Compiling open api typescript client")
out = cls.OPENAPI_DIR / "schema.json" out = cls.OPENAPI_DIR / "schema.json"
@ -191,4 +191,4 @@ class OpenApi:
with open(out, "w") as f: with open(out, "w") as f:
_ = f.write(schema) _ = f.write(schema)
subprocess.run(["npx", "openapi-ts"], check=True) return subprocess.Popen(["npm", "run", "openapi"])

View File

@ -3,8 +3,8 @@
"outDir": "./staticfiles/generated/bundled/", "outDir": "./staticfiles/generated/bundled/",
"sourceMap": true, "sourceMap": true,
"noImplicitAny": true, "noImplicitAny": true,
"module": "esnext", "module": "es6",
"target": "es2022", "target": "es6",
"allowJs": true, "allowJs": true,
"moduleResolution": "node", "moduleResolution": "node",
"experimentalDecorators": true, "experimentalDecorators": true,
@ -12,8 +12,9 @@
"esModuleInterop": true, "esModuleInterop": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"types": ["jquery", "alpinejs"], "types": ["jquery", "alpinejs"],
"lib": ["es7"],
"paths": { "paths": {
"#openapi": ["./staticfiles/generated/openapi/index.ts"], "#openapi": ["./staticfiles/generated/openapi/client/index.ts"],
"#core:*": ["./core/static/bundled/*"], "#core:*": ["./core/static/bundled/*"],
"#pedagogy:*": ["./pedagogy/static/bundled/*"], "#pedagogy:*": ["./pedagogy/static/bundled/*"],
"#counter:*": ["./counter/static/bundled/*"], "#counter:*": ["./counter/static/bundled/*"],