Architecture
Marginalia is one Java web application running in one JVM. There is no separate frontend project, no REST API and no external database: the browser shows Vaadin components that live on the server, the components call Spring services, and the services store everything in a single SQLite file. Story generation runs on background threads that talk to an OpenAI-compatible HTTP API and push the text to the browser as it arrives. Extensions are OSGi bundles loaded into the same JVM; they change the UI by hooking into methods of instrumented classes.
flowchart LR
Browser["Browser<br/>(Vaadin client)"]
subgraph JVM["Servlet container (Jetty 12 / Tomcat 11)"]
direction TB
Servlet["MarginaliaServlet<br/>(VaadinServlet)"]
UI["UI components<br/>routes, workspace, dialogs<br/>@Configurable, @Extendable"]
Services["Spring services<br/>domain/service"]
Gen["Generation engine<br/>pipeline steps on<br/>generation threads"]
Repos["Repositories<br/>JPA / Hibernate"]
OSGi["Apache Felix<br/>extension bundles"]
Agent["ByteBuddy agent<br/>instruments @Extendable"]
end
DB[("~/.marginalia/<br/>marginalia.sqlite")]
LLM["OpenAI-compatible API<br/>OpenAI, OpenRouter,<br/>llama.cpp, ..."]
Browser <-- "HTTP + push WebSocket" --> Servlet
Servlet --> UI
UI --> Services
Services --> Repos --> DB
Services --> Gen
Gen -- "streaming chat completions" --> LLM
Gen -. "chunks via UI.access + push" .-> UI
OSGi -- "decorators" --> UI
Agent -. "rewrites bytecode" .-> UIDesign in brief
-
Server-side UI. Vaadin Flow keeps the component tree on the server; the browser renders it and sends events back. UI code is ordinary Java that calls services directly - there is no API layer to keep in sync.
-
Spring without Spring Boot. The application context is built from XML files; services and repositories are explicit beans. Vaadin components are created with
newand get their dependencies through AspectJ-woven@Configurable. -
One file of data. SQLite in WAL mode, schema managed by Flyway, mapped by Hibernate. Backing up an installation means copying one folder.
-
Multi-user, single process. Every entity belongs to a user; services filter by the user of the current HTTP session. All users share one JVM and one database.
-
Pipeline generation. A story part is generated by a fixed sequence of steps with events between them, so extensions can inspect or change the prompt, the lore and the result.
-
Runtime extensions. OSGi bundles can be loaded and unloaded without a restart. The UI classes they extend are instrumented at startup so that any of their methods can be decorated.
Runtime and deployment
The build produces one WAR. It is deployed in a different container depending on how Marginalia is run, but the code is the same everywhere:
DesktopLauncher (marginalia/src/desktop/java) depends on the JDK only: it prepares a Jetty base in
~/.marginalia/desktop/, starts Jetty's start.jar by reflection in the same JVM, opens the browser and shows the tray
icon. It is compiled separately and never ends up in the WAR. See Packaging & releases.
Startup
sequenceDiagram
participant C as Servlet container
participant W as WebappApplicationInitializer
participant S as Spring root context
participant F as Felix (OsgiServiceImpl)
participant V as MarginaliaServlet
C->>W: onStartup (Spring's ServletContainerInitializer)
W->>S: XmlWebApplicationContext(application-config.xml)
Note over S: Configuration: data folder, pending database restore, copy before a migration
Note over S: dataSource → Flyway migrate → EntityManagerFactory (validate)
Note over S: repositories, services, generation steps
Note over S: ApplicationInitializer, RuntimeInstrumentationInitializer (agent)
S->>F: ContextRefreshedEvent → start framework, install JARs from extensions/
F->>F: each bundle's MarginaliaExtension.onExtensionLoad
C->>V: load servlet (@WebServlet "/*")
V-->>C: Vaadin ready, routes "/" and "/view"-
The container finds Spring's
SpringServletContainerInitializer, which callsui/main/WebappApplicationInitializer. It creates anXmlWebApplicationContextfromMETA-INF/spring/application-config.xmland registers theContextLoaderListener, theRequestContextListener(makes request and session scoped beans work) and theSessionTrackingListener(feeds HTTP sessions to theSessionManager, seeSessions ). -
application-config.xmlloadsconfig/configuration.properties, the build info andgit.properties, enables annotation config,@Configurable(context:spring-configured),@Async/@Scheduled(task:annotation-driven) and AspectJ auto-proxies, scanscom.github.enercciofor components, and imports three files: -
Configurationresolves the data folder (<user.home>/.marginalia) and creates its subfolders. When the data source asks for the database URL (configuration.resolveDb(...)), a staged database restore (marginalia.sqlite.restore) is swapped in first - a restore can't replace a database that is open. Then a copy of an existing database that is behind the bundled migrations is saved indb-backups(pre-migration-*, see Startup). -
Flyway migrates the schema (
classpath:migration), then Hibernate builds theEntityManagerFactorywithhbm2ddl.auto=validate. A mismatch between entities and schema stops the startup here. -
RuntimeInstrumentationInitializerinstalls the ByteBuddy agent and registers a transformer for all classes annotated@Extendable(seeExtensions ). Classes already loaded are retransformed. -
When the root context is refreshed,
OsgiServiceImplstarts Apache Felix with~/.marginalia/extensionsas its storage, installs and starts every JAR in that folder and callsonExtensionLoadon each registeredMarginaliaExtension. A JAR that fails is logged and skipped. When the root context closes, the extensions are unloaded and the framework is stopped. -
MarginaliaServlet(@WebServlet("/*"), aVaadinServlet) serves the UI.AppShellConfigconfigures the page: push enabled, dark Lumo theme,shared-styles.css, the icons (icons/icon.svgas the vector favicon,icons/icon.pngand the PWA icons).
Layers and packages
All application code is in com.github.enerccio.marginalia (marginalia/src/main/java/...):
The UI depends on services, services on repositories, repositories on the entities - not the other way round. UI classes may read entities directly (they are detached JPA objects), but all changes go through services. Project structure lists the files in detail.
Spring wiring
Services and repositories are declared in services-config.xml, one repository bean per entity injected into its
service:
<bean id="manuscriptRepository" class="...repository.impl.JpaManuscriptRepository">
<property name="entityManager" ref="em"/>
</bean>
<bean class="...service.impl.ManuscriptServiceImpl">
<property name="repository" ref="manuscriptRepository" />
</bean>
Inside the services, other beans are @Autowired by interface. A new service needs a bean definition here - the
component scan doesn't pick up the service implementations.
UI classes are not Spring beans. They are created with new (new ManuscriptDialog(...)) and annotated
@Configurable (most with preConstruction = true, so fields are injected before the constructor body runs). The
AspectJ compiler weaves AnnotationBeanConfigurerAspect into them, which autowires their fields from the root
context. This only works for classes compiled by ajc - see AspectJ weaving.
Transactions use the common transaction manager (JpaTransactionManager) and are applied by Spring's CGLIB
proxies around the service beans (tx:annotation-driven, proxy-target-class="true") - so only calls that come
through a bean reference are transactional, not calls of a service to its own methods (see
Services). Service methods are annotated with the meta-annotations from domain.traits:
The current user is the session-scoped bean user (User behind a scoped proxy). After login, Main copies the
logged-in user's id, login and name into it; services inject User and use it to set and check ownership
(OwnedService.findAllForUser(), findForUser(uuid)). Code running outside a request (generation threads) needs the
request attributes copied over - see
Configuration is read by the property placeholder of application-config.xml and wired to the configuration and
localization beans in container-config.xml. Each setting is a property marginalia.<name> that can be given as an
environment variable (MARGINALIA_<NAME>, dots and dashes become underscores) or a -D system property, both before
the older keys of src/main/webapp/config/configuration.properties, which stay as the fallback:
Configuration.hmac(purpose, data) is the keyed hash for identifiers that leave the application (InferenceSession, the X-Session-ID): HMAC-SHA256 with a subkey derived from secret.key and the purpose, the first 128 bits as 32 hex characters, so the value is stable in an installation and reveals nothing about the data. The application version in container-config.xml is 6 (ExtendedContentMigration, see Database).
A new setting needs the placeholder in container-config.xml (${marginalia.some-name:default}) and a row here and in
the Docker server page. A value of the wrong type
(MARGINALIA_PERSISTENT_LOGIN_TTL=abc) stops the start with the Spring error.
Health endpoint. GET /health (ui.main.HealthServlet, registered in WebappApplicationInitializer with an exact
mapping that wins over the /* of the Vaadin servlet) needs no login and answers 200 {"status":"UP"} when the root
application context is active and SELECT 1 runs on the data source, else 503 with {"status":"DOWN","context":..., "database":...}; the context that is still starting counts as down. HEAD is answered without a body. It reveals no
version or other detail, the cause of a failure is in the log. The Docker HEALTHCHECK uses it.
User interface
flowchart TB
Main["Main (route /)<br/>LoginCheckRoute"] --> WS["DesktopWorkspace"]
Main --> HW["HelpAssistantWidget<br/>robot button and chat"]
Viewer["Viewer (route /view)<br/>read-only published books"]
WS --> MP["ManuscriptPart<br/>Books"]
WS --> LP["LorebookPart"]
WS --> UP["UserPart<br/>Settings"]
WS --> PP["ProtocolPart"]
WS --> AP["AIPart<br/>Inference Providers"]
WS --> RP["ResourcesPart<br/>Resources"]
WS --> TP["TrashPart<br/>Trash"]
WS --> AdP["AdminPart<br/>Users, Backups, Cleanup, Help assistant, Extensions"]
MP --> MD["ManuscriptDialog<br/>(book window)"]
MD --> Story["ManuscriptStoryPart"]
MD --> Tree["ManuscriptTreePart"]
MD --> Info["ManuscriptInfoPart"]
MD --> Prompt["ManuscriptPromptPart"]
MD --> Lore["ManuscriptLorebookPart"]
MD --> Backup["ManuscriptBackupPart"]-
Mainshows the login (or the create administrator dialog on an empty installation), restores a saved login from cookies, and after login replaces its content with theDesktopWorkspace(the implementation of theWorkspaceinterface), then restores the autosaved state of the workspace (the tab, the open book...) withrestoreAutosaved(); see Remembering where the user was. It also adds theHelpAssistantWidget(the round robot button with the chat panel, hidden while the help assistant is off). The classes of the UI describe themselves to the assistant's model throughLLMInteract, see Letting the help assistant see the UI. -
DesktopWorkspaceholds the left-hand tabs. Each tab is aWorkspaceComponent(create,refresh,onTabSwitched,onTabClosed) and isRestorable(it stores and restores its own state). -
Opening a book opens
ManuscriptDialog, whose tabs areManuscriptDialogParts. The story editor (ManuscriptStoryPart) is the largest UI class: the parts sidebar, the text, the instruction panel, generation. -
Vieweris a separate route for reading published books without the workspace.
Most of these classes are @Extendable, which is what makes them extension points. See
User interface for details.
Data
classDiagram
class BaseEntity {
Long id
String uuid
boolean deleted
Date creation
Date modification
}
class OwnedEntity {
User owner
}
class ExtendableEntity {
byte[] extendedContent
String _fulltext
JsonObject attributes
}
BaseEntity <|-- OwnedEntity
OwnedEntity <|-- ExtendableEntity
ExtendableEntity <|-- Manuscript
ExtendableEntity <|-- ChatMessage
ExtendableEntity <|-- Lorebook
ExtendableEntity <|-- LorebookEntry
ExtendableEntity <|-- AI
ExtendableEntity <|-- Protocol
ExtendableEntity <|-- Summary
ExtendableEntity <|-- Tag
ExtendableEntity <|-- SettingAll data entities extend ExtendableEntity. User extends BaseEntity directly and Resource and Usage are OwnedEntitys.
-
Every entity has a numeric
id(database key) and auuid(stable identity used in URLs, backups and exports). -
Deleting is soft by default (
deleted = true); the admin Cleanup page purges deleted rows that nothing references any more, following the@CleanupReferenceannotations. -
OwnedEntity.ownerties data to a user.ExtendableEntity.attributesis a JSON object, stored serialized inextendedContentbyExtendableEntityListener, where extensions keep their own data (and where@ExtendedAttributefields of the entity are stored) - so that data travels with backups and exports without schema changes. The same listener fills_fulltextwith the text of the@Fulltextablefields, for searching. -
The story is a tree of
ChatMessages (parts): regenerate, swipe and branch create siblings; the book remembers the active leaf.
The database is SQLite in WAL mode (busy_timeout=10000), through commons-dbcp pools (read-write transactions begin with the write lock, see
Database & migrations) and a TransactionAwareDataSourceProxy. Flyway migrations in src/main/resources/migration/ create and upgrade the schema,
and every entity is listed in src/main/webapp/config/persistence.xml (exclude-unlisted-classes).
SaneSQLiteDialect adapts Hibernate's community SQLite dialect. See Domain model and
Database & migrations.
Generation
StoryGenerationService.generateNextTurn(manuscript, input, request, listener) starts generating a part and returns a
CancellationToken right away. The work is done by a GenerationEngine that runs the installed steps one after
another on a cached thread pool (Generation thread NNN):
Between and inside the steps the engine emits Events (BEFORE_PROCESS_LOREBOOK, AFTER_PREPARE_PAYLOAD,
CHUNK_RECEIVED...). Listeners registered with addEventListener run in a chain and can change the engine's data or
stop the chain; this is how extensions take part in generation. The UI follows progress through the
GenerationListener it passed in (onNodeCreated, onResponseChunk, onComplete, onError...). Cancelling, an
error or an interrupted thread jump straight to CLEANUP.
Prompts are Handlebars templates rendered by TemplateService;
SillyTavern macros ({{user}}, {{getvar::x}}, {{random::a::b}}...) are translated to Handlebars helpers by
MacroTranslator first. Inference goes through InferenceServices, which picks the implementation for the provider
type: OpenAICompatibleInferenceService (openai-java), or the service that the factory of an extension creates for an ExtensionAI (see Inference providers from a plugin). Token counts come from TokenizerService, which tries the
TokenizerStrategy implementations in turn (OpenAI SDK, llama.cpp and LiteLLM tokenize endpoints, JTokkit as the local
fallback) and remembers the first one that works for each TokenizerKey (the endpoint, the model and the API key of the provider).
See Generation pipeline and Templating & macros.
Threads and push
Vaadin UI state may only be changed while holding the session lock. Code on other threads - generation, backups,
summaries - updates the UI with ui.access(...), and the change reaches the browser through server push
(@Push on AppShellConfig, WebSocket with long-polling fallback). Two helpers make this safe:
UIPushGuard.push(ui)pushes explicitly but never re-entrantly from the same thread.ThreadAccessDialog(andProgressBarDialog) run a task on a worker thread and route its UI updates throughui.access.
Background threads have no HTTP request, so session-scoped beans (the current user) would not resolve.
generateNextTurn therefore captures the request attributes in a ThreadCopyRequestAttributes and the engine wraps
its work in ThreadCopyRequestAttributes.InRequestScope, which makes the session of the user who started the
generation current on the worker thread.
An agent run is not started by a request at all: AgentRunner works it in a background worker of its own
(a thread pool, one round of the model at a time) and sets up the request context from the run, not from a browser:
AgentRequestAttributes make the user bean resolve to the owner of the agent and the session point an
AgenticSessionPoint (the working copy is the open book, the language is the one of the run). What a run keeps of its session is the
AgentSession record (the uuid of the owner and the language), made up again at every start and resume. Anything that shows on
the screen of the owner goes through ui.access like any other background work, see
the request context of a run.
Sessions
bound/SessionManager (bean sessionManager, defined in container-config.xml) knows every HTTP session of the
application and which user is logged into it.
sequenceDiagram
participant C as Servlet container
participant L as SessionTrackingListener
participant M as SessionManager
participant R as LoginCheckRoute
C->>L: sessionCreated
L->>M: onSessionCreate → new SessionInformation
R->>M: userLoggedIn(user, VaadinSession) after login
Note over M: binds user, Vaadin session, main UI, request attributes
loop every DEAD_SESSION_CHECK_TIMEOUT / 10
M->>M: close UIs without heartbeat, invalidate sessions with no open UI
end
C->>L: sessionDestroyed (logout, timeout, invalidate)
L->>M: onSessionDestroy → SessionCloseListeners of the other sessions-
Tracking.
SessionTrackingListeneris anHttpSessionListenerregistered inWebappApplicationInitializer; it looks the bean up in the root context and callsonSessionCreate/onSessionDestroy. Each session gets aSessionInformation. After a successful loginMainandViewercalluserLoggedIn(user, VaadinSession), which fills it in once: the Vaadin session, the user, the current UI as the main UI and aThreadCopyRequestAttributescopy of the request. Note that login callsVaadinService.reinitializeSession- the old session is destroyed and a new one created, so the bound session is the new one. -
Running code in other sessions.
runForUsers(test, runnable, uiBound)runs aRunInSessionfor every session whose user matches the predicate, with that session's request attributes in scope (so the session-scopeduserresolves to that user). WithuiBoundthe runnable is queued withVaadinSession.accesson a live UI of the session (the main UI, or any other open one if it was closed) - it runs asynchronously, once the session lock is free, and changes are pushed when the lock is released. The sessions are processed on a separate thread (ThreadUtils.executeInThread), so the caller's Vaadin and request thread locals don't leak into them; the call waits for the dispatch, not for the queuedaccesstasks. TheskipCurrentoverload leaves out the caller's own Vaadin session.UserServiceImpl.changePassword/clearPassworduse it to invalidate the other sessions of the user whose password changed - a user changing their own password stays logged in in the current session.reloadUser(user)reloads the page of every UI of the sessions of a user the same way (runForUserswith the main UI'sPage.reload()): the permissions dialog uses it after it changed the permissions of another user. -
The help chat.
SessionPoint(the session-scoped bean with the language and the workspace of the page) also keeps the conversation with the help assistant (getHelpChat(), a synchronized list of messages) and the problems the user was shown (getProblems(), see What went wrong);Mainattaches that log to the Vaadin session after login so that code on threads without a request can write to it. Logout invalidates the session, which ends both. -
Open UIs. After login
MainandViewerregister their UI inApplicationPoint(beanapplicationPoint) with the workspace (Mainonly) and a copy of the user.SessionInformation.getUI(workspaceClass, applicationPoint)/getWorkspace(...)find the session's UI showing a given workspace type. -
Dead UI detection.
SessionManageris also a daemon thread (Inactive session watcher), started byafterPropertiesSetand stopped on context shutdown. EveryDEAD_SESSION_CHECK_TIMEOUT / 10seconds (seeConstants) ittryLocks each logged-in session - a locked session is in use and skipped - and closes UIs whose last heartbeat is older than the timeout. Threads queued on the session lock count as activity. The timeout is never shorter than three Vaadin heartbeat intervals (5 minutes by default), otherwise an open but idle tab would be closed between two heartbeats. When every UI of the session is closing, the HTTP session is invalidated. -
Close notifications.
addSessionCloseListenerregisters aSessionCloseListeneron the current session. When another session is destroyed, every listener of every remaining session is called inside its own session'saccessand request scope with theSessionInformationof the closed session. -
getActiveUsers(),getOpenedSessions(user),getSessionInformation(id)andgetActiveSessionIds()expose the registry, e.g. for admin views.
Extensions
sequenceDiagram
participant P as Plugin bundle
participant E as ExtensionService
participant C as @Extendable class (instrumented)
P->>E: registerDecorator(decorator, className, methodName)
Note over C: later, a user opens a book
C->>E: onExtendableMethodEnter(context with arguments)
E->>P: decorator.onMethodEnter(this, context)
Note over C: original method body runs,<br/>local variables recorded in context
C->>E: onExtendableMethodLeave(context, thrown)
E->>P: decorator.onMethodLeave(this, context, thrown)-
Loading. Extensions are OSGi bundles run by an embedded Apache Felix framework (
OsgiServiceImpl). Boot delegation is open for all packages (org.osgi.framework.bootdelegation=*, parent = the framework's class loader, which is the web application's), so bundles see the application's classes and libraries directly without importing them. A bundle registers aMarginaliaExtensionservice; Marginalia callsonExtensionLoad/onExtensionUnloadand passes theExtensionService. Before an extension is started,ExtensionService.verifyExtensionchecks the classes, methods, arguments, locals and fields its decorators use against the running application (the result is kept inname.valid/name.invalidnext to the JAR); an extension that doesn't fit is not started. -
Instrumentation. At startup a ByteBuddy agent rewrites every non-static, non-constructor method of each class annotated
@Extendable:ExtendableMethodVisitoradds a call toExtensionService.onExtendableMethodEnterat the start, records each local variable as it is stored, and callsonExtendableMethodLeaveon every return and throw (at the return of a value the original value is registered first and the one the decorators leave is returned). This happens on every call, with or without decorators, so@Extendablebelongs on UI classes, not on hot code. -
Decorators.
ExtensionService.registerDecorator(decorator, className, methodName)attaches code before and after a method. Through theExtendableMethodContextit can read the method's arguments, read local variables by name (for example the menu the method just built, to add an item to it) and access fields and methods reflectively. On enter it can replace arguments, on leave it can replace the return value (chained through the decorators); it can't skip the method. -
Data. Extensions keep their data in the
attributesof existing entities, so no schema changes are needed and the data is part of backups. They can also listen to generation events and take part in cleanup (CleanupService.registerContributor). -
UI lifetime. An extension registers the components it adds with
OsgiService.bindAttachableComponenttogether with a callback that removes them; the callbacks of components still attached run when the extension is unloaded, each insideui.access(...)of the component's UI. -
Languages. An extension that implements
ExtensionHasLocalizationadds languages to the UI:OsgiServiceImplregisters itsLocalizationBundles inLocalizationafter the extension loads and removes them on unload (Localization ). -
Other extensions. Extensions don't import each other.
ToolServicelets them offer suites of tools (functions and commands) that a dialog with a Tools button gives the model, andIPCServicelets them register providers of text about a book or a story part under their bundle's symbolic name (removed when the extension is unloaded) and ask for it; to use one particular extension (the name generator) the others find it amongOsgiService.getLoadedExtensions()and call it by reflection (Sharing data between extensions). -
Inherited settings. An extension registers a line, a text area or a choice with
InheritedSettingRegistry(InheritedSetting); the Prompts tab of a book and the folder window show them with the other prompts and the values live in theattributesof the book or folder (InheritedSettingValues), resolved through the folders (Inherited settings). -
Styles. An extension that implements
ExtensionHasCssgets its CSS put into every open page:CssRegistry(owned byMainandViewer) reads the loaded extensions when its page is attached and listens to the loaded/unloaded events ofOsgiServiceafterwards (Styles of a plugin).
Because hooks are attached by class and method name and read local variables by name, extensions are tied to one version of the application. See Plugin development.
Files on disk
Everything is in <user.home>/.marginalia/ (see also The data folder):
Localization
All UI texts are looked up through the Localization bean by an L key (loc.getValue(L.LABEL_SAVE)). The bean
(LocalizationImpl) holds one LocalizationBundle per Language: the texts of every key, the Locale and the date
formats of that language, and the name shown in the language selector. The application ships LocalizationBundleEN
(the default language, which has to be complete - the application doesn't start otherwise) and LocalizationBundleJP;
they are listed in the localization bean in container-config.xml. Texts of template variables and macros (the
hints shown next to prompt fields) are localized the same way.
flowchart LR
SP["SessionPoint<br/>(session scoped)<br/>language"] -->|current language| Loc["LocalizationImpl<br/>getValue(L)"]
Loc --> B1["LocalizationBundleEN"]
Loc --> B2["LocalizationBundleJP"]
Loc --> B3["bundles of extensions"]
US["UserSetting.language"] -->|"Main, when the workspace opens"| SP
LS["LanguageSelector"] -->|saves, then reloads the user's pages| US-
Which language.
getValueand the formats use the language in the session'sSessionPoint. Without a request (startup, scheduled backups) or with a language that has no bundle (any more) the default language is used, and a text a bundle doesn't have is taken from the default language, so a bundle that lags behind the keys keeps working. Worker threads get the session throughThreadCopyRequestAttributeslike theuserbean. -
Choosing. The language of a user is
UserSetting.language(a language code,nullfor the default).Main.proceedWithLoginsetsSessionPoint.languagefrom it before the workspace is created, so the login page itself is always in the default language. TheLanguageSelectorat the bottom of the workspace (desktop sidebar, phone drawer) offersLocalization.getAvailableLanguages(); a change saves the setting, sets the session and reloads every UI of every session of the user (SessionManager.runForUsers), where the workspace opens again with the new language.Viewerdoesn't choose anything, it uses what is in itsSessionPoint. -
Extensions. An extension that implements
ExtensionHasLocalizationreturns bundles of its own;OsgiServiceImplregisters them afteronExtensionLoadand removes them when the extension is unloaded (see Example plugin). A language that already has a bundle is refused, so an extension can't replace English. -
Adding a language to the application. Copy
LocalizationBundleEN, translate, add the class as a bean to thebundleslist oflocalization.BundleCompletenessTestchecks that a bundle has every key and the same%s/%dplaceholders as the English text.