import-renpho, v2 readings API, dashboard lab chips, portal updates

- Add import-renpho: Go binary to sync Renpho body composition data
  into inou vitals via direct EntryWrite (AES-128/ECB Renpho API client,
  13 body metrics, dedup, auto RBAC grants, -setup/-discover modes)
- Add POST /api/v2/readings endpoint for batch vital ingest
- Fix dashboard lab chip: filter by lab_order not lab_report
- Portal: upload handler, dossier page rework, dashboard updates
- Remove tools/fix-lang (replaced by toolkit translate)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
James 2026-02-28 04:54:58 -05:00
parent 033d558535
commit ee40b3a81b
28 changed files with 1385 additions and 185 deletions

View File

@ -49,6 +49,11 @@ import-dicom: $(BINDIR)/import-dicom
$(BINDIR)/import-dicom: ./import-dicom/*.go ./lib/*.go | $(BINDIR) $(BINDIR)/import-dicom: ./import-dicom/*.go ./lib/*.go | $(BINDIR)
$(FIPS) go build $(LDFLAGS) -o $@ ./import-dicom $(FIPS) go build $(LDFLAGS) -o $@ ./import-dicom
# Renpho import tool
import-renpho: $(BINDIR)/import-renpho
$(BINDIR)/import-renpho: ./import-renpho/*.go ./lib/*.go | $(BINDIR)
$(FIPS) go build $(LDFLAGS) -o $@ ./import-renpho
# Nuke imaging tool # Nuke imaging tool
nuke-imaging: $(BINDIR)/nuke-imaging nuke-imaging: $(BINDIR)/nuke-imaging
$(BINDIR)/nuke-imaging: ./nuke-imaging/*.go ./lib/*.go | $(BINDIR) $(BINDIR)/nuke-imaging: ./nuke-imaging/*.go ./lib/*.go | $(BINDIR)

View File

@ -20,6 +20,7 @@ Before Apple/Google app review, the privacy policy needs these additions:
## Portal UI ## Portal UI
- **Drag & drop upload on dossier page** — Immich-style: whole page is a drop target. Drop a PDF anywhere → small floating progress widget in bottom-right corner shows upload + processing status (OCR → extracting → done). Dismiss when done, new data appears on page. No page navigation needed. Upload page remains for DICOM folders, genome files, batch uploads.
- **Language picker on login/landing page** — Currently cannot change language when not logged in. Need language picker on landing page to allow users to select language before registration. - **Language picker on login/landing page** — Currently cannot change language when not logged in. Need language picker on landing page to allow users to select language before registration.
- **Restyle upload health data page** — Visual refresh needed, uses old container class - **Restyle upload health data page** — Visual refresh needed, uses old container class
- **Improve genome import speed** — Currently slow due to tier creation and encryption overhead - **Improve genome import speed** — Currently slow due to tier creation and encryption overhead

202
api/api_v2_readings.go Normal file
View File

@ -0,0 +1,202 @@
package main
import (
"crypto/sha256"
"encoding/json"
"fmt"
"net/http"
"strings"
"inou/lib"
)
type readingsRequest struct {
DossierID string `json:"dossier_id"`
Category string `json:"category"`
Readings []struct {
Source string `json:"source"`
Metric string `json:"metric"`
Value float64 `json:"value"`
Unit string `json:"unit"`
Timestamp int64 `json:"timestamp"`
} `json:"readings"`
}
// POST /api/v2/readings
func v2Readings(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
v1Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
authID, ok := v1AuthRequired(w, r)
if !ok {
return
}
var req readingsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
v1Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.DossierID == "" {
v1Error(w, "dossier_id required", http.StatusBadRequest)
return
}
if req.Category == "" {
v1Error(w, "category required", http.StatusBadRequest)
return
}
catInt, catOK := lib.CategoryFromString[req.Category]
if !catOK {
v1Error(w, "unknown category: "+req.Category, http.StatusBadRequest)
return
}
if len(req.Readings) == 0 {
v1JSON(w, map[string]any{"created": 0, "skipped": 0, "errors": []string{}})
return
}
// Fail fast: check write access before doing any work
if !lib.CheckAccess(authID, req.DossierID, "", lib.PermWrite) {
v1Error(w, "Access denied: no write permission for dossier "+req.DossierID, http.StatusForbidden)
return
}
// Find or create category root (depth 1)
rootID, err := ensureRoot(authID, req.DossierID, catInt)
if err != nil {
v1Error(w, err.Error(), http.StatusInternalServerError)
return
}
created, skipped := 0, 0
var errors []string
groups := map[string]string{} // metric → entryID
for _, rd := range req.Readings {
if rd.Metric == "" {
errors = append(errors, "reading missing metric")
continue
}
if rd.Timestamp == 0 {
errors = append(errors, rd.Metric+": missing timestamp")
continue
}
// Find or create group container (depth 2)
groupID, exists := groups[rd.Metric]
if !exists {
groupID, err = ensureGroup(authID, req.DossierID, catInt, rootID, rd.Metric, rd.Unit, rd.Source)
if err != nil {
errors = append(errors, fmt.Sprintf("%s: %v", rd.Metric, err))
continue
}
groups[rd.Metric] = groupID
}
// Dedup: same parent + same timestamp + type=reading → skip
existing, _ := lib.EntryRead(authID, req.DossierID, &lib.Filter{
Category: catInt,
Type: "reading",
ParentID: groupID,
FromDate: rd.Timestamp,
ToDate: rd.Timestamp + 1,
})
if len(existing) > 0 {
skipped++
continue
}
// Create reading (depth 3)
valueStr := fmt.Sprintf("%g", rd.Value)
summary := valueStr
if rd.Unit != "" {
summary += " " + rd.Unit
}
data, _ := json.Marshal(map[string]string{"unit": rd.Unit, "source": rd.Source})
reading := &lib.Entry{
DossierID: req.DossierID,
ParentID: groupID,
Category: catInt,
Type: "reading",
Value: valueStr,
Summary: summary,
Timestamp: rd.Timestamp,
Data: string(data),
}
if err := lib.EntryWrite(authID, reading); err != nil {
errors = append(errors, fmt.Sprintf("%s@%d: %v", rd.Metric, rd.Timestamp, err))
continue
}
created++
}
v1JSON(w, map[string]any{"created": created, "skipped": skipped, "errors": errors})
}
// ensureRoot finds or creates the category root entry (depth 1).
func ensureRoot(authID, dossierID string, cat int) (string, error) {
entries, err := lib.EntryRead(authID, dossierID, &lib.Filter{
Category: cat,
Type: "root",
ParentID: dossierID,
})
if err != nil {
return "", err
}
if len(entries) > 0 {
return entries[0].EntryID, nil
}
id := deterministicID(dossierID, "cat", fmt.Sprintf("%d", cat))
err = lib.EntryWrite(authID, &lib.Entry{
EntryID: id,
DossierID: dossierID,
ParentID: dossierID,
Category: cat,
Type: "root",
})
return id, err
}
// ensureGroup finds or creates a group container (depth 2).
func ensureGroup(authID, dossierID string, cat int, rootID, metric, unit, source string) (string, error) {
entries, err := lib.EntryRead(authID, dossierID, &lib.Filter{
Category: cat,
Type: metric,
ParentID: rootID,
})
if err != nil {
return "", err
}
if len(entries) > 0 {
return entries[0].EntryID, nil
}
id := deterministicID(dossierID, "group", fmt.Sprintf("%d", cat), metric)
data, _ := json.Marshal(map[string]string{"unit": unit, "source": source})
err = lib.EntryWrite(authID, &lib.Entry{
EntryID: id,
DossierID: dossierID,
ParentID: rootID,
Category: cat,
Type: metric,
Summary: metricLabel(metric),
Data: string(data),
})
return id, err
}
func deterministicID(parts ...string) string {
h := sha256.Sum256([]byte(strings.Join(parts, ":")))
return fmt.Sprintf("%016x", h[:8])
}
func metricLabel(s string) string {
words := strings.Split(strings.ReplaceAll(s, "_", " "), " ")
for i, w := range words {
if len(w) > 0 {
words[i] = strings.ToUpper(w[:1]) + w[1:]
}
}
return strings.Join(words, " ")
}

View File

@ -65,6 +65,9 @@ func main() {
// V1 API (new design) // V1 API (new design)
RegisterV1Routes() RegisterV1Routes()
// V2 API
http.HandleFunc("/api/v2/readings", v2Readings)
loadLLMConfig() // RENAMED from loadAnthropicConfig() loadLLMConfig() // RENAMED from loadAnthropicConfig()
// Load translations for CategoryTranslate // Load translations for CategoryTranslate

612
import-renpho/main.go Normal file
View File

@ -0,0 +1,612 @@
package main
import (
"bytes"
"crypto/aes"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"inou/lib"
)
const (
baseURL = "https://cloud.renpho.com"
aesKey = "ed*wijdi$h6fe3ew"
)
// Renpho API response
type apiResponse struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data json.RawMessage `json:"data"`
}
// Login response
type loginUser struct {
UserID string `json:"id"`
Token string `json:"terminal_user_session_key"`
Email string `json:"email"`
}
// Table mapping
type tableMapping struct {
UserID string `json:"user_id"`
TableName string `json:"table_name"`
}
// Measurement from Renpho
type measurement struct {
TimeStamp int64 `json:"time_stamp"`
Weight float64 `json:"weight"`
BodyFat float64 `json:"bodyfat"`
Water float64 `json:"water"`
BMR float64 `json:"bmr"`
BodyAge float64 `json:"bodyage"`
Muscle float64 `json:"muscle"`
Bone float64 `json:"bone"`
SubFat float64 `json:"subfat"`
VisFat float64 `json:"visfat"`
BMI float64 `json:"bmi"`
Protein float64 `json:"protein"`
FatFree float64 `json:"fat_free_weight"`
Sinew float64 `json:"sinew"`
UserID string `json:"internal_model"`
}
// Account config stored in Renpho dossier's Data field
type renphoConfig struct {
Accounts []renphoAccount `json:"renpho_accounts"`
}
type renphoAccount struct {
Email string `json:"email"`
Password string `json:"password"`
DossierID string `json:"dossier_id"` // inou dossier to write vitals to
UserMap map[string]string `json:"user_map"` // renpho userID → inou dossierID (for multi-user scales)
}
// Runtime state for an authenticated session
type session struct {
token string
}
func main() {
setup := flag.Bool("setup", false, "Create Renpho system dossier and configure accounts")
discover := flag.Bool("discover", false, "Login and show Renpho user IDs for mapping")
flag.Parse()
if err := lib.Init(); err != nil {
fatal("lib.Init: %v", err)
}
if *setup {
runSetup()
return
}
if *discover {
runDiscover()
return
}
renphoID, cfg, err := loadConfig()
if err != nil {
fatal("load config: %v", err)
}
if len(cfg.Accounts) == 0 {
fmt.Println("No Renpho accounts configured. Run with -setup first.")
return
}
importID := lib.NextImportID()
for _, acct := range cfg.Accounts {
fmt.Printf("=== %s ===\n", acct.Email)
if err := syncAccount(renphoID, &acct, importID); err != nil {
fmt.Printf(" ERROR: %v\n", err)
}
}
}
// runSetup creates the Renpho system dossier with config from stdin
func runSetup() {
// Check if already exists
entries, err := lib.EntryRead("", "", &lib.Filter{Category: 0})
if err != nil {
fatal("list dossiers: %v", err)
}
for _, e := range entries {
if strings.EqualFold(e.SearchKey, "renpho") {
fmt.Printf("Renpho dossier already exists: %s\n", e.EntryID)
fmt.Println("Current config:")
fmt.Println(e.Data)
return
}
}
// Read config JSON from stdin
fmt.Println("Paste renpho_accounts JSON (or empty for blank), then Ctrl+D:")
data, _ := io.ReadAll(os.Stdin)
configJSON := strings.TrimSpace(string(data))
// Validate if non-empty
if configJSON != "" {
var cfg renphoConfig
if err := json.Unmarshal([]byte(configJSON), &cfg); err != nil {
fatal("invalid JSON: %v", err)
}
} else {
configJSON = `{"renpho_accounts":[]}`
}
id := lib.NewID()
err = lib.EntryWrite("", &lib.Entry{
EntryID: id,
DossierID: id,
Category: 0,
Type: "dossier",
Summary: "Renpho",
SearchKey: "renpho",
Data: configJSON,
Timestamp: time.Now().Unix(),
})
if err != nil {
fatal("create dossier: %v", err)
}
fmt.Printf("Created Renpho dossier: %s\n", id)
}
// runDiscover logs into Renpho and shows user IDs + table mappings
func runDiscover() {
if flag.NArg() < 2 {
fmt.Println("Usage: import-renpho -discover <email> <password>")
os.Exit(1)
}
email, password := flag.Arg(0), flag.Arg(1)
s, user, err := login(email, password)
if err != nil {
fatal("login: %v", err)
}
fmt.Printf("Logged in: %s (user ID: %s)\n", user.Email, user.UserID)
tables, err := getTableMappings(s)
if err != nil {
fatal("table mappings: %v", err)
}
fmt.Println("\nUser → Table mappings:")
for _, t := range tables {
fmt.Printf(" user_id: %s table: %s\n", t.UserID, t.TableName)
// Fetch a sample measurement to show what user this is
ms, err := fetchMeasurements(s, t.UserID, t.TableName)
if err == nil && len(ms) > 0 {
fmt.Printf(" %d measurements, latest weight: %.1f kg\n", len(ms), ms[0].Weight)
}
}
}
// loadConfig reads the Renpho system dossier and its config from Data field
func loadConfig() (string, *renphoConfig, error) {
// Find Renpho dossier by searching for email="renpho" in cat=0
entries, err := lib.EntryRead("", "", &lib.Filter{Category: 0})
if err != nil {
return "", nil, fmt.Errorf("list dossiers: %v", err)
}
for _, e := range entries {
if strings.EqualFold(e.SearchKey, "renpho") {
var cfg renphoConfig
if e.Data != "" {
if err := json.Unmarshal([]byte(e.Data), &cfg); err != nil {
return "", nil, fmt.Errorf("parse config: %v", err)
}
}
return e.EntryID, &cfg, nil
}
}
return "", nil, fmt.Errorf("no Renpho system dossier found (create a dossier with SearchKey='renpho')")
}
func syncAccount(renphoID string, acct *renphoAccount, importID int64) error {
// Login
s, user, err := login(acct.Email, acct.Password)
if err != nil {
return fmt.Errorf("login: %v", err)
}
fmt.Printf(" Logged in as %s (user %s)\n", user.Email, user.UserID)
// Get table mappings
tables, err := getTableMappings(s)
if err != nil {
return fmt.Errorf("table mappings: %v", err)
}
for _, t := range tables {
dossierID := acct.DossierID
if acct.UserMap != nil {
if mapped, ok := acct.UserMap[t.UserID]; ok {
dossierID = mapped
}
}
if dossierID == "" {
fmt.Printf(" Skipping user %s (no dossier mapped)\n", t.UserID)
continue
}
// Ensure Renpho has write access to this dossier
if !lib.CheckAccess(renphoID, dossierID, "", lib.PermWrite) {
fmt.Printf(" Granting Renpho access to %s\n", dossierID)
if err := lib.GrantAccess(dossierID, renphoID, dossierID, lib.PermRead|lib.PermWrite, 0); err != nil {
return fmt.Errorf("grant access to %s: %v", dossierID, err)
}
}
measurements, err := fetchMeasurements(s, t.UserID, t.TableName)
if err != nil {
fmt.Printf(" Table %s: %v\n", t.TableName, err)
continue
}
fmt.Printf(" Table %s: %d measurements for dossier %s\n", t.TableName, len(measurements), dossierID)
created, skipped, err := writeMeasurements(renphoID, dossierID, measurements, importID)
if err != nil {
fmt.Printf(" Write error: %v\n", err)
continue
}
fmt.Printf(" Created %d, skipped %d\n", created, skipped)
}
return nil
}
func login(email, password string) (*session, *loginUser, error) {
body := map[string]string{"email": email, "password": password}
resp, err := apiCall(nil, "RenphoHealth/centerUser/loginUser", body)
if err != nil {
return nil, nil, err
}
if resp.Code != 101 {
return nil, nil, fmt.Errorf("code %d: %s", resp.Code, resp.Msg)
}
var user loginUser
if err := json.Unmarshal(resp.Data, &user); err != nil {
return nil, nil, fmt.Errorf("parse login: %v", err)
}
return &session{token: user.Token}, &user, nil
}
func getTableMappings(s *session) ([]tableMapping, error) {
resp, err := apiCall(s, "RenphoHealth/scale/getUserIdMappingTableNames", map[string]string{})
if err != nil {
return nil, err
}
if resp.Code != 101 {
return nil, fmt.Errorf("code %d: %s", resp.Code, resp.Msg)
}
var tables []tableMapping
if err := json.Unmarshal(resp.Data, &tables); err != nil {
return nil, err
}
return tables, nil
}
func fetchMeasurements(s *session, userID, tableName string) ([]measurement, error) {
userIDs, _ := json.Marshal([]string{userID})
body := map[string]any{
"pageNum": 1,
"pageSize": "200",
"userIds": string(userIDs),
"tableName": tableName,
}
resp, err := apiCall(s, "RenphoHealth/scale/queryAllMeasureDataList/", body)
if err != nil {
return nil, err
}
if resp.Code != 101 {
return nil, fmt.Errorf("code %d: %s", resp.Code, resp.Msg)
}
var ms []measurement
if err := json.Unmarshal(resp.Data, &ms); err != nil {
return nil, err
}
return ms, nil
}
// writeMeasurements converts Renpho data to inou vital entries
func writeMeasurements(renphoID, dossierID string, ms []measurement, importID int64) (int, int, error) {
created, skipped := 0, 0
// Ensure category root
rootID, err := ensureRoot(renphoID, dossierID)
if err != nil {
return 0, 0, err
}
// Metric definitions: field name → (unit, label)
type metricDef struct {
unit string
label string
}
metrics := []struct {
name string
def metricDef
get func(m *measurement) float64
}{
{"weight", metricDef{"kg", "Weight"}, func(m *measurement) float64 { return m.Weight }},
{"body_fat", metricDef{"%", "Body Fat"}, func(m *measurement) float64 { return m.BodyFat }},
{"water", metricDef{"%", "Water"}, func(m *measurement) float64 { return m.Water }},
{"bmr", metricDef{"kcal", "BMR"}, func(m *measurement) float64 { return m.BMR }},
{"body_age", metricDef{"years", "Body Age"}, func(m *measurement) float64 { return m.BodyAge }},
{"muscle", metricDef{"%", "Muscle"}, func(m *measurement) float64 { return m.Muscle }},
{"bone", metricDef{"kg", "Bone Mass"}, func(m *measurement) float64 { return m.Bone }},
{"subcutaneous_fat", metricDef{"%", "Subcutaneous Fat"}, func(m *measurement) float64 { return m.SubFat }},
{"visceral_fat", metricDef{"", "Visceral Fat"}, func(m *measurement) float64 { return m.VisFat }},
{"bmi", metricDef{"", "BMI"}, func(m *measurement) float64 { return m.BMI }},
{"protein", metricDef{"%", "Protein"}, func(m *measurement) float64 { return m.Protein }},
{"fat_free_weight", metricDef{"kg", "Fat Free Weight"}, func(m *measurement) float64 { return m.FatFree }},
{"skeletal_muscle", metricDef{"%", "Skeletal Muscle"}, func(m *measurement) float64 { return m.Sinew }},
}
groups := map[string]string{} // metric name → groupID
for _, m := range ms {
ts := m.TimeStamp
if ts == 0 {
continue
}
// Renpho timestamps are in seconds
if ts > 1e12 {
ts = ts / 1000 // convert millis to seconds if needed
}
for _, met := range metrics {
val := met.get(&m)
if val == 0 {
continue
}
// Ensure group
groupID, ok := groups[met.name]
if !ok {
groupID, err = ensureGroup(renphoID, dossierID, rootID, met.name, met.def.unit, met.def.label)
if err != nil {
return created, skipped, fmt.Errorf("group %s: %v", met.name, err)
}
groups[met.name] = groupID
}
// Dedup: check for existing reading at same timestamp in same group
existing, _ := lib.EntryRead(renphoID, dossierID, &lib.Filter{
Category: lib.CategoryVital,
Type: "reading",
ParentID: groupID,
FromDate: ts,
ToDate: ts + 1,
})
if len(existing) > 0 {
skipped++
continue
}
valueStr := fmt.Sprintf("%g", val)
summary := valueStr
if met.def.unit != "" {
summary += " " + met.def.unit
}
data, _ := json.Marshal(map[string]string{"unit": met.def.unit, "source": "renpho"})
reading := &lib.Entry{
DossierID: dossierID,
ParentID: groupID,
Category: lib.CategoryVital,
Type: "reading",
Value: valueStr,
Summary: summary,
Timestamp: ts,
Data: string(data),
Import: importID,
}
if err := lib.EntryWrite(renphoID, reading); err != nil {
return created, skipped, fmt.Errorf("write %s@%d: %v", met.name, ts, err)
}
created++
}
}
return created, skipped, nil
}
func ensureRoot(accessorID, dossierID string) (string, error) {
entries, err := lib.EntryRead(accessorID, dossierID, &lib.Filter{
Category: lib.CategoryVital,
Type: "root",
ParentID: dossierID,
})
if err != nil {
return "", err
}
if len(entries) > 0 {
return entries[0].EntryID, nil
}
id := deterministicID(dossierID, "cat", fmt.Sprintf("%d", lib.CategoryVital))
err = lib.EntryWrite(accessorID, &lib.Entry{
EntryID: id,
DossierID: dossierID,
ParentID: dossierID,
Category: lib.CategoryVital,
Type: "root",
})
return id, err
}
func ensureGroup(accessorID, dossierID, rootID, metric, unit, label string) (string, error) {
entries, err := lib.EntryRead(accessorID, dossierID, &lib.Filter{
Category: lib.CategoryVital,
Type: metric,
ParentID: rootID,
})
if err != nil {
return "", err
}
if len(entries) > 0 {
return entries[0].EntryID, nil
}
id := deterministicID(dossierID, "group", fmt.Sprintf("%d", lib.CategoryVital), metric)
data, _ := json.Marshal(map[string]string{"unit": unit, "source": "renpho"})
err = lib.EntryWrite(accessorID, &lib.Entry{
EntryID: id,
DossierID: dossierID,
ParentID: rootID,
Category: lib.CategoryVital,
Type: metric,
Summary: label,
Data: string(data),
})
return id, err
}
func deterministicID(parts ...string) string {
h := sha256.Sum256([]byte(strings.Join(parts, ":")))
return fmt.Sprintf("%016x", h[:8])
}
// ============================================================================
// Renpho API client
// ============================================================================
func apiCall(s *session, path string, body any) (*apiResponse, error) {
bodyJSON, err := json.Marshal(body)
if err != nil {
return nil, err
}
encrypted := aesEncrypt(bodyJSON)
wrapper, _ := json.Marshal(map[string]string{"encryptData": encrypted})
req, err := http.NewRequest("POST", baseURL+"/"+path, bytes.NewReader(wrapper))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("timeZone", "+1")
req.Header.Set("language", "2")
req.Header.Set("appVersion", "5.18.3")
req.Header.Set("platform", "2")
req.Header.Set("systemVersion", "14")
req.Header.Set("phone", "import-renpho")
req.Header.Set("languageCode", "en")
req.Header.Set("area", "0")
if s != nil {
req.Header.Set("token", s.token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(data))
}
// Outer response is plain JSON with encrypted data field
var outer struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(data, &outer); err != nil {
return nil, fmt.Errorf("parse response: %v (%s)", err, string(data))
}
result := &apiResponse{Code: outer.Code, Msg: outer.Msg}
// Data field is a quoted base64 AES-encrypted string
if len(outer.Data) > 2 && outer.Data[0] == '"' {
var encStr string
json.Unmarshal(outer.Data, &encStr)
if encStr != "" {
decrypted := aesDecrypt(encStr)
if decrypted != nil {
result.Data = decrypted
}
}
} else {
result.Data = outer.Data
}
return result, nil
}
// ============================================================================
// AES-128/ECB/PKCS5 — matches Renpho's com.renpho.module.utils.AESUtil
// ============================================================================
func aesEncrypt(plaintext []byte) string {
block, _ := aes.NewCipher([]byte(aesKey))
padded := pkcs5Pad(plaintext, aes.BlockSize)
out := make([]byte, len(padded))
for i := 0; i < len(padded); i += aes.BlockSize {
block.Encrypt(out[i:i+aes.BlockSize], padded[i:i+aes.BlockSize])
}
return base64.StdEncoding.EncodeToString(out)
}
func aesDecrypt(b64 string) []byte {
data, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
return nil
}
block, err := aes.NewCipher([]byte(aesKey))
if err != nil {
return nil
}
if len(data)%aes.BlockSize != 0 {
return nil
}
out := make([]byte, len(data))
for i := 0; i < len(data); i += aes.BlockSize {
block.Decrypt(out[i:i+aes.BlockSize], data[i:i+aes.BlockSize])
}
return pkcs5Unpad(out)
}
func pkcs5Pad(data []byte, blockSize int) []byte {
padding := blockSize - len(data)%blockSize
pad := bytes.Repeat([]byte{byte(padding)}, padding)
return append(data, pad...)
}
func pkcs5Unpad(data []byte) []byte {
if len(data) == 0 {
return nil
}
padding := int(data[len(data)-1])
if padding > len(data) || padding > aes.BlockSize {
return data
}
return data[:len(data)-padding]
}
func fatal(format string, args ...any) {
fmt.Printf("FATAL: "+format+"\n", args...)
panic("abort")
}

View File

@ -506,6 +506,7 @@ func entriesToSectionItems(entries []*lib.Entry) []SectionItem {
"source": true, "source": true,
"source_spans": true, "source_spans": true,
"summary_translated": true, "summary_translated": true,
"data_translated": true,
} }
var items []SectionItem var items []SectionItem
@ -531,6 +532,9 @@ func entriesToSectionItems(entries []*lib.Entry) []SectionItem {
if e.Data != "" { if e.Data != "" {
var dataMap map[string]interface{} var dataMap map[string]interface{}
if json.Unmarshal([]byte(e.Data), &dataMap) == nil { if json.Unmarshal([]byte(e.Data), &dataMap) == nil {
// Use translated data values when available
translated, _ := dataMap["data_translated"].(map[string]interface{})
// Collect keys in deterministic order: preferred fields first, then alphabetical // Collect keys in deterministic order: preferred fields first, then alphabetical
var keys []string var keys []string
for k := range dataMap { for k := range dataMap {
@ -550,10 +554,13 @@ func entriesToSectionItems(entries []*lib.Entry) []SectionItem {
if val == "" { if val == "" {
continue continue
} }
item.Children = append(item.Children, SectionItem{ child := SectionItem{Label: k, Value: val}
Label: k, if translated != nil {
Value: val, if tv := formatDataValue(k, translated[k]); tv != "" && tv != val {
}) child.Meta = tv
}
}
item.Children = append(item.Children, child)
} }
if len(item.Children) > 0 { if len(item.Children) > 0 {
item.Expandable = true item.Expandable = true

View File

@ -236,3 +236,6 @@ api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any
api_authentication: "Authentication" api_authentication: "Authentication"
api_auth_instructions: "[EN] Include your API token in the Authorization header:" api_auth_instructions: "[EN] Include your API token in the Authorization header:"
copy: "Copy" copy: "Copy"
relation: "Relation"
relation_to: "Til"
me: "Mig"

View File

@ -236,3 +236,6 @@ api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any
api_authentication: "Authentication" api_authentication: "Authentication"
api_auth_instructions: "[EN] Include your API token in the Authorization header:" api_auth_instructions: "[EN] Include your API token in the Authorization header:"
copy: "Copy" copy: "Copy"
relation: "Beziehung"
relation_to: "Zu"
me: "Ich"

View File

@ -309,3 +309,6 @@ api_token_regenerate_confirm: "This will invalidate your current token. Any conn
api_authentication: "Authentication" api_authentication: "Authentication"
api_auth_instructions: "Include your API token in the Authorization header:" api_auth_instructions: "Include your API token in the Authorization header:"
copy: "Copy" copy: "Copy"
relation: "Relation"
relation_to: "To"
me: "Me"

View File

@ -236,3 +236,6 @@ api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any
api_authentication: "Authentication" api_authentication: "Authentication"
api_auth_instructions: "[EN] Include your API token in the Authorization header:" api_auth_instructions: "[EN] Include your API token in the Authorization header:"
copy: "Copy" copy: "Copy"
relation: "Relación"
relation_to: "A"
me: "Yo"

View File

@ -236,3 +236,6 @@ api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any
api_authentication: "Authentication" api_authentication: "Authentication"
api_auth_instructions: "[EN] Include your API token in the Authorization header:" api_auth_instructions: "[EN] Include your API token in the Authorization header:"
copy: "Copy" copy: "Copy"
relation: "Suhde"
relation_to: "Suhteessa"
me: "Minä"

View File

@ -236,3 +236,6 @@ api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any
api_authentication: "Authentication" api_authentication: "Authentication"
api_auth_instructions: "[EN] Include your API token in the Authorization header:" api_auth_instructions: "[EN] Include your API token in the Authorization header:"
copy: "Copy" copy: "Copy"
relation: "Relation"
relation_to: "À"
me: "Moi"

View File

@ -236,3 +236,6 @@ api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any
api_authentication: "Authentication" api_authentication: "Authentication"
api_auth_instructions: "[EN] Include your API token in the Authorization header:" api_auth_instructions: "[EN] Include your API token in the Authorization header:"
copy: "Copy" copy: "Copy"
relation: "Relazione"
relation_to: "A"
me: "Io"

View File

@ -236,3 +236,6 @@ api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any
api_authentication: "Authentication" api_authentication: "Authentication"
api_auth_instructions: "[EN] Include your API token in the Authorization header:" api_auth_instructions: "[EN] Include your API token in the Authorization header:"
copy: "Copy" copy: "Copy"
relation: "関係"
relation_to: "宛先"
me: "自分"

View File

@ -236,3 +236,6 @@ api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any
api_authentication: "Authentication" api_authentication: "Authentication"
api_auth_instructions: "[EN] Include your API token in the Authorization header:" api_auth_instructions: "[EN] Include your API token in the Authorization header:"
copy: "Copy" copy: "Copy"
relation: "관계"
relation_to: "에게"
me: "나"

View File

@ -250,3 +250,6 @@ api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any
api_authentication: "Authentication" api_authentication: "Authentication"
api_auth_instructions: "[EN] Include your API token in the Authorization header:" api_auth_instructions: "[EN] Include your API token in the Authorization header:"
copy: "Copy" copy: "Copy"
relation: "Relatie"
relation_to: "Aan"
me: "Ik"

View File

@ -236,3 +236,6 @@ api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any
api_authentication: "Authentication" api_authentication: "Authentication"
api_auth_instructions: "[EN] Include your API token in the Authorization header:" api_auth_instructions: "[EN] Include your API token in the Authorization header:"
copy: "Copy" copy: "Copy"
relation: "Relasjon"
relation_to: "Til"
me: "Meg"

View File

@ -236,3 +236,6 @@ api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any
api_authentication: "Authentication" api_authentication: "Authentication"
api_auth_instructions: "[EN] Include your API token in the Authorization header:" api_auth_instructions: "[EN] Include your API token in the Authorization header:"
copy: "Copy" copy: "Copy"
relation: "Relação"
relation_to: "Para"
me: "Eu"

View File

@ -240,3 +240,8 @@ api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any
api_authentication: "Authentication" api_authentication: "Authentication"
api_auth_instructions: "[EN] Include your API token in the Authorization header:" api_auth_instructions: "[EN] Include your API token in the Authorization header:"
copy: "Copy" copy: "Copy"
save: "Сохранить"
cancel: "Отмена"
relation: "Отношение"
relation_to: "Кому"
me: "Я"

View File

@ -236,3 +236,6 @@ api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any
api_authentication: "Authentication" api_authentication: "Authentication"
api_auth_instructions: "[EN] Include your API token in the Authorization header:" api_auth_instructions: "[EN] Include your API token in the Authorization header:"
copy: "Copy" copy: "Copy"
relation: "Relation"
relation_to: "Till"
me: "Jag"

View File

@ -236,3 +236,6 @@ api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any
api_authentication: "Authentication" api_authentication: "Authentication"
api_auth_instructions: "[EN] Include your API token in the Authorization header:" api_auth_instructions: "[EN] Include your API token in the Authorization header:"
copy: "Copy" copy: "Copy"
relation: "关系"
relation_to: "与…的关系"
me: "我"

View File

@ -17,6 +17,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strconv" "strconv"
"sort"
"strings" "strings"
"time" "time"
@ -28,6 +29,7 @@ var (
Version string = "dev" // Set via ldflags at build time Version string = "dev" // Set via ldflags at build time
templates *template.Template templates *template.Template
translations map[string]map[string]string translations map[string]map[string]string
langOptions []LangOption
smtpHost, smtpPort, smtpUser, smtpToken, smtpFrom string smtpHost, smtpPort, smtpUser, smtpToken, smtpFrom string
accessLog *os.File accessLog *os.File
) )
@ -85,14 +87,36 @@ func logMiddleware(next http.Handler) http.Handler {
} }
type LangOption struct {
Code, Name, Flag string
}
type RelationTarget struct {
DossierID, Name string
}
type StatChip struct {
Icon string
Count int
Label string // "genome" for binary, empty for count-based
Color string // CSS color class for pill
}
type DossierStats struct { type DossierStats struct {
Imaging, Documents, Labs, Genome, Vitals, Medications, Supplements int Imaging, Documents, Labs, Genome, Vitals, Medications, Supplements int
Chips []StatChip // top 5 non-zero categories
OverflowCount int // how many more categories beyond the top 5
TotalCount int // total entries across all categories
} }
type AccessEntry struct { type AccessEntry struct {
DossierID string DossierID string
Name, Relation, DateOfBirth string Name, Relation, DateOfBirth string
Context string // inter-dossier relationship, e.g. "Parent of Anastasiia"
Lang string // language code for flag display
Sex int
RelationInt int RelationInt int
NewGroup bool // true if this entry starts a new visual group
IsCareReceiver, CanEdit, IsSelf, IsPending bool IsCareReceiver, CanEdit, IsSelf, IsPending bool
AccessedAt time.Time AccessedAt time.Time
Stats DossierStats Stats DossierStats
@ -137,6 +161,9 @@ type PageData struct {
SelectedRole string SelectedRole string
// Dossier: unified sections // Dossier: unified sections
Sections []DossierSection Sections []DossierSection
Languages []LangOption
RelationTargets []RelationTarget
RelationTo string
} }
type CategoryAccess struct { type CategoryAccess struct {
@ -166,6 +193,14 @@ func initDB() {
// Rate limiting removed - handled differently now // Rate limiting removed - handled differently now
} }
// Native names for languages (used in language selector dropdowns).
var langNativeNames = map[string]string{
"da": "Dansk", "de": "Deutsch", "en": "English", "es": "Español",
"fi": "Suomi", "fr": "Français", "it": "Italiano", "ja": "日本語",
"ko": "한국어", "nl": "Nederlands", "no": "Norsk", "pt": "Português",
"ru": "Русский", "sv": "Svenska", "zh": "中文",
}
func loadTranslations() { func loadTranslations() {
translations = make(map[string]map[string]string) translations = make(map[string]map[string]string)
files, _ := filepath.Glob(filepath.Join(langDir, "*.yaml")) files, _ := filepath.Glob(filepath.Join(langDir, "*.yaml"))
@ -183,6 +218,26 @@ func loadTranslations() {
} }
file.Close() file.Close()
} }
// Build sorted language options from loaded translations
codes := make([]string, 0, len(translations))
for code := range translations { codes = append(codes, code) }
sort.Strings(codes)
langOptions = make([]LangOption, 0, len(codes))
for _, code := range codes {
name := langNativeNames[code]
if name == "" { name = code }
flag := ""
// Reuse the same flag mapping as langFlag template func
flags := map[string]string{
"en": "\U0001F1EC\U0001F1E7", "nl": "\U0001F1F3\U0001F1F1", "de": "\U0001F1E9\U0001F1EA",
"ru": "\U0001F1F7\U0001F1FA", "zh": "\U0001F1E8\U0001F1F3", "ja": "\U0001F1EF\U0001F1F5",
"ko": "\U0001F1F0\U0001F1F7", "fr": "\U0001F1EB\U0001F1F7", "es": "\U0001F1EA\U0001F1F8",
"pt": "\U0001F1E7\U0001F1F7", "it": "\U0001F1EE\U0001F1F9", "sv": "\U0001F1F8\U0001F1EA",
"no": "\U0001F1F3\U0001F1F4", "da": "\U0001F1E9\U0001F1F0", "fi": "\U0001F1EB\U0001F1EE",
}
if f, ok := flags[code]; ok { flag = f }
langOptions = append(langOptions, LangOption{Code: code, Name: name, Flag: flag})
}
} }
func T(lang, key string) string { func T(lang, key string) string {
@ -228,6 +283,59 @@ func loadTemplates() {
} }
return d return d
}, },
"age": func(dob string) string {
if len(dob) < 10 { return "" }
t, err := time.Parse("2006-01-02", dob[:10])
if err != nil { return "" }
years := int(time.Since(t).Hours() / 8766) // 365.25 * 24
return fmt.Sprintf("%dy", years)
},
"initials": func(name string) string {
parts := strings.Fields(name)
if len(parts) == 0 { return "?" }
first := string([]rune(parts[0])[:1])
if len(parts) == 1 { return strings.ToUpper(first) }
last := string([]rune(parts[len(parts)-1])[:1])
return strings.ToUpper(first + last)
},
"initialColor": func(id string) string {
// Muted versions of the dossier section colors, keyed by dossier ID (hex)
colors := []string{
"#C47A3D", // amber
"#5AAD8A", // green
"#7E8FC2", // blue
"#C26E6E", // red
"#9A82B8", // purple
"#C2963D", // gold
"#6BA0B8", // teal
"#B87898", // pink
"#7DAD6B", // olive
"#8B7D6B", // stone
}
if len(id) < 2 { return colors[0] }
// Use last 2 hex chars of ID for good distribution
b, _ := strconv.ParseUint(id[len(id)-2:], 16, 8)
return colors[b%uint64(len(colors))]
},
"langFlag": func(code string) string {
for _, opt := range langOptions {
if opt.Code == code { return opt.Flag }
}
// Flags for languages without portal translations
extra := map[string]string{
"ar": "\U0001F1F8\U0001F1E6", "hi": "\U0001F1EE\U0001F1F3",
"th": "\U0001F1F9\U0001F1ED", "tr": "\U0001F1F9\U0001F1F7",
"uk": "\U0001F1FA\U0001F1E6",
}
if f, ok := extra[code]; ok { return f }
return ""
},
"borderLevel": func(total int) string {
if total == 0 { return "" }
if total < 5 { return "border-sparse" }
if total < 20 { return "border-moderate" }
return "border-rich"
},
} }
templates = template.Must(template.New("").Funcs(funcs).ParseGlob(filepath.Join(tmplDir, "*.tmpl"))) templates = template.Must(template.New("").Funcs(funcs).ParseGlob(filepath.Join(tmplDir, "*.tmpl")))
} }
@ -260,6 +368,14 @@ func randomHash() string { b := make([]byte, 32); rand.Read(b); return base64.St
func parseID(s string) int64 { id, _ := strconv.ParseInt(s, 10, 64); return id } func parseID(s string) int64 { id, _ := strconv.ParseInt(s, 10, 64); return id }
func invertRelation(rel int) int {
switch rel {
case 1: return 2 // Parent → Child
case 2: return 1 // Child → Parent
default: return rel
}
}
func isMinor(dob string) bool { func isMinor(dob string) bool {
// Try multiple formats // Try multiple formats
var t time.Time var t time.Time
@ -608,7 +724,7 @@ func handleSetLang(w http.ResponseWriter, r *http.Request) {
d := getLoggedInDossier(r) d := getLoggedInDossier(r)
if d == nil { http.Redirect(w, r, "/", http.StatusSeeOther); return } if d == nil { http.Redirect(w, r, "/", http.StatusSeeOther); return }
lang := r.FormValue("lang") lang := r.FormValue("lang")
if _, ok := translations[lang]; ok { if len(lang) >= 2 && len(lang) <= 5 {
d.Preferences.Language = lang d.Preferences.Language = lang
lib.DossierWrite(d.DossierID, d) lib.DossierWrite(d.DossierID, d)
} }
@ -778,16 +894,82 @@ func handleInvite(w http.ResponseWriter, r *http.Request) {
render(w, r, PageData{Page: "invite", Lang: lang, Dossier: p, EmailLang: lang, Success: "Invitation sent!"}) render(w, r, PageData{Page: "invite", Lang: lang, Dossier: p, EmailLang: lang, Success: "Invitation sent!"})
} }
// categoryMeta maps category IDs to their dashboard icon, short label, and count type.
var categoryMeta = []struct {
Category int
Icon string
Short string // short label for chip display
TypeFilter string
Binary bool // show as label only (no count), e.g. genome
Color string // CSS class for pill color
}{
{lib.CategoryImaging, "📷", "imaging", "study", false, "chip-blue"},
{lib.CategoryLab, "🧪", "labs", "lab_order", false, "chip-emerald"},
{lib.CategoryGenome, "🧬", "genome", "tier", true, "chip-purple"},
{lib.CategoryDocument, "📄", "docs", "", false, "chip-stone"},
{lib.CategorySurgery, "🔪", "procedures", "", false, "chip-red"},
{lib.CategoryDiagnosis, "🏥", "diagnoses", "", false, "chip-amber"},
{lib.CategoryHospital, "🏨", "hospitals", "", false, "chip-red"},
{lib.CategoryMedication, "💊", "meds", "", false, "chip-pink"},
{lib.CategorySupplement, "🌿", "supps", "", false, "chip-green"},
{lib.CategoryVital, "❤️", "vitals", "", false, "chip-rose"},
{lib.CategoryExercise, "🏃", "exercise", "", false, "chip-cyan"},
{lib.CategoryTherapy, "🧠", "therapy", "", false, "chip-violet"},
{lib.CategoryDevice, "⚙️", "devices", "", false, "chip-slate"},
{lib.CategoryProvider, "👨‍⚕️", "providers", "", false, "chip-indigo"},
{lib.CategoryConsultation, "📋", "consults", "", false, "chip-amber"},
{lib.CategoryAssessment, "📊", "assessments", "", false, "chip-teal"},
}
func getDossierStats(dossierID string) DossierStats { func getDossierStats(dossierID string) DossierStats {
ctx := &lib.AccessContext{AccessorID: dossierID} // Self-access for dashboard ctx := &lib.AccessContext{AccessorID: dossierID}
var stats DossierStats var stats DossierStats
stats.Imaging, _ = lib.EntryCount(ctx, dossierID, lib.CategoryImaging, "study")
stats.Labs, _ = lib.EntryCount(ctx, dossierID, lib.CategoryLab, "lab_report") // Count all categories, build chips for non-zero ones
stats.Genome, _ = lib.EntryCount(ctx, dossierID, lib.CategoryGenome, "tier") var all []StatChip
stats.Documents, _ = lib.EntryCount(ctx, dossierID, lib.CategoryDocument, "") for _, m := range categoryMeta {
stats.Vitals, _ = lib.EntryCount(ctx, dossierID, lib.CategoryVital, "") n, _ := lib.EntryCount(ctx, dossierID, m.Category, m.TypeFilter)
stats.Medications, _ = lib.EntryCount(ctx, dossierID, lib.CategoryMedication, "") if n == 0 {
stats.Supplements, _ = lib.EntryCount(ctx, dossierID, lib.CategorySupplement, "") continue
}
stats.TotalCount += n
chip := StatChip{Icon: m.Icon, Count: n, Label: m.Short, Color: m.Color}
if m.Binary {
chip.Count = 0
}
all = append(all, chip)
// Keep legacy fields for backward compat
switch m.Category {
case lib.CategoryImaging:
stats.Imaging = n
case lib.CategoryLab:
stats.Labs = n
case lib.CategoryGenome:
stats.Genome = n
case lib.CategoryDocument:
stats.Documents = n
case lib.CategoryVital:
stats.Vitals = n
case lib.CategoryMedication:
stats.Medications = n
case lib.CategorySupplement:
stats.Supplements = n
}
}
// Top 5 by count (binary items like genome sort last among equals)
sort.Slice(all, func(i, j int) bool {
ci, cj := all[i].Count, all[j].Count
if ci == 0 { ci = 1 } // binary items sort as 1
if cj == 0 { cj = 1 }
return ci > cj
})
if len(all) > 5 {
stats.OverflowCount = len(all) - 5
all = all[:5]
}
stats.Chips = all
return stats return stats
} }
@ -806,14 +988,19 @@ func handleDashboard(w http.ResponseWriter, r *http.Request) {
grantMap := map[string]*lib.Access{} grantMap := map[string]*lib.Access{}
for _, g := range grants { grantMap[g.DossierID] = g } for _, g := range grants { grantMap[g.DossierID] = g }
// Build accessible dossier list with names index
nameMap := map[string]string{} // dossierID → name
var accessible []AccessEntry var accessible []AccessEntry
for _, entry := range allDossiers { for _, entry := range allDossiers {
if entry.DossierID == p.DossierID { continue } if entry.DossierID == p.DossierID { continue }
d := lib.DossierFromEntry(entry) d := lib.DossierFromEntry(entry)
nameMap[d.DossierID] = d.Name
e := AccessEntry{ e := AccessEntry{
DossierID: d.DossierID, DossierID: d.DossierID,
Name: d.Name, Name: d.Name,
DateOfBirth: d.DateOfBirth, DateOfBirth: d.DateOfBirth,
Sex: d.Sex,
Lang: d.Preferences.Language,
Stats: getDossierStats(d.DossierID), Stats: getDossierStats(d.DossierID),
} }
if g := grantMap[d.DossierID]; g != nil { if g := grantMap[d.DossierID]; g != nil {
@ -824,6 +1011,93 @@ func handleDashboard(w http.ResponseWriter, r *http.Request) {
} }
accessible = append(accessible, e) accessible = append(accessible, e)
} }
// Find inter-dossier relationships (scan all accessible dossiers)
idSet := map[string]bool{}
relOfMe := map[string]int{} // dossierID → my relation to them
for _, e := range accessible {
idSet[e.DossierID] = true
relOfMe[e.DossierID] = e.RelationInt
}
groupOf := map[string]string{} // dossierID → group anchor ID
contextMap := map[string]string{} // dossierID → "Parent · Name"
isParent := map[string]bool{} // dossierID → true if they're a parent in their group
familyLinked := map[string]bool{} // "other" dossierID → true if linked to a family member
// relNames: grantee's role → label. inverseRel: target's role (opposite direction)
relNames := map[int]string{1: "Parent", 2: "Child", 3: "Spouse"}
inverseRel := map[int]string{1: "Child", 2: "Parent", 3: "Spouse"}
isFamily := func(rel int) bool { return rel >= 1 && rel <= 4 }
for _, e := range accessible {
grants, _ := lib.AccessList(&lib.AccessFilter{TargetID: e.DossierID})
for _, g := range grants {
if g.GranteeID == p.DossierID { continue }
if !idSet[g.GranteeID] { continue }
// g.GranteeID has relation g.Relation to e.DossierID
groupOf[g.GranteeID] = e.DossierID
groupOf[e.DossierID] = e.DossierID
if g.Relation == 1 { isParent[g.GranteeID] = true }
// Context: grantee gets their role label, target gets the inverse
if rn, ok := relNames[g.Relation]; ok {
contextMap[g.GranteeID] = rn + " · " + nameMap[e.DossierID]
}
if rn, ok := inverseRel[g.Relation]; ok {
if _, exists := contextMap[e.DossierID]; !exists {
contextMap[e.DossierID] = rn + " · " + nameMap[g.GranteeID]
}
}
// If an "other" is connected to a family member, promote to family
gRel, eRel := relOfMe[g.GranteeID], relOfMe[e.DossierID]
if !isFamily(gRel) && isFamily(eRel) { familyLinked[g.GranteeID] = true }
if !isFamily(eRel) && isFamily(gRel) { familyLinked[e.DossierID] = true }
}
}
// Tier: 0=family (rel 1-4 or linked to family), 1=connected others, 2=ungrouped others, 3=demo
tier := func(e *AccessEntry) int {
switch {
case e.RelationInt >= 1 && e.RelationInt <= 4: return 0
case familyLinked[e.DossierID]: return 0
case e.RelationInt == 99: return 3
case groupOf[e.DossierID] != "": return 1
default: return 2
}
}
// Within family: spouse first, then children by name
famOrder := map[int]int{3: 0, 2: 1, 1: 2, 4: 3}
sort.SliceStable(accessible, func(i, j int) bool {
ti, tj := tier(&accessible[i]), tier(&accessible[j])
if ti != tj { return ti < tj }
if ti == 0 { // family: spouse → child → parent → sibling
fi, fj := famOrder[accessible[i].RelationInt], famOrder[accessible[j].RelationInt]
if fi != fj { return fi < fj }
return accessible[i].Name < accessible[j].Name
}
if ti == 1 { // connected others: group together, parent before child
gi, gj := groupOf[accessible[i].DossierID], groupOf[accessible[j].DossierID]
if gi != gj { return gi < gj }
// Within same group: parent first
pi, pj := isParent[accessible[i].DossierID], isParent[accessible[j].DossierID]
if pi != pj { return pi }
return accessible[i].Name < accessible[j].Name
}
return accessible[i].Name < accessible[j].Name
})
// Set context strings + detect group breaks
prevTier, prevGroup := -1, ""
for i := range accessible {
// Only show inter-dossier context on "other" dossiers, not direct family
if ctx, ok := contextMap[accessible[i].DossierID]; ok && !isFamily(accessible[i].RelationInt) {
accessible[i].Context = ctx
}
t := tier(&accessible[i])
g := groupOf[accessible[i].DossierID]
if i > 0 && (t != prevTier || (t == 1 && g != prevGroup)) {
accessible[i].NewGroup = true
}
prevTier, prevGroup = t, g
}
render(w, r, PageData{Page: "dashboard", Lang: lang, Embed: isEmbed(r), Dossier: p, AccessibleDossiers: accessible, SelfStats: selfStats}) render(w, r, PageData{Page: "dashboard", Lang: lang, Embed: isEmbed(r), Dossier: p, AccessibleDossiers: accessible, SelfStats: selfStats})
} }
@ -866,13 +1140,25 @@ func handleDemo(w http.ResponseWriter, r *http.Request) {
}) })
} }
func loadRelationTargets(accessorID string) []RelationTarget {
allDossiers, _ := lib.EntryRead(accessorID, "", &lib.Filter{Category: 0})
var targets []RelationTarget
for _, e := range allDossiers {
if e.DossierID == accessorID { continue }
d := lib.DossierFromEntry(e)
targets = append(targets, RelationTarget{d.DossierID, d.Name})
}
return targets
}
func handleAddDossier(w http.ResponseWriter, r *http.Request) { func handleAddDossier(w http.ResponseWriter, r *http.Request) {
p := getLoggedInDossier(r) p := getLoggedInDossier(r)
if p == nil { http.Redirect(w, r, "/", http.StatusSeeOther); return } if p == nil { http.Redirect(w, r, "/", http.StatusSeeOther); return }
lang := getLang(r) lang := getLang(r)
targets := loadRelationTargets(p.DossierID)
if r.Method == "GET" { if r.Method == "GET" {
render(w, r, PageData{Page: "add_dossier", Lang: lang, Dossier: p}) render(w, r, PageData{Page: "add_dossier", Lang: lang, Dossier: p, Languages: langOptions, RelationTargets: targets})
return return
} }
@ -880,12 +1166,18 @@ func handleAddDossier(w http.ResponseWriter, r *http.Request) {
dob := r.FormValue("dob") dob := r.FormValue("dob")
sex := r.FormValue("sex") sex := r.FormValue("sex")
email := strings.ToLower(strings.TrimSpace(r.FormValue("email"))) email := strings.ToLower(strings.TrimSpace(r.FormValue("email")))
emailLang := r.FormValue("email_lang") emailLang := r.FormValue("lang")
relation := r.FormValue("relation") relation := r.FormValue("relation")
relationTo := r.FormValue("relation_to")
isCareReceiver := r.FormValue("is_care_receiver") == "1" isCareReceiver := r.FormValue("is_care_receiver") == "1"
canEdit := r.FormValue("can_edit") == "1"
renderErr := func(msg string) {
render(w, r, PageData{Page: "add_dossier", Lang: lang, Dossier: p, Name: name, DOB: dob, Sex: sex, Email: email, EmailLang: emailLang, Relation: relation, RelationTo: relationTo, IsCareReceiver: isCareReceiver, CanEdit: canEdit, Languages: langOptions, RelationTargets: targets, Error: msg})
}
if name == "" || dob == "" || sex == "" || relation == "" { if name == "" || dob == "" || sex == "" || relation == "" {
render(w, r, PageData{Page: "add_dossier", Lang: lang, Dossier: p, Name: name, DOB: dob, Sex: sex, Email: email, Relation: relation, IsCareReceiver: isCareReceiver, Error: "Name, date of birth, sex, and relation are required"}) renderErr("Name, date of birth, sex, and relation are required")
return return
} }
@ -902,14 +1194,16 @@ func handleAddDossier(w http.ResponseWriter, r *http.Request) {
// Check duplicate: same email already exists // Check duplicate: same email already exists
if email != "" { if email != "" {
if _, exists := lib.DossierExists(email); exists { if _, exists := lib.DossierExists(email); exists {
render(w, r, PageData{Page: "add_dossier", Lang: lang, Dossier: p, Name: name, DOB: dob, Sex: sex, Email: email, Relation: relation, IsCareReceiver: isCareReceiver, Error: "A dossier with this email already exists"}) renderErr("A dossier with this email already exists")
return return
} }
} }
// Create cat-0 entry for the new dossier // Create cat-0 entry for the new dossier
newID := lib.NewID() newID := lib.NewID()
dataJSON, _ := json.Marshal(map[string]any{"dob": dob, "sex": sexInt, "lang": lang}) dossierLang := emailLang
if dossierLang == "" { dossierLang = lang }
dataJSON, _ := json.Marshal(map[string]any{"dob": dob, "sex": sexInt, "lang": dossierLang})
newEntry := &lib.Entry{ newEntry := &lib.Entry{
EntryID: newID, EntryID: newID,
DossierID: newID, DossierID: newID,
@ -921,16 +1215,28 @@ func handleAddDossier(w http.ResponseWriter, r *http.Request) {
Timestamp: time.Now().Unix(), Timestamp: time.Now().Unix(),
} }
if err := lib.EntryWrite("", newEntry); err != nil { if err := lib.EntryWrite("", newEntry); err != nil {
render(w, r, PageData{Page: "add_dossier", Lang: lang, Dossier: p, Name: name, DOB: dob, Sex: sex, Email: email, Relation: relation, IsCareReceiver: isCareReceiver, Error: "Failed to create dossier"}) renderErr("Failed to create dossier")
return return
} }
// Grant creator access to the new dossier // Grant access
isMe := relationTo == "" || relationTo == p.DossierID
if isMe {
// "Child of Me" → I am their Parent → invert relation for grant
ops := lib.PermRead | lib.PermWrite ops := lib.PermRead | lib.PermWrite
if relationInt == 1 || relationInt == 4 { // parent or guardian inverted := invertRelation(relationInt)
if inverted == 1 || inverted == 5 { // I am parent or guardian
ops = lib.PermRead | lib.PermWrite | lib.PermDelete | lib.PermManage ops = lib.PermRead | lib.PermWrite | lib.PermDelete | lib.PermManage
} }
lib.GrantAccess(newID, p.DossierID, newID, ops, relationInt) lib.GrantAccess(newID, p.DossierID, newID, ops, inverted)
} else {
// "Parent of Tanya" → Tanya is their Child → invert for Tanya's grant
lib.GrantAccess(newID, relationTo, newID, lib.PermRead, invertRelation(relationInt))
// Creator gets access as Other with read+write
ops := lib.PermRead | lib.PermWrite
lib.GrantAccess(newID, p.DossierID, newID, ops, 10)
}
lib.AuditLogFull(p.DossierID, "", newID, "dossier_added", "", relationInt) lib.AuditLogFull(p.DossierID, "", newID, "dossier_added", "", relationInt)
if email != "" && !isMinor(dob) { sendShareEmail(email, name, name, p.Name, true, emailLang) } if email != "" && !isMinor(dob) { sendShareEmail(email, name, name, p.Name, true, emailLang) }
@ -981,8 +1287,9 @@ func handleEditDossier(w http.ResponseWriter, r *http.Request) {
Page: "add_dossier", Lang: lang, Dossier: p, Page: "add_dossier", Lang: lang, Dossier: p,
EditMode: true, EditDossier: target, IsSelf: isSelf, EditMode: true, EditDossier: target, IsSelf: isSelf,
Name: target.Name, DOB: target.DateOfBirth, Sex: sexStr, Email: target.Email, Name: target.Name, DOB: target.DateOfBirth, Sex: sexStr, Email: target.Email,
EmailLang: target.Preferences.Language,
Relation: relationStr, IsCareReceiver: false, CanEdit: canEdit, Relation: relationStr, IsCareReceiver: false, CanEdit: canEdit,
Error: errMsg, Languages: langOptions, Error: errMsg,
}) })
return return
} }
@ -1001,8 +1308,9 @@ func handleEditDossier(w http.ResponseWriter, r *http.Request) {
Page: "add_dossier", Lang: lang, Dossier: p, Page: "add_dossier", Lang: lang, Dossier: p,
EditMode: true, EditDossier: target, IsSelf: isSelf, EditMode: true, EditDossier: target, IsSelf: isSelf,
Name: name, DOB: dob, Sex: sex, Email: email, Name: name, DOB: dob, Sex: sex, Email: email,
EmailLang: r.FormValue("lang"),
Relation: relation, IsCareReceiver: isCareReceiver, CanEdit: canEdit, Relation: relation, IsCareReceiver: isCareReceiver, CanEdit: canEdit,
Error: msg, Languages: langOptions, Error: msg,
}) })
} }
@ -1034,6 +1342,7 @@ func handleEditDossier(w http.ResponseWriter, r *http.Request) {
} }
target.Sex = sexInt target.Sex = sexInt
target.Email = email target.Email = email
target.Preferences.Language = r.FormValue("lang")
if err := lib.DossierWrite(p.DossierID, target); err != nil { if err := lib.DossierWrite(p.DossierID, target); err != nil {
renderErr("Failed to update dossier") renderErr("Failed to update dossier")

View File

@ -1559,15 +1559,29 @@ a:hover {
.sg-section-header { border-bottom: 1px solid var(--border); padding-bottom: 12px; margin-bottom: 0; } .sg-section-header { border-bottom: 1px solid var(--border); padding-bottom: 12px; margin-bottom: 0; }
.sg-card-content { padding: 32px; } .sg-card-content { padding: 32px; }
.sg-card-content-sm { padding: 24px; max-width: 480px; } .sg-card-content-sm { padding: 24px; max-width: 480px; }
.sg-profile-card { padding: 20px; min-height: 140px; display: flex; flex-direction: column; } /* Dashboard grid separator */
.grid-separator { grid-column: 1 / -1; border: none; border-top: 1px solid var(--border); margin: 4px 0; }
/* Dashboard profile cards */
.sg-profile-card { padding: 20px; min-height: 140px; display: flex; flex-direction: column; border-left: 3px solid transparent; transition: border-color 0.2s; }
.sg-profile-card.border-sparse { border-left-color: var(--border-hover); }
.sg-profile-card.border-moderate { border-left-color: var(--accent); }
.sg-profile-card.border-rich { border-left-color: var(--success); }
.sg-profile-card h3 { font-size: 1.25rem; margin-bottom: 4px; } .sg-profile-card h3 { font-size: 1.25rem; margin-bottom: 4px; }
.card-actions { position: absolute; top: 14px; right: 14px; display: flex; gap: 4px; } .card-actions { position: absolute; top: 14px; right: 14px; display: flex; gap: 4px; }
.card-actions a { color: var(--text-muted); text-decoration: none; padding: 2px 5px; font-size: 1.1rem; line-height: 1; border-radius: 4px; } .card-actions a { color: var(--text-muted); text-decoration: none; padding: 2px 5px; font-size: 1.1rem; line-height: 1; border-radius: 4px; }
.card-actions a:hover { color: var(--accent); background: var(--accent-light); } .card-actions a:hover { color: var(--accent); background: var(--accent-light); }
.sg-profile-card .card-meta { margin-bottom: 8px; } .sg-profile-card .card-meta { margin-bottom: 0; }
.card-context { font-size: 0.8rem; color: var(--text-subtle); font-style: italic; margin: 0; }
.card-flag { font-size: 0.85rem; vertical-align: middle; }
.sg-profile-dob { font-size: 0.85rem; color: var(--text-muted); margin-bottom: 12px; } .sg-profile-dob { font-size: 0.85rem; color: var(--text-muted); margin-bottom: 12px; }
.sg-profile-stats { display: flex; gap: 16px; font-size: 0.8rem; color: var(--text-muted); margin-bottom: 12px; } .sg-profile-stats { display: flex; flex-wrap: wrap; gap: 6px; font-size: 0.75rem; }
.sg-profile-stat { display: flex; align-items: center; gap: 4px; } .sg-profile-stat { display: flex; align-items: center; gap: 4px; }
/* Avatar */
.profile-header { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; }
.avatar { width: 40px; height: 40px; border-radius: 50%; display: flex; align-items: center; justify-content: center; color: white; font-weight: 600; font-size: 0.9rem; flex-shrink: 0; }
/* Pill chips */
.sg-profile-chip { display: inline-flex; align-items: center; gap: 3px; padding: 2px 8px; border-radius: 10px; font-size: 0.75rem; font-weight: 500; white-space: nowrap; border: 1px solid var(--border-hover); color: var(--text-muted); background: transparent; }
.chip-muted { border-color: var(--border); color: var(--text-subtle); }
.sg-row-link { color: var(--accent); font-size: 1.1rem; text-decoration: none; padding: 4px 8px; border-radius: 4px; } .sg-row-link { color: var(--accent); font-size: 1.1rem; text-decoration: none; padding: 4px 8px; border-radius: 4px; }
.sg-row-link:hover { background: var(--accent-light); } .sg-row-link:hover { background: var(--accent-light); }
.sg-supp-dose { font-size: 0.85rem; color: var(--text-muted); } .sg-supp-dose { font-size: 0.85rem; color: var(--text-muted); }
@ -1698,7 +1712,8 @@ a:hover {
/* Profile cards tighter */ /* Profile cards tighter */
.sg-profile-card { padding: 16px; min-height: auto; } .sg-profile-card { padding: 16px; min-height: auto; }
.sg-profile-stats { flex-wrap: wrap; gap: 8px; } .sg-profile-stats { gap: 4px; }
.avatar { width: 32px; height: 32px; font-size: 0.75rem; }
/* LLM options full width */ /* LLM options full width */
.sg-llm-option { padding: 12px; } .sg-llm-option { padding: 12px; }
@ -2043,9 +2058,23 @@ a:hover {
min-width: 100px; min-width: 100px;
flex: 0 0 auto !important; flex: 0 0 auto !important;
} }
.data-row.child .data-value-group {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
min-width: 0;
}
.data-row.child .data-value { .data-row.child .data-value {
color: var(--text); color: var(--text);
text-align: right; text-align: right;
white-space: normal;
overflow-wrap: break-word;
word-break: break-word;
}
.data-row.child .data-value.translated {
color: var(--text-muted);
font-size: 0.8rem;
} }
/* Document side pane */ /* Document side pane */

