Compare commits
2
Commits
afbd76445d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29e82033ef | ||
|
|
623ada5f99 |
+11
-3
@@ -1,6 +1,6 @@
|
|||||||
# go-nc-exapp
|
# go-nc-exapp
|
||||||
|
|
||||||
Shared Go Library for Nextcloud ExApp Services: AppAPI authentication, OCS calls, per-user ExApp preferences, Notifications, Users and Groups, and optional Required Groups gating. ExApps import `gitea.neitzel.de/konrad/go-nc-exapp`. File storage and folder visits live in go-nc-files.
|
Shared Go Library for Nextcloud ExApp Services: AppAPI authentication, OCS calls, per-user ExApp preferences, Notifications, Users and Groups, the Requesting user's UI language, and optional Required Groups gating. ExApps import `gitea.neitzel.de/konrad/go-nc-exapp`. File storage and folder visits live in go-nc-files.
|
||||||
|
|
||||||
## Language
|
## Language
|
||||||
|
|
||||||
@@ -12,6 +12,14 @@ _Avoid_: API key (generic), session token
|
|||||||
The Nextcloud user on whose behalf the current ExApp request runs, taken from AppAPI authorization headers. WebDAV, preferences, and a Notification to that same user use this identity; there is no separate ExApp login.
|
The Nextcloud user on whose behalf the current ExApp request runs, taken from AppAPI authorization headers. WebDAV, preferences, and a Notification to that same user use this identity; there is no separate ExApp login.
|
||||||
_Avoid_: service account (for per-request identity), anonymous
|
_Avoid_: service account (for per-request identity), anonymous
|
||||||
|
|
||||||
|
**UI language**:
|
||||||
|
The Nextcloud language of the Requesting user, not the locale. A stored personal language is used. When none is stored, the browser language is used, then the instance default, then English. The Library resolves it once for a page. ExApp pages and the Library's own chrome use that same language for their Phrases. English and German are the languages the Library provides. A more specific code uses the broader language when that is the one provided. Any language the Library does not provide uses English. When the UI language cannot be read, it is English, and the page still renders.
|
||||||
|
_Avoid_: locale, Accept-Language, ExApp preference
|
||||||
|
|
||||||
|
**Phrase**:
|
||||||
|
A label or sentence on an ExApp page, identified by one stable name, not by the English text. English and German are two texts for that same Phrase. A Phrase is one text per language. A count is a hole, not a second form. English is the complete set. A Phrase with no text for the UI language shows the English text. An id with no English text shows the id. A Phrase may contain named holes for values the page inserts. English and German name the same holes and may place them differently. A hole with no value stays visible. Those values are not Phrases. The ExApp owns the Phrases of its pages and of its navigation items. The Library owns the Phrases of App navigation chrome and of Dialog buttons. A Library or an ExApp has one default set. A module keeps its own set with that module. The English text stays on the page, and the element names its Phrase, including a placeholder, a title, or an aria-label. The script shows the text for the UI language. A hole value is plain text. An error the page displays is the finished sentence for the UI language, not the id. The page and its script show the ExApp's default set and any module sets, as one map. A repeated id uses the text from the set added later. The Library writes its own Phrases into the chrome it renders, and that set is not in the map.
|
||||||
|
_Avoid_: Catalog, Message, user data, zone name, Notification subject
|
||||||
|
|
||||||
**Recipient**:
|
**Recipient**:
|
||||||
The Nextcloud user a Notification is created for. May be the Requesting user or another user. AppAPI accepts one Recipient per call; sending to a group or to all admins is many Notifications.
|
The Nextcloud user a Notification is created for. May be the Requesting user or another user. AppAPI accepts one Recipient per call; sending to a group or to all admins is many Notifications.
|
||||||
_Avoid_: destination, target (HTTP), addressee, treating a group as a Recipient
|
_Avoid_: destination, target (HTTP), addressee, treating a group as a Recipient
|
||||||
@@ -41,11 +49,11 @@ The Library check that enforces Required Groups for the Requesting user on ExApp
|
|||||||
_Avoid_: Nextcloud middleware, HaRP ACL, admin bypass, gating the top-menu bootstrap script
|
_Avoid_: Nextcloud middleware, HaRP ACL, admin bypass, gating the top-menu bootstrap script
|
||||||
|
|
||||||
**App navigation**:
|
**App navigation**:
|
||||||
The tree of named items along the left of an ExApp page. The ExApp supplies the tree. The selected item is the page on display. It plays the same role as the navigation in Nextcloud Files. On a screen at most 1024px wide the tree is hidden until the user opens it; choosing an item, pressing Escape, or clicking outside closes it again. An ExApp that does not use App navigation shows its own page instead.
|
The tree of named items along the left of an ExApp page. The ExApp supplies the tree. The selected item is the page on display. It plays the same role as the navigation in Nextcloud Files. On a screen at most 1024px wide the tree is hidden until the user opens it; choosing an item, pressing Escape, or clicking outside closes it again. An ExApp that does not use App navigation shows its own page instead. The Library's own phrases on that chrome follow the UI language. The names of the items are the ExApp's.
|
||||||
_Avoid_: Top Menu, sidebar, NcAppNavigation
|
_Avoid_: Top Menu, sidebar, NcAppNavigation
|
||||||
|
|
||||||
**Dialog**:
|
**Dialog**:
|
||||||
A modal window on an ExApp page, in the same Nextcloud theme as that page. It does not require App navigation. The page waits for the user's choice; that choice is not a server request. The heading, the message, and the button word are plain text. The heading may be omitted. The fixed buttons are the English words OK and Cancel. One of three kinds: a Message (short text at info, warning, or error, dismissed with one button), a Confirm (yes or no; the agreeing button may be destructive), or a Prompt (one entered value, or cancelled). A Prompt with no value cannot be agreed. A successful Prompt value has no surrounding spaces. The caller may supply a starting value. A Prompt field has no label of its own; the message is the text above it.
|
A modal window on an ExApp page, in the same Nextcloud theme as that page. It does not require App navigation. The page waits for the user's choice; that choice is not a server request. The heading, the message, and the button word are plain text. The heading may be omitted. The fixed buttons follow the UI language. The Library provides them in English and German. One of three kinds: a Message (short text at info, warning, or error, dismissed with one button), a Confirm (yes or no; the agreeing button may be destructive), or a Prompt (one entered value, or cancelled). A Prompt with no value cannot be agreed. A successful Prompt value has no surrounding spaces. The caller may supply a starting value. A Prompt field has no label of its own; the message is the text above it.
|
||||||
_Avoid_: popup, browser alert/confirm/prompt, Notification, toast, HTML message
|
_Avoid_: popup, browser alert/confirm/prompt, Notification, toast, HTML message
|
||||||
|
|
||||||
**Top Menu visibility**:
|
**Top Menu visibility**:
|
||||||
|
|||||||
+1
-1
@@ -269,7 +269,7 @@ func firstDiff(want, got string) string {
|
|||||||
w := strings.Split(want, "\n")
|
w := strings.Split(want, "\n")
|
||||||
g := strings.Split(got, "\n")
|
g := strings.Split(got, "\n")
|
||||||
n := min(len(g), len(w))
|
n := min(len(g), len(w))
|
||||||
for i := 0; i < n; i++ {
|
for i := range n {
|
||||||
if w[i] != g[i] {
|
if w[i] != g[i] {
|
||||||
return fmt.Sprintf("line %d:\n got: %s\nwant: %s", i+1, g[i], w[i])
|
return fmt.Sprintf("line %d:\n got: %s\nwant: %s", i+1, g[i], w[i])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,18 +2,50 @@ package gonexapp
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
_ "embed"
|
_ "embed"
|
||||||
|
"html"
|
||||||
"html/template"
|
"html/template"
|
||||||
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed dialog.js
|
//go:embed dialog.js
|
||||||
var dialogJS string
|
var dialogJS string
|
||||||
|
|
||||||
// DialogHTML is the Dialog markup and script for an ExApp page.
|
// DialogHTML is the Dialog markup and script for an ExApp page, in English.
|
||||||
// App navigation includes it. A page the ExApp renders itself inserts the
|
// App navigation includes the same fragment in the UI language and does not
|
||||||
// same fragment. The page then calls exappDialog.message, exappDialog.confirm,
|
// take a language argument. A page the ExApp renders itself inserts this
|
||||||
|
// fragment, or Credentials.DialogHTML when that page has a request.
|
||||||
|
// The page then calls exappDialog.message, exappDialog.confirm,
|
||||||
// or exappDialog.prompt and waits for the user's choice.
|
// or exappDialog.prompt and waits for the user's choice.
|
||||||
func DialogHTML() template.HTML {
|
func DialogHTML() template.HTML {
|
||||||
return template.HTML(dialogStyle + dialogMarkup + "<script>" + dialogJS + "</script>")
|
return dialogHTML("en")
|
||||||
|
}
|
||||||
|
|
||||||
|
// DialogHTML is the Dialog fragment in the UI language of r.
|
||||||
|
// A missing Requesting user or a failed read uses English.
|
||||||
|
// The caller does not pass a language.
|
||||||
|
func (c Credentials) DialogHTML(r *http.Request) template.HTML {
|
||||||
|
return dialogHTML(c.UILanguage(r))
|
||||||
|
}
|
||||||
|
|
||||||
|
func dialogHTML(lang string) template.HTML {
|
||||||
|
cancel := html.EscapeString(chromePhrase(lang, "dialog.cancel", nil))
|
||||||
|
agree := html.EscapeString(chromePhrase(lang, "dialog.ok", nil))
|
||||||
|
return template.HTML(dialogStyle + dialogButtons(cancel, agree) + "<script>" + dialogJS + "</script>")
|
||||||
|
}
|
||||||
|
|
||||||
|
func dialogButtons(cancel, agree string) string {
|
||||||
|
return `<dialog id="exapp-dialog" aria-modal="true">
|
||||||
|
<div id="exapp-dialog-panel">
|
||||||
|
<h2 id="exapp-dialog-heading" hidden></h2>
|
||||||
|
<p id="exapp-dialog-severity" hidden></p>
|
||||||
|
<p id="exapp-dialog-message"></p>
|
||||||
|
<input id="exapp-dialog-value" hidden autocomplete="off">
|
||||||
|
<div id="exapp-dialog-actions">
|
||||||
|
<button type="button" id="exapp-dialog-cancel">` + cancel + `</button>
|
||||||
|
<button type="button" id="exapp-dialog-agree">` + agree + `</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>`
|
||||||
}
|
}
|
||||||
|
|
||||||
const dialogStyle = `<style>
|
const dialogStyle = `<style>
|
||||||
@@ -78,16 +110,3 @@ const dialogStyle = `<style>
|
|||||||
}
|
}
|
||||||
#exapp-dialog-actions button#exapp-dialog-agree:disabled { opacity: 0.45; cursor: default; }
|
#exapp-dialog-actions button#exapp-dialog-agree:disabled { opacity: 0.45; cursor: default; }
|
||||||
</style>`
|
</style>`
|
||||||
|
|
||||||
const dialogMarkup = `<dialog id="exapp-dialog" aria-modal="true">
|
|
||||||
<div id="exapp-dialog-panel">
|
|
||||||
<h2 id="exapp-dialog-heading" hidden></h2>
|
|
||||||
<p id="exapp-dialog-severity" hidden></p>
|
|
||||||
<p id="exapp-dialog-message"></p>
|
|
||||||
<input id="exapp-dialog-value" hidden autocomplete="off">
|
|
||||||
<div id="exapp-dialog-actions">
|
|
||||||
<button type="button" id="exapp-dialog-cancel">Cancel</button>
|
|
||||||
<button type="button" id="exapp-dialog-agree">OK</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</dialog>`
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
var field = document.getElementById("exapp-dialog-value");
|
var field = document.getElementById("exapp-dialog-value");
|
||||||
var cancelBtn = document.getElementById("exapp-dialog-cancel");
|
var cancelBtn = document.getElementById("exapp-dialog-cancel");
|
||||||
var agreeBtn = document.getElementById("exapp-dialog-agree");
|
var agreeBtn = document.getElementById("exapp-dialog-agree");
|
||||||
|
var defaultAgree = agreeBtn.textContent;
|
||||||
var queue = [];
|
var queue = [];
|
||||||
var busy = false;
|
var busy = false;
|
||||||
var finish = null;
|
var finish = null;
|
||||||
@@ -97,7 +98,7 @@
|
|||||||
field.hidden = !isPrompt;
|
field.hidden = !isPrompt;
|
||||||
field.value = isPrompt ? textOf(opts.value) : "";
|
field.value = isPrompt ? textOf(opts.value) : "";
|
||||||
cancelBtn.hidden = kind === "message";
|
cancelBtn.hidden = kind === "message";
|
||||||
agreeBtn.textContent = kind !== "message" && opts.agree ? textOf(opts.agree) : "OK";
|
agreeBtn.textContent = kind !== "message" && opts.agree ? textOf(opts.agree) : defaultAgree;
|
||||||
agreeBtn.classList.toggle("destructive", kind === "confirm" && !!opts.destructive);
|
agreeBtn.classList.toggle("destructive", kind === "confirm" && !!opts.destructive);
|
||||||
refreshAgree();
|
refreshAgree();
|
||||||
if (!dialog.open) dialog.showModal();
|
if (!dialog.open) dialog.showModal();
|
||||||
|
|||||||
+88
-3
@@ -109,7 +109,15 @@ NextcloudIcons are SVG paths served by Nextcloud core under /core/img/. The libr
|
|||||||
func DialogHTML() template.HTML
|
func DialogHTML() template.HTML
|
||||||
```
|
```
|
||||||
|
|
||||||
DialogHTML is the Dialog markup and script for an ExApp page. App navigation includes it. A page the ExApp renders itself inserts the same fragment. The page then calls exappDialog.message, exappDialog.confirm, or exappDialog.prompt and waits for the user's choice.
|
DialogHTML is the Dialog markup and script for an ExApp page, in English. App navigation includes the same fragment in the UI language and does not take a language argument. A page the ExApp renders itself inserts this fragment, or Credentials.DialogHTML when that page has a request. The page then calls exappDialog.message, exappDialog.confirm, or exappDialog.prompt and waits for the user's choice.
|
||||||
|
|
||||||
|
### FillScript
|
||||||
|
|
||||||
|
```go
|
||||||
|
func FillScript() template.HTML
|
||||||
|
```
|
||||||
|
|
||||||
|
FillScript replaces English source after load and refreshes Fold from the visible item name. It does not parse Phrase text as HTML. App navigation includes it. A page the ExApp renders itself places Script in the head and FillScript at the end of the body.
|
||||||
|
|
||||||
### ParseCacheSeconds
|
### ParseCacheSeconds
|
||||||
|
|
||||||
@@ -275,6 +283,15 @@ type AppNavigation struct {
|
|||||||
// DisableTheme skips the Nextcloud theme stylesheets. The zero value
|
// DisableTheme skips the Nextcloud theme stylesheets. The zero value
|
||||||
// loads the active theme.
|
// loads the active theme.
|
||||||
DisableTheme bool
|
DisableTheme bool
|
||||||
|
// Cred reads the UI language for this page from Nextcloud.
|
||||||
|
// The zero value leaves the page in English.
|
||||||
|
// The caller does not pass a language.
|
||||||
|
Cred Credentials
|
||||||
|
// Phrases is the ExApp's set. The page script receives it.
|
||||||
|
// Library chrome is not included. The zero value leaves the page in English.
|
||||||
|
Phrases Phrases
|
||||||
|
// MissingPhrase names the ExApp Phrase for MissingMessage.
|
||||||
|
MissingPhrase string
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -340,6 +357,22 @@ func (c Credentials) AuthHeaders() http.Header
|
|||||||
|
|
||||||
AuthHeaders returns AppAPI headers for a request to Nextcloud. The set is AA-VERSION, EX-APP-ID, EX-APP-VERSION, AUTHORIZATION-APP-API, and OCS-APIRequest. AUTHORIZATION-APP-API is base64 of UserID, a colon, and AppSecret. The method does not return an error; empty fields are sent as empty.
|
AuthHeaders returns AppAPI headers for a request to Nextcloud. The set is AA-VERSION, EX-APP-ID, EX-APP-VERSION, AUTHORIZATION-APP-API, and OCS-APIRequest. AUTHORIZATION-APP-API is base64 of UserID, a colon, and AppSecret. The method does not return an error; empty fields are sent as empty.
|
||||||
|
|
||||||
|
#### Credentials.DialogHTML
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (c Credentials) DialogHTML(r *http.Request) template.HTML
|
||||||
|
```
|
||||||
|
|
||||||
|
DialogHTML is the Dialog fragment in the UI language of r. A missing Requesting user or a failed read uses English. The caller does not pass a language.
|
||||||
|
|
||||||
|
#### Credentials.UILanguage
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (c Credentials) UILanguage(r *http.Request) string
|
||||||
|
```
|
||||||
|
|
||||||
|
UILanguage is the language of the Phrases for this request, "en" or "de". It reads the Requesting user's language from the Provisioning OCS user record and forwards the request's Accept-Language on that read. The locale field is ignored. A more specific code uses the broader language when this Library provides that language. Any other code, a missing Requesting user, or a failed read is English, and the page still renders. A second call with the request returned from a page render reuses that result.
|
||||||
|
|
||||||
#### Credentials.WithUser
|
#### Credentials.WithUser
|
||||||
|
|
||||||
```go
|
```go
|
||||||
@@ -399,11 +432,12 @@ type Item struct {
|
|||||||
ID string
|
ID string
|
||||||
Label string
|
Label string
|
||||||
Icon string
|
Icon string
|
||||||
|
Phrase string
|
||||||
Children []Item
|
Children []Item
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Item is one App navigation entry. The ExApp chooses the ids and labels. Children may nest. The shell does not interpret the ids. Icon is an optional same-origin image URL. NextcloudIcons names the core SVGs.
|
Item is one App navigation entry. The ExApp chooses the ids and labels. Children may nest. The shell does not interpret the ids. Icon is an optional same-origin image URL. NextcloudIcons names the core SVGs. Phrase names the ExApp Phrase for Label. The English label stays in the page until the script replaces it. Empty leaves the label as written.
|
||||||
|
|
||||||
### Notification
|
### Notification
|
||||||
|
|
||||||
@@ -425,10 +459,11 @@ Notification is one Nextcloud bell for a single Recipient.
|
|||||||
type OCSClient struct {
|
type OCSClient struct {
|
||||||
Cred Credentials
|
Cred Credentials
|
||||||
Client *http.Client
|
Client *http.Client
|
||||||
|
Header http.Header
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
OCSClient performs AppAPI-authenticated OCS requests with format=json.
|
OCSClient performs AppAPI-authenticated OCS requests with format=json. Header is copied onto the outbound request after the AppAPI auth headers. The UI language read uses it to forward Accept-Language.
|
||||||
|
|
||||||
#### OCSClient.Call
|
#### OCSClient.Call
|
||||||
|
|
||||||
@@ -446,3 +481,53 @@ func (c OCSClient) URL(path string) string
|
|||||||
|
|
||||||
URL builds an OCS URL under /ocs/v2.php with format=json.
|
URL builds an OCS URL under /ocs/v2.php with format=json.
|
||||||
|
|
||||||
|
### Phrases
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Phrases struct {
|
||||||
|
// contains filtered or unexported fields
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Phrases is one ExApp's merged Phrase set. English is the complete set. A module set is merged with Merge; a repeated id uses the set added later. The page script receives this set. Library chrome is not included.
|
||||||
|
|
||||||
|
#### Merge
|
||||||
|
|
||||||
|
```go
|
||||||
|
func Merge(sets ...Phrases) Phrases
|
||||||
|
```
|
||||||
|
|
||||||
|
Merge returns the sets combined in order. A repeated id uses the text from the set added later.
|
||||||
|
|
||||||
|
#### ParsePhrases
|
||||||
|
|
||||||
|
```go
|
||||||
|
func ParsePhrases(en, de []byte) (Phrases, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
ParsePhrases reads an English object and a German object.
|
||||||
|
|
||||||
|
#### PhrasesFromFS
|
||||||
|
|
||||||
|
```go
|
||||||
|
func PhrasesFromFS(fsys fs.FS) (Phrases, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
PhrasesFromFS reads i18n/en.json and i18n/de.json. Each file is one JSON object of id → text.
|
||||||
|
|
||||||
|
#### Phrases.Script
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (p Phrases) Script(lang string) template.HTML
|
||||||
|
```
|
||||||
|
|
||||||
|
Script defines exappPhrase for the page, from this set only.
|
||||||
|
|
||||||
|
#### Phrases.Sentence
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (p Phrases) Sentence(lang, id string, holes map[string]string) string
|
||||||
|
```
|
||||||
|
|
||||||
|
Sentence is the finished text for lang. A missing text for that language uses the English text. An id missing from English is the id. A hole with no value stays visible as {name}. The result is plain text; the caller escapes it when writing HTML.
|
||||||
|
|
||||||
|
|||||||
+24
-3
@@ -335,11 +335,13 @@ Use it when the ExApp wants Nextcloud's navigation column, theme background, and
|
|||||||
### Call sequence
|
### Call sequence
|
||||||
|
|
||||||
1. Fill `AppNavigation`. `Items`, `Page`, and `DefaultID` are required. `Header` and `MissingMessage` are optional.
|
1. Fill `AppNavigation`. `Items`, `Page`, and `DefaultID` are required. `Header` and `MissingMessage` are optional.
|
||||||
2. `Items` returns the tree for this request. An `Item` has an id, a label, optional children, and an optional `Icon`.
|
2. `Items` returns the tree for this request. An `Item` has an id, a label, optional children, an optional `Icon`, and an optional `Phrase` id. The label is the English source. When `Phrase` is set, the page script replaces it for the UI language.
|
||||||
3. Set `Icon` to a same-origin image URL, or to a field of `NextcloudIcons` (core SVGs Nextcloud already serves, such as `NextcloudIcons.Folder`).
|
3. Set `Icon` to a same-origin image URL, or to a field of `NextcloudIcons` (core SVGs Nextcloud already serves, such as `NextcloudIcons.Folder`).
|
||||||
4. `Page` returns the HTML body for the selected id and whether that id is known.
|
4. `Page` returns the HTML body for the selected id and whether that id is known.
|
||||||
5. `SelectKey` defaults to `item`. Other query parameters, including a Visit folder, stay on the address.
|
5. `SelectKey` defaults to `item`. Other query parameters, including a Visit folder, stay on the address.
|
||||||
6. Mount `Handler` on the ExApp's page route.
|
6. Set `Cred` to the ExApp's Nextcloud credentials. The shell reads the UI language once from that. The zero value leaves the page in English. Do not pass a language.
|
||||||
|
7. Set `Phrases` to the ExApp's set. `MissingPhrase` names the Phrase for `MissingMessage`.
|
||||||
|
8. Mount `Handler` on the ExApp's page route.
|
||||||
|
|
||||||
A missing item renders `DefaultID`, shows `MissingMessage`, and publishes the corrected query on `data-address`. Every parent starts expanded. At 1024px and below, the tree stays hidden until the user opens it. Choosing an item, pressing Escape, or clicking outside closes it.
|
A missing item renders `DefaultID`, shows `MissingMessage`, and publishes the corrected query on `data-address`. Every parent starts expanded. At 1024px and below, the tree stays hidden until the user opens it. Choosing an item, pressing Escape, or clicking outside closes it.
|
||||||
|
|
||||||
@@ -369,6 +371,25 @@ nav := gonexapp.AppNavigation{
|
|||||||
http.Handle("/page", nav.Handler())
|
http.Handle("/page", nav.Handler())
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## UI language and Phrases
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
An ExApp page speaks the Requesting user's UI language. The Library resolves it once per page and writes its own chrome. The ExApp owns the Phrases of its pages and navigation items.
|
||||||
|
|
||||||
|
### Call sequence
|
||||||
|
|
||||||
|
1. `Credentials.UILanguage(r)` reads the Provisioning OCS user field `language` and forwards the page's `Accept-Language`. The field `locale` is ignored. `de` and `de_DE` use German. `en` and `en_GB` use English. Any other code, a missing user, or a failed read is English, and the page still renders. A later call on the same page request reuses that result.
|
||||||
|
2. Put `i18n/en.json` and `i18n/de.json` at the project root, or beside a module. Each file is one JSON object of stable dotted id → text. Embed the directory and load it with `PhrasesFromFS`. `Merge` combines sets; a repeated id uses the set added later. CheckDNS and StalwartAdmin use only the default set.
|
||||||
|
3. English is the complete set. A missing German text shows the English text. An unknown id shows the id. A hole is `{name}`. English and German name the same holes and may place them differently. A hole with no value stays as `{name}`. The value is plain text.
|
||||||
|
4. Leave the English sentence in the HTML. Name the Phrase with `data-phrase` on the text, `data-phrase-placeholder`, `data-phrase-title`, or `data-phrase-aria-label`. A hole value is JSON on `data-phrase-holes`. App navigation injects `Phrases.Script` and `FillScript`. A page the ExApp renders itself puts `Script(lang)` in the head and `FillScript()` at the end of the body.
|
||||||
|
5. A sentence the script creates calls `exappPhrase(id, holes)`. The map does not include Library chrome.
|
||||||
|
6. An error the page displays is `Phrases.Sentence(lang, id, holes)`, the finished sentence. Escape it when writing HTML. A log line the page does not show stays English.
|
||||||
|
|
||||||
|
### Errors
|
||||||
|
|
||||||
|
`UILanguage` does not return an error. A failed read is English. `PhrasesFromFS` and `ParsePhrases` return an error when a file is missing or is not a JSON object of strings.
|
||||||
|
|
||||||
## Dialog
|
## Dialog
|
||||||
|
|
||||||
### Purpose
|
### Purpose
|
||||||
@@ -377,7 +398,7 @@ The Dialog is a modal for a message, a confirm, or a prompt. The page calls it f
|
|||||||
|
|
||||||
### When to use it
|
### When to use it
|
||||||
|
|
||||||
App navigation already includes the dialog. Insert `DialogHTML` on a page the ExApp renders itself. The buttons say OK and Cancel. The agreeing button's word may be replaced. The choice stays in the page.
|
App navigation already includes the dialog in the UI language. Insert `DialogHTML()` on a page the ExApp renders itself when that page stays in English, or `Credentials.DialogHTML(r)` so the same read supplies the language. OK stays OK. Cancel is Abbrechen in German. A renamed agreeing button is the exact text the caller passes. The choice stays in the page.
|
||||||
|
|
||||||
### Call sequence
|
### Call sequence
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"dialog.ok": "OK",
|
||||||
|
"dialog.cancel": "Abbrechen",
|
||||||
|
"nav.open": "Navigation öffnen",
|
||||||
|
"nav.close": "Navigation schließen",
|
||||||
|
"nav.fold": "{label} einklappen"
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"dialog.ok": "OK",
|
||||||
|
"dialog.cancel": "Cancel",
|
||||||
|
"nav.open": "Open navigation",
|
||||||
|
"nav.close": "Close navigation",
|
||||||
|
"nav.fold": "Fold {label}"
|
||||||
|
}
|
||||||
+163
@@ -0,0 +1,163 @@
|
|||||||
|
package gonexapp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
_ "embed"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed i18n/en.json
|
||||||
|
var chromeENJSON []byte
|
||||||
|
|
||||||
|
//go:embed i18n/de.json
|
||||||
|
var chromeDEJSON []byte
|
||||||
|
|
||||||
|
// chromeSets is the Library's own chrome. It is not part of the map the page script sees.
|
||||||
|
var chromeSets = map[string]map[string]string{
|
||||||
|
"en": mustChrome(chromeENJSON),
|
||||||
|
"de": mustChrome(chromeDEJSON),
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustChrome(raw []byte) map[string]string {
|
||||||
|
var m map[string]string
|
||||||
|
if err := json.Unmarshal(raw, &m); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
type uiLanguageKey struct{}
|
||||||
|
|
||||||
|
// UILanguage is the language of the Phrases for this request, "en" or "de".
|
||||||
|
// It reads the Requesting user's language from the Provisioning OCS user
|
||||||
|
// record and forwards the request's Accept-Language on that read.
|
||||||
|
// The locale field is ignored. A more specific code uses the broader
|
||||||
|
// language when this Library provides that language. Any other code, a
|
||||||
|
// missing Requesting user, or a failed read is English, and the page still renders.
|
||||||
|
// A second call with the request returned from a page render reuses that result.
|
||||||
|
func (c Credentials) UILanguage(r *http.Request) string {
|
||||||
|
if r == nil {
|
||||||
|
return "en"
|
||||||
|
}
|
||||||
|
if lang, ok := r.Context().Value(uiLanguageKey{}).(string); ok {
|
||||||
|
return lang
|
||||||
|
}
|
||||||
|
return c.readUILanguage(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Credentials) readUILanguage(r *http.Request) string {
|
||||||
|
if c.BaseURL == "" {
|
||||||
|
return "en"
|
||||||
|
}
|
||||||
|
userID, err := UserFromRequest(r)
|
||||||
|
if err != nil || userID == "" {
|
||||||
|
return "en"
|
||||||
|
}
|
||||||
|
ocs := OCSClient{Cred: c.WithUser(userID)}
|
||||||
|
if al := r.Header.Get("Accept-Language"); al != "" {
|
||||||
|
ocs.Header = http.Header{"Accept-Language": {al}}
|
||||||
|
}
|
||||||
|
raw, err := ocs.Call(http.MethodGet, "cloud/users/"+url.PathEscape(userID), nil)
|
||||||
|
if err != nil {
|
||||||
|
return "en"
|
||||||
|
}
|
||||||
|
return matchLanguage(userLanguage(raw), chromeHas)
|
||||||
|
}
|
||||||
|
|
||||||
|
func chromeHas(code string) bool {
|
||||||
|
_, ok := chromeSets[code]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchLanguage picks a Phrase set for the code Nextcloud returned.
|
||||||
|
// The code itself wins when that set exists. Otherwise the part before "_"
|
||||||
|
// wins when that set exists. Otherwise English.
|
||||||
|
func matchLanguage(code string, has func(string) bool) string {
|
||||||
|
code = strings.TrimSpace(code)
|
||||||
|
if code != "" && has(code) {
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
if i := strings.IndexByte(code, '_'); i > 0 {
|
||||||
|
if base := code[:i]; has(base) {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "en"
|
||||||
|
}
|
||||||
|
|
||||||
|
func userLanguage(raw []byte) string {
|
||||||
|
var parsed struct {
|
||||||
|
OCS struct {
|
||||||
|
Data struct {
|
||||||
|
Language string `json:"language"`
|
||||||
|
} `json:"data"`
|
||||||
|
} `json:"ocs"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return parsed.OCS.Data.Language
|
||||||
|
}
|
||||||
|
|
||||||
|
func chromePhrase(lang, id string, holes map[string]string) string {
|
||||||
|
if _, ok := chromeSets[lang]; !ok {
|
||||||
|
lang = "en"
|
||||||
|
}
|
||||||
|
text := chromeSets[lang][id]
|
||||||
|
if text == "" && lang != "en" {
|
||||||
|
text = chromeSets["en"][id]
|
||||||
|
}
|
||||||
|
if text == "" {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
return fillHoles(text, holes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fillHoles replaces {name} with the plain-text value.
|
||||||
|
// A hole with no value stays visible as {name}.
|
||||||
|
// A value is not scanned for further holes.
|
||||||
|
func fillHoles(text string, holes map[string]string) string {
|
||||||
|
if len(holes) == 0 || !strings.Contains(text, "{") {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
b.Grow(len(text))
|
||||||
|
for i := 0; i < len(text); {
|
||||||
|
if text[i] != '{' {
|
||||||
|
b.WriteByte(text[i])
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
end := strings.IndexByte(text[i+1:], '}')
|
||||||
|
if end < 0 {
|
||||||
|
b.WriteByte(text[i])
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := text[i+1 : i+1+end]
|
||||||
|
value, ok := holes[name]
|
||||||
|
if !ok {
|
||||||
|
b.WriteByte(text[i])
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteString(value)
|
||||||
|
i += end + 2
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// rememberUILanguage returns r carrying lang so a later UILanguage call
|
||||||
|
// on this page does not read Nextcloud again.
|
||||||
|
func rememberUILanguage(r *http.Request, lang string) *http.Request {
|
||||||
|
if r == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, ok := r.Context().Value(uiLanguageKey{}).(string); ok {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
return r.WithContext(context.WithValue(r.Context(), uiLanguageKey{}, lang))
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
package gonexapp_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/chromedp/cdproto/network"
|
||||||
|
"github.com/chromedp/chromedp"
|
||||||
|
|
||||||
|
"gitea.neitzel.de/konrad/go-nc-exapp"
|
||||||
|
)
|
||||||
|
|
||||||
|
const browserLanguage = "de-DE,de;q=0.9"
|
||||||
|
|
||||||
|
func TestUILanguageChrome(t *testing.T) {
|
||||||
|
german := chromeSeen{
|
||||||
|
lang: "de", open: "Navigation öffnen", close: "Navigation schließen",
|
||||||
|
fold: "Domains einklappen", cancel: "Abbrechen", ok: "OK",
|
||||||
|
}
|
||||||
|
english := chromeSeen{
|
||||||
|
lang: "en", open: "Open navigation", close: "Close navigation",
|
||||||
|
fold: "Fold Domains", cancel: "Cancel", ok: "OK",
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
user bool
|
||||||
|
status int
|
||||||
|
ncLang string
|
||||||
|
want chromeSeen
|
||||||
|
}{
|
||||||
|
{name: "german", user: true, status: http.StatusOK, ncLang: "de", want: german},
|
||||||
|
{name: "english", user: true, status: http.StatusOK, ncLang: "en", want: english},
|
||||||
|
{name: "formal german", user: true, status: http.StatusOK, ncLang: "de_DE", want: german},
|
||||||
|
{name: "british english", user: true, status: http.StatusOK, ncLang: "en_GB", want: english},
|
||||||
|
{name: "french", user: true, status: http.StatusOK, ncLang: "fr", want: english},
|
||||||
|
{name: "failed read", user: true, status: http.StatusInternalServerError, ncLang: "de", want: english},
|
||||||
|
{name: "missing user", user: false, status: http.StatusOK, ncLang: "de", want: english},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
nc := fakeLanguage(tt.status, tt.ncLang)
|
||||||
|
t.Cleanup(nc.Close)
|
||||||
|
page := languagePage(nc.URL)
|
||||||
|
t.Cleanup(page.Close)
|
||||||
|
|
||||||
|
headers := network.Headers{"Accept-Language": browserLanguage}
|
||||||
|
if tt.user {
|
||||||
|
headers["AUTHORIZATION-APP-API"] = base64.StdEncoding.EncodeToString([]byte("alice:secret"))
|
||||||
|
}
|
||||||
|
got := readChrome(t, page.URL+"/?item=domains", headers)
|
||||||
|
if !strings.Contains(got.page, "domains-page") {
|
||||||
|
t.Fatalf("page did not render: %+v", got)
|
||||||
|
}
|
||||||
|
if got.lang != tt.want.lang || got.open != tt.want.open || got.close != tt.want.close ||
|
||||||
|
got.fold != tt.want.fold || got.cancel != tt.want.cancel || got.ok != tt.want.ok || got.agree != "Remove" {
|
||||||
|
t.Fatalf("got %+v, want %+v with agree %q", got, tt.want, "Remove")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type chromeSeen struct {
|
||||||
|
lang string
|
||||||
|
open string
|
||||||
|
close string
|
||||||
|
fold string
|
||||||
|
cancel string
|
||||||
|
ok string
|
||||||
|
agree string
|
||||||
|
page string
|
||||||
|
}
|
||||||
|
|
||||||
|
func fakeLanguage(status int, language string) *httptest.Server {
|
||||||
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/cloud/users/") {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if status != http.StatusOK {
|
||||||
|
http.Error(w, "language unavailable", status)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// A read that drops the browser language must not look like a hit.
|
||||||
|
lang := language
|
||||||
|
if r.Header.Get("Accept-Language") != browserLanguage {
|
||||||
|
lang = "en"
|
||||||
|
}
|
||||||
|
// locale differs from language so a read of the wrong field fails the page.
|
||||||
|
locale := "fr"
|
||||||
|
if language == "fr" || language == "en_GB" {
|
||||||
|
locale = "de"
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
fmt.Fprintf(w, `{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":{"language":%q,"locale":%q}}}`, lang, locale)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func languagePage(ncBase string) *httptest.Server {
|
||||||
|
nav := sampleNavigation()
|
||||||
|
nav.Cred = gonexapp.Credentials{
|
||||||
|
BaseURL: ncBase,
|
||||||
|
AppID: "exapp",
|
||||||
|
AppVersion: "1.0.0",
|
||||||
|
AAVersion: "1.0.0",
|
||||||
|
AppSecret: "secret",
|
||||||
|
}
|
||||||
|
nav.Page = func(_ *http.Request, id string) (string, bool) {
|
||||||
|
switch id {
|
||||||
|
case "domains":
|
||||||
|
return `<p>domains-page</p><button type="button" id="ask">Ask</button>
|
||||||
|
<script>
|
||||||
|
document.getElementById("ask").addEventListener("click", function () {
|
||||||
|
exappDialog.confirm({ text: "Sure?", agree: "Remove" });
|
||||||
|
});
|
||||||
|
</script>`, true
|
||||||
|
case "keys":
|
||||||
|
return "<p>keys-page</p>", true
|
||||||
|
case "example.com":
|
||||||
|
return "<p>zone-page</p>", true
|
||||||
|
default:
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return httptest.NewServer(nav.Handler())
|
||||||
|
}
|
||||||
|
|
||||||
|
func readChrome(t *testing.T, pageURL string, headers network.Headers) chromeSeen {
|
||||||
|
t.Helper()
|
||||||
|
ctx, cancel := chromedp.NewContext(allocCtx)
|
||||||
|
t.Cleanup(cancel)
|
||||||
|
ctx, cancel = context.WithTimeout(ctx, 25*time.Second)
|
||||||
|
t.Cleanup(cancel)
|
||||||
|
|
||||||
|
var raw, closeLabel, agree string
|
||||||
|
err := chromedp.Run(ctx,
|
||||||
|
network.Enable(),
|
||||||
|
network.SetExtraHTTPHeaders(headers),
|
||||||
|
chromedp.Navigate(pageURL),
|
||||||
|
chromedp.Evaluate(chromeProbeJS, &raw),
|
||||||
|
chromedp.Evaluate(`document.getElementById("nav-toggle").click()`, nil),
|
||||||
|
chromedp.Evaluate(`document.getElementById("nav-toggle").getAttribute("aria-label")`, &closeLabel),
|
||||||
|
chromedp.Evaluate(`document.getElementById("ask").click()`, nil),
|
||||||
|
chromedp.Evaluate(`document.getElementById("exapp-dialog-agree").textContent`, &agree),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var probe struct {
|
||||||
|
Lang string `json:"lang"`
|
||||||
|
Open string `json:"open"`
|
||||||
|
Fold string `json:"fold"`
|
||||||
|
Cancel string `json:"cancel"`
|
||||||
|
OK string `json:"ok"`
|
||||||
|
Page string `json:"page"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(raw), &probe); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return chromeSeen{
|
||||||
|
lang: probe.Lang, open: probe.Open, close: closeLabel, fold: probe.Fold,
|
||||||
|
cancel: probe.Cancel, ok: probe.OK, agree: agree, page: probe.Page,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const chromeProbeJS = `(() => {
|
||||||
|
const fold = document.querySelector("button.fold");
|
||||||
|
return JSON.stringify({
|
||||||
|
lang: document.documentElement.lang,
|
||||||
|
open: document.getElementById("nav-toggle").getAttribute("aria-label"),
|
||||||
|
fold: fold ? fold.getAttribute("aria-label") : "",
|
||||||
|
cancel: document.getElementById("exapp-dialog-cancel").textContent,
|
||||||
|
ok: document.getElementById("exapp-dialog-agree").textContent,
|
||||||
|
page: document.querySelector("main").textContent
|
||||||
|
});
|
||||||
|
})()`
|
||||||
+49
-9
@@ -12,10 +12,13 @@ const shellCSP = "default-src 'none'; base-uri 'none'; form-action 'none'; frame
|
|||||||
// Item is one App navigation entry. The ExApp chooses the ids and labels.
|
// Item is one App navigation entry. The ExApp chooses the ids and labels.
|
||||||
// Children may nest. The shell does not interpret the ids.
|
// Children may nest. The shell does not interpret the ids.
|
||||||
// Icon is an optional same-origin image URL. NextcloudIcons names the core SVGs.
|
// Icon is an optional same-origin image URL. NextcloudIcons names the core SVGs.
|
||||||
|
// Phrase names the ExApp Phrase for Label. The English label stays in the
|
||||||
|
// page until the script replaces it. Empty leaves the label as written.
|
||||||
type Item struct {
|
type Item struct {
|
||||||
ID string
|
ID string
|
||||||
Label string
|
Label string
|
||||||
Icon string
|
Icon string
|
||||||
|
Phrase string
|
||||||
Children []Item
|
Children []Item
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +39,15 @@ type AppNavigation struct {
|
|||||||
// DisableTheme skips the Nextcloud theme stylesheets. The zero value
|
// DisableTheme skips the Nextcloud theme stylesheets. The zero value
|
||||||
// loads the active theme.
|
// loads the active theme.
|
||||||
DisableTheme bool
|
DisableTheme bool
|
||||||
|
// Cred reads the UI language for this page from Nextcloud.
|
||||||
|
// The zero value leaves the page in English.
|
||||||
|
// The caller does not pass a language.
|
||||||
|
Cred Credentials
|
||||||
|
// Phrases is the ExApp's set. The page script receives it.
|
||||||
|
// Library chrome is not included. The zero value leaves the page in English.
|
||||||
|
Phrases Phrases
|
||||||
|
// MissingPhrase names the ExApp Phrase for MissingMessage.
|
||||||
|
MissingPhrase string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handler serves the shell. The selected item is the SelectKey query parameter.
|
// Handler serves the shell. The selected item is the SelectKey query parameter.
|
||||||
@@ -54,6 +66,8 @@ func (n AppNavigation) selectKey() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (n AppNavigation) serve(w http.ResponseWriter, r *http.Request) {
|
func (n AppNavigation) serve(w http.ResponseWriter, r *http.Request) {
|
||||||
|
lang := n.Cred.UILanguage(r)
|
||||||
|
r = rememberUILanguage(r, lang)
|
||||||
var items []Item
|
var items []Item
|
||||||
if n.Items != nil {
|
if n.Items != nil {
|
||||||
items = n.Items(r)
|
items = n.Items(r)
|
||||||
@@ -100,12 +114,18 @@ func (n AppNavigation) serve(w http.ResponseWriter, r *http.Request) {
|
|||||||
Selected: selected,
|
Selected: selected,
|
||||||
Address: n.address(r, selected),
|
Address: n.address(r, selected),
|
||||||
Header: template.HTML(header),
|
Header: template.HTML(header),
|
||||||
Items: viewItems(r, items, selected, n.address),
|
Items: viewItems(r, items, selected, n.address, lang),
|
||||||
Page: template.HTML(page),
|
Page: template.HTML(page),
|
||||||
Missing: msg,
|
Missing: msg,
|
||||||
Title: title,
|
Title: title,
|
||||||
Theme: !n.DisableTheme,
|
Theme: !n.DisableTheme,
|
||||||
Dialog: DialogHTML(),
|
Dialog: n.Cred.DialogHTML(r),
|
||||||
|
Lang: lang,
|
||||||
|
OpenLabel: chromePhrase(lang, "nav.open", nil),
|
||||||
|
CloseLabel: chromePhrase(lang, "nav.close", nil),
|
||||||
|
PhraseScript: n.Phrases.Script(lang),
|
||||||
|
PhraseFill: FillScript(),
|
||||||
|
MissingPhrase: n.MissingPhrase,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,6 +157,12 @@ type shellView struct {
|
|||||||
Title string
|
Title string
|
||||||
Theme bool
|
Theme bool
|
||||||
Dialog template.HTML
|
Dialog template.HTML
|
||||||
|
Lang string
|
||||||
|
OpenLabel string
|
||||||
|
CloseLabel string
|
||||||
|
PhraseScript template.HTML
|
||||||
|
PhraseFill template.HTML
|
||||||
|
MissingPhrase string
|
||||||
}
|
}
|
||||||
|
|
||||||
type itemView struct {
|
type itemView struct {
|
||||||
@@ -147,12 +173,21 @@ type itemView struct {
|
|||||||
Current bool
|
Current bool
|
||||||
HasChildren bool
|
HasChildren bool
|
||||||
Children []itemView
|
Children []itemView
|
||||||
|
Fold string
|
||||||
|
FoldPattern string
|
||||||
|
Phrase string
|
||||||
}
|
}
|
||||||
|
|
||||||
func viewItems(r *http.Request, items []Item, selected string, href func(*http.Request, string) string) []itemView {
|
func viewItems(r *http.Request, items []Item, selected string, href func(*http.Request, string) string, lang string) []itemView {
|
||||||
out := make([]itemView, 0, len(items))
|
out := make([]itemView, 0, len(items))
|
||||||
for _, it := range items {
|
for _, it := range items {
|
||||||
children := viewItems(r, it.Children, selected, href)
|
children := viewItems(r, it.Children, selected, href, lang)
|
||||||
|
fold := ""
|
||||||
|
pattern := ""
|
||||||
|
if len(children) > 0 {
|
||||||
|
fold = chromePhrase(lang, "nav.fold", map[string]string{"label": it.Label})
|
||||||
|
pattern = chromePhrase(lang, "nav.fold", nil)
|
||||||
|
}
|
||||||
out = append(out, itemView{
|
out = append(out, itemView{
|
||||||
ID: it.ID,
|
ID: it.ID,
|
||||||
Label: it.Label,
|
Label: it.Label,
|
||||||
@@ -161,17 +196,21 @@ func viewItems(r *http.Request, items []Item, selected string, href func(*http.R
|
|||||||
Current: it.ID == selected,
|
Current: it.ID == selected,
|
||||||
HasChildren: len(children) > 0,
|
HasChildren: len(children) > 0,
|
||||||
Children: children,
|
Children: children,
|
||||||
|
Fold: fold,
|
||||||
|
FoldPattern: pattern,
|
||||||
|
Phrase: it.Phrase,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
var shellTmpl = template.Must(template.New("shell").Parse(`<!DOCTYPE html>
|
var shellTmpl = template.Must(template.New("shell").Parse(`<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="{{.Lang}}">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>{{.Title}}</title>
|
<title>{{.Title}}</title>
|
||||||
|
{{.PhraseScript}}
|
||||||
{{if .Theme}}
|
{{if .Theme}}
|
||||||
<link rel="stylesheet" href="/apps/theming/css/default.css">
|
<link rel="stylesheet" href="/apps/theming/css/default.css">
|
||||||
<link rel="stylesheet" href="/index.php/apps/theming/theme/default.css">
|
<link rel="stylesheet" href="/index.php/apps/theming/theme/default.css">
|
||||||
@@ -355,7 +394,7 @@ var shellTmpl = template.Must(template.New("shell").Parse(`<!DOCTYPE html>
|
|||||||
{{.Dialog}}
|
{{.Dialog}}
|
||||||
<div id="app-nav">
|
<div id="app-nav">
|
||||||
<div id="nav-header">
|
<div id="nav-header">
|
||||||
<button type="button" id="nav-toggle" aria-controls="app-navigation" aria-expanded="false" aria-label="Open navigation"><svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M3 6h18v2H3V6m0 5h18v2H3v-2m0 5h18v2H3v-2z"/></svg></button>
|
<button type="button" id="nav-toggle" aria-controls="app-navigation" aria-expanded="false" aria-label="{{.OpenLabel}}"><svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M3 6h18v2H3V6m0 5h18v2H3v-2m0 5h18v2H3v-2z"/></svg></button>
|
||||||
<div id="nav-header-text">{{.Header}}</div>
|
<div id="nav-header-text">{{.Header}}</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="nav-backdrop"></div>
|
<div id="nav-backdrop"></div>
|
||||||
@@ -364,7 +403,7 @@ var shellTmpl = template.Must(template.New("shell").Parse(`<!DOCTYPE html>
|
|||||||
<ul class="app-navigation-list">{{template "items" .Items}}</ul>
|
<ul class="app-navigation-list">{{template "items" .Items}}</ul>
|
||||||
</nav>
|
</nav>
|
||||||
<main>
|
<main>
|
||||||
{{if .Missing}}<p id="nav-missing">{{.Missing}}</p>{{end}}
|
{{if .Missing}}<p id="nav-missing"{{if .MissingPhrase}} data-phrase="{{.MissingPhrase}}"{{end}}>{{.Missing}}</p>{{end}}
|
||||||
{{.Page}}
|
{{.Page}}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
@@ -396,7 +435,7 @@ var shellTmpl = template.Must(template.New("shell").Parse(`<!DOCTYPE html>
|
|||||||
document.body.classList.toggle("nav-open", open);
|
document.body.classList.toggle("nav-open", open);
|
||||||
if (!toggle) return;
|
if (!toggle) return;
|
||||||
toggle.setAttribute("aria-expanded", open ? "true" : "false");
|
toggle.setAttribute("aria-expanded", open ? "true" : "false");
|
||||||
toggle.setAttribute("aria-label", open ? "Close navigation" : "Open navigation");
|
toggle.setAttribute("aria-label", open ? {{.CloseLabel}} : {{.OpenLabel}});
|
||||||
}
|
}
|
||||||
placeNav();
|
placeNav();
|
||||||
window.addEventListener("resize", placeNav);
|
window.addEventListener("resize", placeNav);
|
||||||
@@ -416,8 +455,9 @@ var shellTmpl = template.Must(template.New("shell").Parse(`<!DOCTYPE html>
|
|||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
{{.PhraseFill}}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
{{define "items"}}{{range .}}<li class="app-navigation-entry-wrapper{{if .HasChildren}} app-navigation-entry--collapsible app-navigation-entry--opened{{end}}" data-id="{{.ID}}"{{if .HasChildren}} data-expanded="true"{{end}}><div class="app-navigation-entry{{if .Current}} active{{end}}"><a class="app-navigation-entry-link" href="{{.Href}}"{{if .Current}} aria-current="page"{{end}}>{{if .Icon}}<img class="app-navigation-entry-icon" src="{{.Icon}}" alt="">{{end}}<span class="app-navigation-entry__name">{{.Label}}</span></a>{{if .HasChildren}}<div class="app-navigation-entry__utils"><button type="button" class="fold" aria-expanded="true" aria-label="Fold {{.Label}}"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4.5 6.5 8 10l3.5-3.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg></button></div>{{end}}</div>{{if .HasChildren}}<ul class="app-navigation-entry__children">{{template "items" .Children}}</ul>{{end}}</li>{{end}}{{end}}
|
{{define "items"}}{{range .}}<li class="app-navigation-entry-wrapper{{if .HasChildren}} app-navigation-entry--collapsible app-navigation-entry--opened{{end}}" data-id="{{.ID}}"{{if .HasChildren}} data-expanded="true"{{end}}><div class="app-navigation-entry{{if .Current}} active{{end}}"><a class="app-navigation-entry-link" href="{{.Href}}"{{if .Current}} aria-current="page"{{end}}>{{if .Icon}}<img class="app-navigation-entry-icon" src="{{.Icon}}" alt="">{{end}}<span class="app-navigation-entry__name"{{if .Phrase}} data-phrase="{{.Phrase}}"{{end}}>{{.Label}}</span></a>{{if .HasChildren}}<div class="app-navigation-entry__utils"><button type="button" class="fold" aria-expanded="true" aria-label="{{.Fold}}" data-fold="{{.FoldPattern}}"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4.5 6.5 8 10l3.5-3.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg></button></div>{{end}}</div>{{if .HasChildren}}<ul class="app-navigation-entry__children">{{template "items" .Children}}</ul>{{end}}</li>{{end}}{{end}}
|
||||||
`))
|
`))
|
||||||
|
|||||||
@@ -10,9 +10,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// OCSClient performs AppAPI-authenticated OCS requests with format=json.
|
// OCSClient performs AppAPI-authenticated OCS requests with format=json.
|
||||||
|
// Header is copied onto the outbound request after the AppAPI auth headers.
|
||||||
|
// The UI language read uses it to forward Accept-Language.
|
||||||
type OCSClient struct {
|
type OCSClient struct {
|
||||||
Cred Credentials
|
Cred Credentials
|
||||||
Client *http.Client
|
Client *http.Client
|
||||||
|
Header http.Header
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c OCSClient) httpClient() *http.Client {
|
func (c OCSClient) httpClient() *http.Client {
|
||||||
@@ -48,6 +51,11 @@ func (c OCSClient) Call(method, path string, body []byte) ([]byte, error) {
|
|||||||
req.Header.Set(k, v)
|
req.Header.Set(k, v)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for k, vs := range c.Header {
|
||||||
|
for _, v := range vs {
|
||||||
|
req.Header.Add(k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
res, err := c.httpClient().Do(req)
|
res, err := c.httpClient().Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
package gonexapp_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"html"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/chromedp/cdproto/network"
|
||||||
|
"github.com/chromedp/chromedp"
|
||||||
|
|
||||||
|
"gitea.neitzel.de/konrad/go-nc-exapp"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExAppPhrasesOnThePage(t *testing.T) {
|
||||||
|
phrases := mergedPhrases(t)
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
ncLang string
|
||||||
|
want phraseSeen
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "german",
|
||||||
|
ncLang: "de",
|
||||||
|
want: phraseSeen{
|
||||||
|
lang: "de", heading: "Spätere Überschrift", placeholder: "Platzhalter",
|
||||||
|
title: "Titel", aria: "Beschriftung", hole: "example.com fehlt",
|
||||||
|
openHole: "{zone} fehlt", fallback: "English only", unknown: "not.a.phrase",
|
||||||
|
htmlHole: "<b>x</b> fehlt", scripted: "Skript", chromeID: "dialog.cancel",
|
||||||
|
err: "<b>x</b> fehlt", nav: "Domänen", fold: "Domänen einklappen",
|
||||||
|
missing: "Dieser Eintrag fehlt",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "english",
|
||||||
|
ncLang: "en",
|
||||||
|
want: phraseSeen{
|
||||||
|
lang: "en", heading: "Later heading", placeholder: "Placeholder",
|
||||||
|
title: "Title", aria: "Aria", hole: "Zone example.com is missing",
|
||||||
|
openHole: "Zone {zone} is missing", fallback: "English only", unknown: "not.a.phrase",
|
||||||
|
htmlHole: "Zone <b>x</b> is missing", scripted: "Scripted", chromeID: "dialog.cancel",
|
||||||
|
err: "Missing <b>x</b>", nav: "Domains", fold: "Fold Domains",
|
||||||
|
missing: "that item is not in this Visit",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
nc := fakeLanguage(http.StatusOK, tt.ncLang)
|
||||||
|
t.Cleanup(nc.Close)
|
||||||
|
page := phrasePage(nc.URL, phrases)
|
||||||
|
t.Cleanup(page.Close)
|
||||||
|
got := readPhrases(t, page.URL+"/?item=missing", network.Headers{
|
||||||
|
"Accept-Language": browserLanguage,
|
||||||
|
"AUTHORIZATION-APP-API": base64.StdEncoding.EncodeToString([]byte("alice:secret")),
|
||||||
|
})
|
||||||
|
if got != tt.want {
|
||||||
|
t.Fatalf("got %+v, want %+v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type phraseSeen struct {
|
||||||
|
lang string
|
||||||
|
heading string
|
||||||
|
placeholder string
|
||||||
|
title string
|
||||||
|
aria string
|
||||||
|
hole string
|
||||||
|
openHole string
|
||||||
|
fallback string
|
||||||
|
unknown string
|
||||||
|
htmlHole string
|
||||||
|
scripted string
|
||||||
|
chromeID string
|
||||||
|
err string
|
||||||
|
nav string
|
||||||
|
fold string
|
||||||
|
missing string
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergedPhrases(t *testing.T) gonexapp.Phrases {
|
||||||
|
t.Helper()
|
||||||
|
base, err := gonexapp.PhrasesFromFS(fstest.MapFS{
|
||||||
|
"i18n/en.json": {Data: []byte(phraseEN)},
|
||||||
|
"i18n/de.json": {Data: []byte(phraseDE)},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mod, err := gonexapp.PhrasesFromFS(fstest.MapFS{
|
||||||
|
"i18n/en.json": {Data: []byte(`{"page.heading":"Later heading"}`)},
|
||||||
|
"i18n/de.json": {Data: []byte(`{"page.heading":"Spätere Überschrift"}`)},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return gonexapp.Merge(base, mod)
|
||||||
|
}
|
||||||
|
|
||||||
|
const phraseEN = `{
|
||||||
|
"page.heading": "Heading",
|
||||||
|
"page.placeholder": "Placeholder",
|
||||||
|
"page.title": "Title",
|
||||||
|
"page.aria": "Aria",
|
||||||
|
"page.hole": "Zone {zone} is missing",
|
||||||
|
"page.english-only": "English only",
|
||||||
|
"page.scripted": "Scripted",
|
||||||
|
"page.error": "Missing {name}",
|
||||||
|
"nav.domains": "Domains",
|
||||||
|
"nav.missing": "that item is not in this Visit"
|
||||||
|
}`
|
||||||
|
|
||||||
|
const phraseDE = `{
|
||||||
|
"page.placeholder": "Platzhalter",
|
||||||
|
"page.title": "Titel",
|
||||||
|
"page.aria": "Beschriftung",
|
||||||
|
"page.hole": "{zone} fehlt",
|
||||||
|
"page.scripted": "Skript",
|
||||||
|
"page.error": "{name} fehlt",
|
||||||
|
"nav.domains": "Domänen",
|
||||||
|
"nav.missing": "Dieser Eintrag fehlt"
|
||||||
|
}`
|
||||||
|
|
||||||
|
func phrasePage(ncBase string, phrases gonexapp.Phrases) *httptest.Server {
|
||||||
|
nav := sampleNavigation()
|
||||||
|
nav.Cred = gonexapp.Credentials{
|
||||||
|
BaseURL: ncBase, AppID: "exapp", AppVersion: "1.0.0", AAVersion: "1.0.0", AppSecret: "secret",
|
||||||
|
}
|
||||||
|
nav.Phrases = phrases
|
||||||
|
nav.MissingMessage = "that item is not in this Visit"
|
||||||
|
nav.MissingPhrase = "nav.missing"
|
||||||
|
nav.Items = func(*http.Request) []gonexapp.Item {
|
||||||
|
return []gonexapp.Item{{
|
||||||
|
ID: "domains", Label: "Domains", Phrase: "nav.domains",
|
||||||
|
Children: []gonexapp.Item{{ID: "example.com", Label: "example.com"}},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
nav.Page = func(r *http.Request, id string) (string, bool) {
|
||||||
|
if id != "domains" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
msg := phrases.Sentence(nav.Cred.UILanguage(r), "page.error", map[string]string{"name": "<b>x</b>"})
|
||||||
|
return `<h1 id="heading" data-phrase="page.heading">Heading</h1>
|
||||||
|
<input id="field" data-phrase-placeholder="page.placeholder" placeholder="Placeholder" data-phrase-title="page.title" title="Title" data-phrase-aria-label="page.aria" aria-label="Aria">
|
||||||
|
<p id="hole" data-phrase="page.hole" data-phrase-holes='{"zone":"example.com"}'>Zone {zone} is missing</p>
|
||||||
|
<p id="open-hole" data-phrase="page.hole">Zone {zone} is missing</p>
|
||||||
|
<p id="fallback" data-phrase="page.english-only">English only</p>
|
||||||
|
<p id="unknown" data-phrase="not.a.phrase">English source</p>
|
||||||
|
<p id="htmlhole" data-phrase="page.hole" data-phrase-holes='{"zone":"<b>x</b>"}'>Zone {zone} is missing</p>
|
||||||
|
<p id="scripted"></p>
|
||||||
|
<p id="chrome-id"></p>
|
||||||
|
<p id="err">` + html.EscapeString(msg) + `</p>
|
||||||
|
<script>
|
||||||
|
document.getElementById("scripted").textContent = exappPhrase("page.scripted");
|
||||||
|
document.getElementById("chrome-id").textContent = exappPhrase("dialog.cancel");
|
||||||
|
</script>`, true
|
||||||
|
}
|
||||||
|
return httptest.NewServer(nav.Handler())
|
||||||
|
}
|
||||||
|
|
||||||
|
func readPhrases(t *testing.T, pageURL string, headers network.Headers) phraseSeen {
|
||||||
|
t.Helper()
|
||||||
|
ctx, cancel := chromedp.NewContext(allocCtx)
|
||||||
|
t.Cleanup(cancel)
|
||||||
|
ctx, cancel = context.WithTimeout(ctx, 25*time.Second)
|
||||||
|
t.Cleanup(cancel)
|
||||||
|
var raw string
|
||||||
|
if err := chromedp.Run(ctx,
|
||||||
|
network.Enable(),
|
||||||
|
network.SetExtraHTTPHeaders(headers),
|
||||||
|
chromedp.Navigate(pageURL),
|
||||||
|
chromedp.Evaluate(phraseProbeJS, &raw),
|
||||||
|
); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var probe struct {
|
||||||
|
Lang string `json:"lang"`
|
||||||
|
Heading string `json:"heading"`
|
||||||
|
Placeholder string `json:"placeholder"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Aria string `json:"aria"`
|
||||||
|
Hole string `json:"hole"`
|
||||||
|
OpenHole string `json:"openHole"`
|
||||||
|
Fallback string `json:"fallback"`
|
||||||
|
Unknown string `json:"unknown"`
|
||||||
|
HTMLHole string `json:"htmlHole"`
|
||||||
|
HTMLHasB bool `json:"htmlHasB"`
|
||||||
|
Scripted string `json:"scripted"`
|
||||||
|
ChromeID string `json:"chromeId"`
|
||||||
|
Err string `json:"err"`
|
||||||
|
ErrHasB bool `json:"errHasB"`
|
||||||
|
Nav string `json:"nav"`
|
||||||
|
Fold string `json:"fold"`
|
||||||
|
Missing string `json:"missing"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(raw), &probe); err != nil {
|
||||||
|
t.Fatalf("probe %s: %v", raw, err)
|
||||||
|
}
|
||||||
|
if probe.HTMLHasB || probe.ErrHasB {
|
||||||
|
t.Fatalf("hole value was parsed as HTML: %+v", probe)
|
||||||
|
}
|
||||||
|
return phraseSeen{
|
||||||
|
lang: probe.Lang, heading: probe.Heading, placeholder: probe.Placeholder,
|
||||||
|
title: probe.Title, aria: probe.Aria, hole: probe.Hole, openHole: probe.OpenHole,
|
||||||
|
fallback: probe.Fallback, unknown: probe.Unknown, htmlHole: probe.HTMLHole,
|
||||||
|
scripted: probe.Scripted, chromeID: probe.ChromeID, err: probe.Err,
|
||||||
|
nav: probe.Nav, fold: probe.Fold, missing: probe.Missing,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const phraseProbeJS = `(() => {
|
||||||
|
const field = document.getElementById("field");
|
||||||
|
const htmlHole = document.getElementById("htmlhole");
|
||||||
|
const err = document.getElementById("err");
|
||||||
|
const name = document.querySelector('[data-id="domains"] > .app-navigation-entry .app-navigation-entry__name');
|
||||||
|
const fold = document.querySelector('[data-id="domains"] button.fold');
|
||||||
|
const missing = document.getElementById("nav-missing");
|
||||||
|
return JSON.stringify({
|
||||||
|
lang: document.documentElement.lang,
|
||||||
|
heading: document.getElementById("heading").textContent,
|
||||||
|
placeholder: field.placeholder,
|
||||||
|
title: field.title,
|
||||||
|
aria: field.getAttribute("aria-label"),
|
||||||
|
hole: document.getElementById("hole").textContent,
|
||||||
|
openHole: document.getElementById("open-hole").textContent,
|
||||||
|
fallback: document.getElementById("fallback").textContent,
|
||||||
|
unknown: document.getElementById("unknown").textContent,
|
||||||
|
htmlHole: htmlHole.textContent,
|
||||||
|
htmlHasB: !!htmlHole.querySelector("b"),
|
||||||
|
scripted: document.getElementById("scripted").textContent,
|
||||||
|
chromeId: document.getElementById("chrome-id").textContent,
|
||||||
|
err: err.textContent,
|
||||||
|
errHasB: !!err.querySelector("b"),
|
||||||
|
nav: name ? name.textContent : "",
|
||||||
|
fold: fold ? fold.getAttribute("aria-label") : "",
|
||||||
|
missing: missing ? missing.textContent : ""
|
||||||
|
});
|
||||||
|
})()`
|
||||||
+212
@@ -0,0 +1,212 @@
|
|||||||
|
package gonexapp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"io/fs"
|
||||||
|
"maps"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Phrases is one ExApp's merged Phrase set.
|
||||||
|
// English is the complete set. A module set is merged with Merge;
|
||||||
|
// a repeated id uses the set added later.
|
||||||
|
// The page script receives this set. Library chrome is not included.
|
||||||
|
type Phrases struct {
|
||||||
|
en map[string]string
|
||||||
|
de map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
// PhrasesFromFS reads i18n/en.json and i18n/de.json.
|
||||||
|
// Each file is one JSON object of id → text.
|
||||||
|
func PhrasesFromFS(fsys fs.FS) (Phrases, error) {
|
||||||
|
en, err := fs.ReadFile(fsys, "i18n/en.json")
|
||||||
|
if err != nil {
|
||||||
|
return Phrases{}, fmt.Errorf("read i18n/en.json: %w", err)
|
||||||
|
}
|
||||||
|
de, err := fs.ReadFile(fsys, "i18n/de.json")
|
||||||
|
if err != nil {
|
||||||
|
return Phrases{}, fmt.Errorf("read i18n/de.json: %w", err)
|
||||||
|
}
|
||||||
|
return ParsePhrases(en, de)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsePhrases reads an English object and a German object.
|
||||||
|
func ParsePhrases(en, de []byte) (Phrases, error) {
|
||||||
|
enMap, err := decodePhraseFile(en)
|
||||||
|
if err != nil {
|
||||||
|
return Phrases{}, fmt.Errorf("en: %w", err)
|
||||||
|
}
|
||||||
|
deMap, err := decodePhraseFile(de)
|
||||||
|
if err != nil {
|
||||||
|
return Phrases{}, fmt.Errorf("de: %w", err)
|
||||||
|
}
|
||||||
|
return Phrases{en: enMap, de: deMap}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodePhraseFile(raw []byte) (map[string]string, error) {
|
||||||
|
if len(strings.TrimSpace(string(raw))) == 0 {
|
||||||
|
return map[string]string{}, nil
|
||||||
|
}
|
||||||
|
var m map[string]string
|
||||||
|
if err := json.Unmarshal(raw, &m); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if m == nil {
|
||||||
|
m = map[string]string{}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge returns the sets combined in order.
|
||||||
|
// A repeated id uses the text from the set added later.
|
||||||
|
func Merge(sets ...Phrases) Phrases {
|
||||||
|
out := Phrases{en: map[string]string{}, de: map[string]string{}}
|
||||||
|
for _, set := range sets {
|
||||||
|
maps.Copy(out.en, set.en)
|
||||||
|
maps.Copy(out.de, set.de)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p Phrases) has(code string) bool {
|
||||||
|
switch code {
|
||||||
|
case "en":
|
||||||
|
return p.en != nil
|
||||||
|
case "de":
|
||||||
|
return p.de != nil
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p Phrases) texts(lang string) map[string]string {
|
||||||
|
if lang == "de" {
|
||||||
|
return p.de
|
||||||
|
}
|
||||||
|
return p.en
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sentence is the finished text for lang.
|
||||||
|
// A missing text for that language uses the English text.
|
||||||
|
// An id missing from English is the id.
|
||||||
|
// A hole with no value stays visible as {name}.
|
||||||
|
// The result is plain text; the caller escapes it when writing HTML.
|
||||||
|
func (p Phrases) Sentence(lang, id string, holes map[string]string) string {
|
||||||
|
lang = matchLanguage(lang, p.has)
|
||||||
|
if text, ok := phraseText(p.texts(lang), id); ok {
|
||||||
|
return fillHoles(text, holes)
|
||||||
|
}
|
||||||
|
if lang != "en" {
|
||||||
|
if text, ok := phraseText(p.en, id); ok {
|
||||||
|
return fillHoles(text, holes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func phraseText(m map[string]string, id string) (string, bool) {
|
||||||
|
text, ok := m[id]
|
||||||
|
if !ok || text == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return text, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolved is the map the page script sees: one text per id for lang,
|
||||||
|
// with English filling gaps. Ids missing from English are omitted.
|
||||||
|
func (p Phrases) resolved(lang string) map[string]string {
|
||||||
|
lang = matchLanguage(lang, p.has)
|
||||||
|
out := map[string]string{}
|
||||||
|
for id, text := range p.en {
|
||||||
|
if text == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[id] = text
|
||||||
|
}
|
||||||
|
if lang == "en" {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
for id, text := range p.de {
|
||||||
|
if text == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := phraseText(p.en, id); !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[id] = text
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Script defines exappPhrase for the page, from this set only.
|
||||||
|
func (p Phrases) Script(lang string) template.HTML {
|
||||||
|
raw, err := json.Marshal(p.resolved(lang))
|
||||||
|
if err != nil {
|
||||||
|
raw = []byte("{}")
|
||||||
|
}
|
||||||
|
return template.HTML("<script>\n" + phraseRuntimeJS + "\nwindow.exappPhrase = exappPhraseMap(" + string(raw) + ");\n</script>")
|
||||||
|
}
|
||||||
|
|
||||||
|
const phraseRuntimeJS = `function exappFill(text, holes) {
|
||||||
|
if (!holes || text.indexOf("{") < 0) return text;
|
||||||
|
var out = "";
|
||||||
|
for (var i = 0; i < text.length; i++) {
|
||||||
|
if (text.charAt(i) !== "{") { out += text.charAt(i); continue; }
|
||||||
|
var end = text.indexOf("}", i + 1);
|
||||||
|
if (end < 0) { out += text.charAt(i); continue; }
|
||||||
|
var name = text.slice(i + 1, end);
|
||||||
|
if (Object.prototype.hasOwnProperty.call(holes, name)) {
|
||||||
|
out += String(holes[name]);
|
||||||
|
i = end;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out += text.charAt(i);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function exappPhraseMap(map) {
|
||||||
|
return function (id, holes) {
|
||||||
|
var text = Object.prototype.hasOwnProperty.call(map, id) ? map[id] : String(id);
|
||||||
|
return exappFill(text, holes);
|
||||||
|
};
|
||||||
|
}`
|
||||||
|
|
||||||
|
// FillScript replaces English source after load and refreshes Fold from the
|
||||||
|
// visible item name. It does not parse Phrase text as HTML.
|
||||||
|
// App navigation includes it. A page the ExApp renders itself places Script
|
||||||
|
// in the head and FillScript at the end of the body.
|
||||||
|
func FillScript() template.HTML {
|
||||||
|
return phraseFillScript
|
||||||
|
}
|
||||||
|
|
||||||
|
// phraseFillScript replaces English source after load and refreshes Fold
|
||||||
|
// from the visible item name. It does not parse Phrase text as HTML.
|
||||||
|
const phraseFillScript = template.HTML(`<script>
|
||||||
|
(function () {
|
||||||
|
function holesOf(el) {
|
||||||
|
var raw = el.getAttribute("data-phrase-holes");
|
||||||
|
if (!raw) return null;
|
||||||
|
try { return JSON.parse(raw); } catch (e) { return null; }
|
||||||
|
}
|
||||||
|
function apply(el, attr, set) {
|
||||||
|
var id = el.getAttribute(attr);
|
||||||
|
if (!id || typeof exappPhrase !== "function") return;
|
||||||
|
set(exappPhrase(id, holesOf(el)));
|
||||||
|
}
|
||||||
|
document.querySelectorAll("[data-phrase],[data-phrase-placeholder],[data-phrase-title],[data-phrase-aria-label]").forEach(function (el) {
|
||||||
|
apply(el, "data-phrase", function (t) { el.textContent = t; });
|
||||||
|
apply(el, "data-phrase-placeholder", function (t) { el.placeholder = t; });
|
||||||
|
apply(el, "data-phrase-title", function (t) { el.title = t; });
|
||||||
|
apply(el, "data-phrase-aria-label", function (t) { el.setAttribute("aria-label", t); });
|
||||||
|
});
|
||||||
|
document.querySelectorAll("button.fold").forEach(function (btn) {
|
||||||
|
var pattern = btn.getAttribute("data-fold");
|
||||||
|
var entry = btn.closest(".app-navigation-entry");
|
||||||
|
var name = entry ? entry.querySelector(".app-navigation-entry__name") : null;
|
||||||
|
if (!pattern || !name || typeof exappFill !== "function") return;
|
||||||
|
btn.setAttribute("aria-label", exappFill(pattern, { label: name.textContent }));
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>`)
|
||||||
Reference in New Issue
Block a user