View File

@ -1,8 +1,6 @@
{{define "add_dossier"}} {{define "add_dossier"}}
<div class="sg-container" style="justify-content: center;"> <div class="sg-container">
<div class="data-card" style="padding: 48px; max-width: 480px; margin: 0 auto;">
<div style="flex: 1; display: flex; align-items: flex-start; padding-top: 5vh; justify-content: center;">
<div class="data-card" style="padding: 48px; max-width: 480px; width: 100%;">
<h1 style="font-size: 2rem; font-weight: 700; margin-bottom: 8px;">{{if .EditMode}}{{.T.edit_dossier}}{{else}}{{.T.add_dossier}}{{end}}</h1> <h1 style="font-size: 2rem; font-weight: 700; margin-bottom: 8px;">{{if .EditMode}}{{.T.edit_dossier}}{{else}}{{.T.add_dossier}}{{end}}</h1>
<p style="color: var(--text-muted); font-weight: 300; margin-bottom: 32px;">{{if .EditMode}}Update dossier information{{else}}Create a dossier for a family member{{end}}</p> <p style="color: var(--text-muted); font-weight: 300; margin-bottom: 32px;">{{if .EditMode}}Update dossier information{{else}}Create a dossier for a family member{{end}}</p>
@ -30,9 +28,19 @@
</div> </div>
</div> </div>
{{if or (not .EditMode) (and .EditMode (not .IsSelf))}}
<div class="form-group"> <div class="form-group">
<label>{{.T.i_am_their}}</label> <label>Language</label>
<select name="lang" tabindex="5" class="sg-select">
<option value="">Not set</option>
{{range .Languages}}<option value="{{.Code}}"{{if eq $.EmailLang .Code}} selected{{end}}>{{.Flag}} {{.Name}}</option>
{{end}}
</select>
</div>
{{if or (not .EditMode) (and .EditMode (not .IsSelf))}}
<div style="display: flex; gap: 12px;">
<div class="form-group" style="flex: 1;">
<label>{{.T.relation}}</label>
<select name="relation" required tabindex="5"> <select name="relation" required tabindex="5">
<option value="">{{.T.select_relation}}</option> <option value="">{{.T.select_relation}}</option>
<option value="1"{{if eq .Relation "1"}} selected{{end}}>{{.T.rel_1}}</option> <option value="1"{{if eq .Relation "1"}} selected{{end}}>{{.T.rel_1}}</option>
@ -47,6 +55,15 @@
<option value="10"{{if eq .Relation "10"}} selected{{end}}>{{.T.rel_10}}</option> <option value="10"{{if eq .Relation "10"}} selected{{end}}>{{.T.rel_10}}</option>
</select> </select>
</div> </div>
<div class="form-group" style="flex: 1;">
<label>{{.T.relation_to}}</label>
<select name="relation_to" tabindex="6">
<option value=""{{if not .RelationTo}} selected{{end}}>{{.T.me}} ({{.Dossier.Name}})</option>
{{range .RelationTargets}}<option value="{{.DossierID}}"{{if eq $.RelationTo .DossierID}} selected{{end}}>{{.Name}}</option>
{{end}}
</select>
</div>
</div>
{{end}} {{end}}
<div class="form-group"> <div class="form-group">
@ -83,7 +100,6 @@
</div> </div>
</form> </form>
</div> </div>
</div>
{{template "footer"}} {{template "footer"}}
</div> </div>
@ -104,6 +120,5 @@ document.getElementById('dob').addEventListener('change', function() {
this.setCustomValidity(''); this.setCustomValidity('');
} }
}); });
</script> </script>
{{end}} {{end}}

View File

@ -5,58 +5,58 @@
<div class="profiles-grid" style="grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));"> <div class="profiles-grid" style="grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));">
<!-- Self dossier --> <!-- Self dossier -->
<div class="card sg-profile-card" style="position: relative;"> <div class="card sg-profile-card {{borderLevel .SelfStats.TotalCount}}" style="position: relative;">
<div class="card-actions"> <div class="card-actions">
<a href="/dossier/{{.Dossier.DossierID}}/upload" title="{{.T.upload_files}}">&#8682;</a> <a href="/dossier/{{.Dossier.DossierID}}/upload" title="{{.T.upload_files}}">&#8682;</a>
<a href="/dossier/{{.Dossier.DossierID}}/edit" title="{{.T.edit}}">&#9998;</a> <a href="/dossier/{{.Dossier.DossierID}}/edit" title="{{.T.edit}}">&#9998;</a>
</div> </div>
<a href="/dossier/{{.Dossier.DossierID}}" style="text-decoration: none; color: inherit; display: contents;"> <a href="/dossier/{{.Dossier.DossierID}}" style="text-decoration: none; color: inherit; display: contents;">
<h3>{{.Dossier.Name}}</h3> <div class="profile-header">
<div class="avatar" style="background: {{initialColor .Dossier.DossierID}};">{{initials .Dossier.Name}}</div>
<div>
<h3>{{.Dossier.Name}}{{with langFlag .Dossier.Preferences.Language}} <span class="card-flag">{{.}}</span>{{end}}</h3>
<p class="card-meta">{{.T.you}}</p> <p class="card-meta">{{.T.you}}</p>
<p class="sg-profile-dob">{{printf "%.10s" .Dossier.DateOfBirth}}{{if .Dossier.Sex}} · {{sexT .Dossier.Sex .Lang}}{{end}}</p> </div>
</div>
<p class="sg-profile-dob">{{printf "%.10s" .Dossier.DateOfBirth}}{{with age .Dossier.DateOfBirth}} · {{.}}{{end}}{{if .Dossier.Sex}} · {{sexT .Dossier.Sex .Lang}}{{end}}</p>
<div class="sg-profile-stats"> <div class="sg-profile-stats">
{{if or .SelfStats.Imaging .SelfStats.Labs .SelfStats.Genome .SelfStats.Documents .SelfStats.Vitals .SelfStats.Medications .SelfStats.Supplements}} {{if .SelfStats.Chips}}
{{if .SelfStats.Imaging}}<span class="sg-profile-stat">📷 {{.SelfStats.Imaging}} {{if eq .SelfStats.Imaging 1}}study{{else}}studies{{end}}</span>{{end}} {{range .SelfStats.Chips}}<span class="sg-profile-chip {{.Color}}">{{.Icon}} {{if .Count}}{{.Count}} {{end}}{{.Label}}</span>{{end}}
{{if .SelfStats.Labs}}<span class="sg-profile-stat">🧪 {{.SelfStats.Labs}} {{if eq .SelfStats.Labs 1}}lab{{else}}labs{{end}}</span>{{end}} {{if .SelfStats.OverflowCount}}<span class="sg-profile-chip chip-muted">+{{.SelfStats.OverflowCount}} more</span>{{end}}
{{if .SelfStats.Genome}}<span class="sg-profile-stat">🧬 genome</span>{{end}}
{{if .SelfStats.Documents}}<span class="sg-profile-stat">📄 {{.SelfStats.Documents}} {{if eq .SelfStats.Documents 1}}doc{{else}}docs{{end}}</span>{{end}}
{{if .SelfStats.Vitals}}<span class="sg-profile-stat">❤️ {{.SelfStats.Vitals}} vitals</span>{{end}}
{{if .SelfStats.Medications}}<span class="sg-profile-stat">💊 {{.SelfStats.Medications}} meds</span>{{end}}
{{if .SelfStats.Supplements}}<span class="sg-profile-stat">🌿 {{.SelfStats.Supplements}} supps</span>{{end}}
{{else}} {{else}}
<span class="sg-profile-stat" style="color: var(--text-muted);">No data yet</span> <span class="sg-profile-chip chip-muted">No data yet</span>
{{end}} {{end}}
</div> </div>
<span class="btn btn-primary btn-small" style="margin-top: auto; align-self: flex-start;">{{.T.view}}</span>
</a> </a>
</div> </div>
<!-- Accessible dossiers --> <!-- Accessible dossiers -->
{{range .AccessibleDossiers}} {{range .AccessibleDossiers}}
<div class="card sg-profile-card" style="position: relative;"> {{if .NewGroup}}<hr class="grid-separator">{{end}}
<div class="card sg-profile-card {{borderLevel .Stats.TotalCount}}" style="position: relative;">
{{if .CanEdit}}<div class="card-actions"> {{if .CanEdit}}<div class="card-actions">
<a href="/dossier/{{.DossierID}}/upload" title="{{$.T.upload_files}}">&#8682;</a> <a href="/dossier/{{.DossierID}}/upload" title="{{$.T.upload_files}}">&#8682;</a>
<a href="/dossier/{{.DossierID}}/edit" title="{{$.T.edit}}">&#9998;</a> <a href="/dossier/{{.DossierID}}/edit" title="{{$.T.edit}}">&#9998;</a>
</div>{{end}} </div>{{end}}
{{if eq .RelationInt 99}}<form method="POST" action="/dossier/{{.DossierID}}/revoke" style="position: absolute; top: 16px; right: 16px; margin: 0;" onsubmit="return confirm('Remove demo dossier from your list?')"><input type="hidden" name="accessor_id" value="{{$.Dossier.DossierID}}"><button type="submit" class="edit-link" title="{{$.T.remove}}" style="background: none; border: none; color: var(--text-muted); cursor: pointer; padding: 4px;">&#10005;</button></form>{{end}} {{if eq .RelationInt 99}}<form method="POST" action="/dossier/{{.DossierID}}/revoke" style="position: absolute; top: 16px; right: 16px; margin: 0;" onsubmit="return confirm('Remove demo dossier from your list?')"><input type="hidden" name="accessor_id" value="{{$.Dossier.DossierID}}"><button type="submit" class="edit-link" title="{{$.T.remove}}" style="background: none; border: none; color: var(--text-muted); cursor: pointer; padding: 4px;">&#10005;</button></form>{{end}}
<a href="/dossier/{{.DossierID}}" style="text-decoration: none; color: inherit; display: contents;"> <a href="/dossier/{{.DossierID}}" style="text-decoration: none; color: inherit; display: contents;">
<h3>{{.Name}}</h3> <div class="profile-header">
<div class="avatar" style="background: {{initialColor .DossierID}};">{{initials .Name}}</div>
<div>
<h3>{{.Name}}{{with langFlag .Lang}} <span class="card-flag">{{.}}</span>{{end}}</h3>
<p class="card-meta">{{if eq .RelationInt 99}}{{$.T.role}}: {{.Relation}}{{else}}{{$.T.my_role}}: {{.Relation}}{{if .IsCareReceiver}} · <span class="badge badge-care">{{$.T.care}}</span>{{end}}{{end}}</p> <p class="card-meta">{{if eq .RelationInt 99}}{{$.T.role}}: {{.Relation}}{{else}}{{$.T.my_role}}: {{.Relation}}{{if .IsCareReceiver}} · <span class="badge badge-care">{{$.T.care}}</span>{{end}}{{end}}</p>
<p class="sg-profile-dob">{{printf "%.10s" .DateOfBirth}}</p> {{if .Context}}<p class="card-context">{{.Context}}</p>{{end}}
</div>
</div>
<p class="sg-profile-dob">{{printf "%.10s" .DateOfBirth}}{{with age .DateOfBirth}} · {{.}}{{end}}{{if .Sex}} · {{sexT .Sex $.Lang}}{{end}}</p>
<div class="sg-profile-stats"> <div class="sg-profile-stats">
{{if or .Stats.Imaging .Stats.Labs .Stats.Genome .Stats.Documents .Stats.Vitals .Stats.Medications .Stats.Supplements}} {{if .Stats.Chips}}
{{if .Stats.Imaging}}<span class="sg-profile-stat">📷 {{.Stats.Imaging}} {{if eq .Stats.Imaging 1}}study{{else}}studies{{end}}</span>{{end}} {{range .Stats.Chips}}<span class="sg-profile-chip {{.Color}}">{{.Icon}} {{if .Count}}{{.Count}} {{end}}{{.Label}}</span>{{end}}
{{if .Stats.Labs}}<span class="sg-profile-stat">🧪 {{.Stats.Labs}} {{if eq .Stats.Labs 1}}lab{{else}}labs{{end}}</span>{{end}} {{if .Stats.OverflowCount}}<span class="sg-profile-chip chip-muted">+{{.Stats.OverflowCount}} more</span>{{end}}
{{if .Stats.Genome}}<span class="sg-profile-stat">🧬 genome</span>{{end}}
{{if .Stats.Documents}}<span class="sg-profile-stat">📄 {{.Stats.Documents}} {{if eq .Stats.Documents 1}}doc{{else}}docs{{end}}</span>{{end}}
{{if .Stats.Vitals}}<span class="sg-profile-stat">❤️ {{.Stats.Vitals}} vitals</span>{{end}}
{{if .Stats.Medications}}<span class="sg-profile-stat">💊 {{.Stats.Medications}} meds</span>{{end}}
{{if .Stats.Supplements}}<span class="sg-profile-stat">🌿 {{.Stats.Supplements}} supps</span>{{end}}
{{else}} {{else}}
<span class="sg-profile-stat" style="color: var(--text-muted);">No data yet</span> <span class="sg-profile-chip chip-muted">No data yet</span>
{{end}} {{end}}
</div> </div>
<span class="btn btn-primary btn-small" style="margin-top: auto; align-self: flex-start;">{{$.T.view}}</span>
</a> </a>
</div> </div>
{{end}} {{end}}

View File

@ -1021,7 +1021,10 @@ loadGeneticsCategories();
{{range $item.Children}} {{range $item.Children}}
<div class="data-row child"{{if .Type}} data-loinc="{{.Type}}"{{end}}> <div class="data-row child"{{if .Type}} data-loinc="{{.Type}}"{{end}}>
<span class="data-label detail-key">{{.Label}}</span> <span class="data-label detail-key">{{.Label}}</span>
<div class="data-value-group">
{{if .Value}}<span class="data-value">{{.Value}}</span>{{end}} {{if .Value}}<span class="data-value">{{.Value}}</span>{{end}}
{{if .Meta}}<span class="data-value translated">{{.Meta}}</span>{{end}}
</div>
{{if .LinkURL}}<a href="{{.LinkURL}}" target="_blank" class="btn-icon" title="{{.LinkTitle}}">→</a>{{end}} {{if .LinkURL}}<a href="{{.LinkURL}}" target="_blank" class="btn-icon" title="{{.LinkTitle}}">→</a>{{end}}
</div> </div>
{{end}} {{end}}

View File

@ -763,6 +763,7 @@ type extractedEntry struct {
SearchKey string `json:"search_key,omitempty"` SearchKey string `json:"search_key,omitempty"`
Timestamp string `json:"timestamp,omitempty"` Timestamp string `json:"timestamp,omitempty"`
Data map[string]interface{} `json:"data"` Data map[string]interface{} `json:"data"`
DataTranslated map[string]interface{} `json:"data_translated,omitempty"`
SourceSpans []sourceSpan `json:"source_spans,omitempty"` SourceSpans []sourceSpan `json:"source_spans,omitempty"`
} }
@ -771,6 +772,20 @@ type sourceSpan struct {
End string `json:"end"` End string `json:"end"`
} }
// langName maps ISO 639-1 codes to full language names for LLM prompts.
func langName(code string) string {
names := map[string]string{
"en": "English", "de": "German", "ru": "Russian", "nl": "Dutch",
"fr": "French", "es": "Spanish", "it": "Italian", "pt": "Portuguese",
"ja": "Japanese", "zh": "Chinese", "ko": "Korean", "ar": "Arabic",
"tr": "Turkish", "pl": "Polish", "uk": "Ukrainian",
}
if n, ok := names[code]; ok {
return n
}
return code
}
// extractionPreamble returns common instructions prepended to every extraction prompt. // extractionPreamble returns common instructions prepended to every extraction prompt.
func extractionPreamble(targetLang string) string { func extractionPreamble(targetLang string) string {
s := `IMPORTANT RULES (apply to all entries you return): s := `IMPORTANT RULES (apply to all entries you return):
@ -780,6 +795,7 @@ func extractionPreamble(targetLang string) string {
` `
if targetLang != "" { if targetLang != "" {
s += `- Include "summary_translated": a translation of the summary field into ` + targetLang + `. s += `- Include "summary_translated": a translation of the summary field into ` + targetLang + `.
- Include "data_translated": a copy of the "data" object with all string values translated into ` + targetLang + `. Keep the same keys (in English). Only translate the values.
` `
} }
return s return s
@ -962,7 +978,7 @@ func processDocumentUpload(uploadID, dossierID, filePath, fileName string) {
// Get dossier language for translations // Get dossier language for translations
var targetLang string var targetLang string
if d, err := lib.DossierGet("", dossierID); err == nil && d.Preferences.Language != "" { if d, err := lib.DossierGet("", dossierID); err == nil && d.Preferences.Language != "" {
targetLang = d.Preferences.Language targetLang = langName(d.Preferences.Language)
} }
preamble := extractionPreamble(targetLang) preamble := extractionPreamble(targetLang)
@ -1069,6 +1085,9 @@ func processDocumentUpload(uploadID, dossierID, filePath, fileName string) {
if e.SummaryTranslated != "" { if e.SummaryTranslated != "" {
dataMap["summary_translated"] = e.SummaryTranslated dataMap["summary_translated"] = e.SummaryTranslated
} }
if len(e.DataTranslated) > 0 {
dataMap["data_translated"] = e.DataTranslated
}
dataJSON, _ := json.Marshal(dataMap) dataJSON, _ := json.Marshal(dataMap)
ts := now ts := now

View File

@ -1,52 +0,0 @@
package main
import (
"encoding/base64"
"fmt"
"log"
"os"
"inou/lib"
)
func main() {
if len(os.Args) < 3 {
fmt.Println("Usage: fix-lang <dossierID> <lang>")
fmt.Println(" fix-data <dossierID> - re-encrypt plaintext Data")
os.Exit(1)
}
dossierID := os.Args[1]
lang := os.Args[2]
if err := lib.Init(); err != nil {
log.Fatal("lib.Init:", err)
}
lib.ConfigInit()
if lang == "fix-data" {
// Re-encrypt plaintext Data by reading raw and re-packing
plainJSON := `{"dob":"2020-02-26","sex":2,"lang":"en"}`
packed := lib.Pack([]byte(plainJSON))
encoded := base64.StdEncoding.EncodeToString(packed)
fmt.Printf("Packed Data (%d bytes): %s\n", len(encoded), encoded[:40]+"...")
// Write via raw SQL won't work without db access — need EntryWrite
// Instead, use lib.Save directly
fmt.Println("Use this to update: UPDATE entries SET Data = X'...' WHERE EntryID = ...")
fmt.Printf("Hex: ")
for _, b := range packed {
fmt.Printf("%02X", b)
}
fmt.Println()
return
}
d, err := lib.DossierGet("", dossierID)
if err != nil {
log.Fatal("DossierGet:", err)
}
fmt.Printf("Dossier: %s (%s), current lang: %q\n", d.Name, d.DossierID, d.Preferences.Language)
d.Preferences.Language = lang
if err := lib.DossierWrite(d.DossierID, d); err != nil {
log.Fatal("DossierWrite:", err)
}
fmt.Printf("Set lang to %q\n", lang)
}