From ee40b3a81bd4ee7bdef29be428fc1c6efdbfcd26 Mon Sep 17 00:00:00 2001 From: James Date: Sat, 28 Feb 2026 04:54:58 -0500 Subject: [PATCH] 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 --- Makefile | 5 + TODO.md | 1 + api/api_v2_readings.go | 202 ++++++++++ api/main.go | 3 + import-renpho/main.go | 612 ++++++++++++++++++++++++++++++ portal/dossier_sections.go | 15 +- portal/lang/da.yaml | 3 + portal/lang/de.yaml | 3 + portal/lang/en.yaml | 3 + portal/lang/es.yaml | 3 + portal/lang/fi.yaml | 3 + portal/lang/fr.yaml | 3 + portal/lang/it.yaml | 3 + portal/lang/ja.yaml | 3 + portal/lang/ko.yaml | 3 + portal/lang/nl.yaml | 3 + portal/lang/no.yaml | 3 + portal/lang/pt.yaml | 3 + portal/lang/ru.yaml | 5 + portal/lang/sv.yaml | 3 + portal/lang/zh.yaml | 3 + portal/main.go | 353 +++++++++++++++-- portal/static/style.css | 37 +- portal/templates/add_dossier.tmpl | 155 ++++---- portal/templates/dashboard.tmpl | 62 +-- portal/templates/dossier.tmpl | 5 +- portal/upload.go | 21 +- tools/fix-lang/main.go | 52 --- 28 files changed, 1385 insertions(+), 185 deletions(-) create mode 100644 api/api_v2_readings.go create mode 100644 import-renpho/main.go delete mode 100644 tools/fix-lang/main.go diff --git a/Makefile b/Makefile index 3578aec..76a4df3 100755 --- a/Makefile +++ b/Makefile @@ -49,6 +49,11 @@ import-dicom: $(BINDIR)/import-dicom $(BINDIR)/import-dicom: ./import-dicom/*.go ./lib/*.go | $(BINDIR) $(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: $(BINDIR)/nuke-imaging $(BINDIR)/nuke-imaging: ./nuke-imaging/*.go ./lib/*.go | $(BINDIR) diff --git a/TODO.md b/TODO.md index 608d202..d82baae 100644 --- a/TODO.md +++ b/TODO.md @@ -20,6 +20,7 @@ Before Apple/Google app review, the privacy policy needs these additions: ## 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. - **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 diff --git a/api/api_v2_readings.go b/api/api_v2_readings.go new file mode 100644 index 0000000..842c24b --- /dev/null +++ b/api/api_v2_readings.go @@ -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, " ") +} diff --git a/api/main.go b/api/main.go index 8e4fc6c..44bf263 100644 --- a/api/main.go +++ b/api/main.go @@ -65,6 +65,9 @@ func main() { // V1 API (new design) RegisterV1Routes() + // V2 API + http.HandleFunc("/api/v2/readings", v2Readings) + loadLLMConfig() // RENAMED from loadAnthropicConfig() // Load translations for CategoryTranslate diff --git a/import-renpho/main.go b/import-renpho/main.go new file mode 100644 index 0000000..5155e08 --- /dev/null +++ b/import-renpho/main.go @@ -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 ") + 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") +} + diff --git a/portal/dossier_sections.go b/portal/dossier_sections.go index 6a1dca7..b7aa9a3 100644 --- a/portal/dossier_sections.go +++ b/portal/dossier_sections.go @@ -506,6 +506,7 @@ func entriesToSectionItems(entries []*lib.Entry) []SectionItem { "source": true, "source_spans": true, "summary_translated": true, + "data_translated": true, } var items []SectionItem @@ -531,6 +532,9 @@ func entriesToSectionItems(entries []*lib.Entry) []SectionItem { if e.Data != "" { var dataMap map[string]interface{} 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 var keys []string for k := range dataMap { @@ -550,10 +554,13 @@ func entriesToSectionItems(entries []*lib.Entry) []SectionItem { if val == "" { continue } - item.Children = append(item.Children, SectionItem{ - Label: k, - Value: val, - }) + child := SectionItem{Label: k, Value: val} + if translated != nil { + if tv := formatDataValue(k, translated[k]); tv != "" && tv != val { + child.Meta = tv + } + } + item.Children = append(item.Children, child) } if len(item.Children) > 0 { item.Expandable = true diff --git a/portal/lang/da.yaml b/portal/lang/da.yaml index 25fd527..c688ab7 100644 --- a/portal/lang/da.yaml +++ b/portal/lang/da.yaml @@ -236,3 +236,6 @@ api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any api_authentication: "Authentication" api_auth_instructions: "[EN] Include your API token in the Authorization header:" copy: "Copy" +relation: "Relation" +relation_to: "Til" +me: "Mig" diff --git a/portal/lang/de.yaml b/portal/lang/de.yaml index 4968490..dad7465 100644 --- a/portal/lang/de.yaml +++ b/portal/lang/de.yaml @@ -236,3 +236,6 @@ api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any api_authentication: "Authentication" api_auth_instructions: "[EN] Include your API token in the Authorization header:" copy: "Copy" +relation: "Beziehung" +relation_to: "Zu" +me: "Ich" diff --git a/portal/lang/en.yaml b/portal/lang/en.yaml index f9f702d..b38c730 100644 --- a/portal/lang/en.yaml +++ b/portal/lang/en.yaml @@ -309,3 +309,6 @@ api_token_regenerate_confirm: "This will invalidate your current token. Any conn api_authentication: "Authentication" api_auth_instructions: "Include your API token in the Authorization header:" copy: "Copy" +relation: "Relation" +relation_to: "To" +me: "Me" diff --git a/portal/lang/es.yaml b/portal/lang/es.yaml index e0bc0fa..192fe71 100644 --- a/portal/lang/es.yaml +++ b/portal/lang/es.yaml @@ -236,3 +236,6 @@ api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any api_authentication: "Authentication" api_auth_instructions: "[EN] Include your API token in the Authorization header:" copy: "Copy" +relation: "Relación" +relation_to: "A" +me: "Yo" diff --git a/portal/lang/fi.yaml b/portal/lang/fi.yaml index 1fcf319..5287bde 100644 --- a/portal/lang/fi.yaml +++ b/portal/lang/fi.yaml @@ -236,3 +236,6 @@ api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any api_authentication: "Authentication" api_auth_instructions: "[EN] Include your API token in the Authorization header:" copy: "Copy" +relation: "Suhde" +relation_to: "Suhteessa" +me: "Minä" diff --git a/portal/lang/fr.yaml b/portal/lang/fr.yaml index 6d7a69f..f87b8d8 100644 --- a/portal/lang/fr.yaml +++ b/portal/lang/fr.yaml @@ -236,3 +236,6 @@ api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any api_authentication: "Authentication" api_auth_instructions: "[EN] Include your API token in the Authorization header:" copy: "Copy" +relation: "Relation" +relation_to: "À" +me: "Moi" diff --git a/portal/lang/it.yaml b/portal/lang/it.yaml index efbaf53..1c6228f 100644 --- a/portal/lang/it.yaml +++ b/portal/lang/it.yaml @@ -236,3 +236,6 @@ api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any api_authentication: "Authentication" api_auth_instructions: "[EN] Include your API token in the Authorization header:" copy: "Copy" +relation: "Relazione" +relation_to: "A" +me: "Io" diff --git a/portal/lang/ja.yaml b/portal/lang/ja.yaml index 0db509d..e329271 100644 --- a/portal/lang/ja.yaml +++ b/portal/lang/ja.yaml @@ -236,3 +236,6 @@ api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any api_authentication: "Authentication" api_auth_instructions: "[EN] Include your API token in the Authorization header:" copy: "Copy" +relation: "関係" +relation_to: "宛先" +me: "自分" diff --git a/portal/lang/ko.yaml b/portal/lang/ko.yaml index 61a2015..31c6ed2 100644 --- a/portal/lang/ko.yaml +++ b/portal/lang/ko.yaml @@ -236,3 +236,6 @@ api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any api_authentication: "Authentication" api_auth_instructions: "[EN] Include your API token in the Authorization header:" copy: "Copy" +relation: "관계" +relation_to: "에게" +me: "나" diff --git a/portal/lang/nl.yaml b/portal/lang/nl.yaml index 13b8b7a..5833592 100644 --- a/portal/lang/nl.yaml +++ b/portal/lang/nl.yaml @@ -250,3 +250,6 @@ api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any api_authentication: "Authentication" api_auth_instructions: "[EN] Include your API token in the Authorization header:" copy: "Copy" +relation: "Relatie" +relation_to: "Aan" +me: "Ik" diff --git a/portal/lang/no.yaml b/portal/lang/no.yaml index 8886621..74a09cc 100644 --- a/portal/lang/no.yaml +++ b/portal/lang/no.yaml @@ -236,3 +236,6 @@ api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any api_authentication: "Authentication" api_auth_instructions: "[EN] Include your API token in the Authorization header:" copy: "Copy" +relation: "Relasjon" +relation_to: "Til" +me: "Meg" diff --git a/portal/lang/pt.yaml b/portal/lang/pt.yaml index 7c5b97a..7786604 100644 --- a/portal/lang/pt.yaml +++ b/portal/lang/pt.yaml @@ -236,3 +236,6 @@ api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any api_authentication: "Authentication" api_auth_instructions: "[EN] Include your API token in the Authorization header:" copy: "Copy" +relation: "Relação" +relation_to: "Para" +me: "Eu" diff --git a/portal/lang/ru.yaml b/portal/lang/ru.yaml index 7448cee..eed3cdb 100644 --- a/portal/lang/ru.yaml +++ b/portal/lang/ru.yaml @@ -240,3 +240,8 @@ api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any api_authentication: "Authentication" api_auth_instructions: "[EN] Include your API token in the Authorization header:" copy: "Copy" +save: "Сохранить" +cancel: "Отмена" +relation: "Отношение" +relation_to: "Кому" +me: "Я" diff --git a/portal/lang/sv.yaml b/portal/lang/sv.yaml index b8219ae..de4c482 100644 --- a/portal/lang/sv.yaml +++ b/portal/lang/sv.yaml @@ -236,3 +236,6 @@ api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any api_authentication: "Authentication" api_auth_instructions: "[EN] Include your API token in the Authorization header:" copy: "Copy" +relation: "Relation" +relation_to: "Till" +me: "Jag" diff --git a/portal/lang/zh.yaml b/portal/lang/zh.yaml index 040f10f..ddd061b 100644 --- a/portal/lang/zh.yaml +++ b/portal/lang/zh.yaml @@ -236,3 +236,6 @@ api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any api_authentication: "Authentication" api_auth_instructions: "[EN] Include your API token in the Authorization header:" copy: "Copy" +relation: "关系" +relation_to: "与…的关系" +me: "我" diff --git a/portal/main.go b/portal/main.go index bc55ba7..efbdbf9 100644 --- a/portal/main.go +++ b/portal/main.go @@ -17,6 +17,7 @@ import ( "os" "path/filepath" "strconv" + "sort" "strings" "time" @@ -28,6 +29,7 @@ var ( Version string = "dev" // Set via ldflags at build time templates *template.Template translations map[string]map[string]string + langOptions []LangOption smtpHost, smtpPort, smtpUser, smtpToken, smtpFrom string 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 { 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 { DossierID 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 + NewGroup bool // true if this entry starts a new visual group IsCareReceiver, CanEdit, IsSelf, IsPending bool AccessedAt time.Time Stats DossierStats @@ -137,6 +161,9 @@ type PageData struct { SelectedRole string // Dossier: unified sections Sections []DossierSection + Languages []LangOption + RelationTargets []RelationTarget + RelationTo string } type CategoryAccess struct { @@ -166,6 +193,14 @@ func initDB() { // 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() { translations = make(map[string]map[string]string) files, _ := filepath.Glob(filepath.Join(langDir, "*.yaml")) @@ -183,6 +218,26 @@ func loadTranslations() { } 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 { @@ -228,6 +283,59 @@ func loadTemplates() { } 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"))) } @@ -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 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 { // Try multiple formats var t time.Time @@ -608,7 +724,7 @@ func handleSetLang(w http.ResponseWriter, r *http.Request) { d := getLoggedInDossier(r) if d == nil { http.Redirect(w, r, "/", http.StatusSeeOther); return } lang := r.FormValue("lang") - if _, ok := translations[lang]; ok { + if len(lang) >= 2 && len(lang) <= 5 { d.Preferences.Language = lang 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!"}) } +// 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 { - ctx := &lib.AccessContext{AccessorID: dossierID} // Self-access for dashboard + ctx := &lib.AccessContext{AccessorID: dossierID} var stats DossierStats - stats.Imaging, _ = lib.EntryCount(ctx, dossierID, lib.CategoryImaging, "study") - stats.Labs, _ = lib.EntryCount(ctx, dossierID, lib.CategoryLab, "lab_report") - stats.Genome, _ = lib.EntryCount(ctx, dossierID, lib.CategoryGenome, "tier") - stats.Documents, _ = lib.EntryCount(ctx, dossierID, lib.CategoryDocument, "") - stats.Vitals, _ = lib.EntryCount(ctx, dossierID, lib.CategoryVital, "") - stats.Medications, _ = lib.EntryCount(ctx, dossierID, lib.CategoryMedication, "") - stats.Supplements, _ = lib.EntryCount(ctx, dossierID, lib.CategorySupplement, "") + + // Count all categories, build chips for non-zero ones + var all []StatChip + for _, m := range categoryMeta { + n, _ := lib.EntryCount(ctx, dossierID, m.Category, m.TypeFilter) + if n == 0 { + 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 } @@ -806,14 +988,19 @@ func handleDashboard(w http.ResponseWriter, r *http.Request) { grantMap := map[string]*lib.Access{} for _, g := range grants { grantMap[g.DossierID] = g } + // Build accessible dossier list with names index + nameMap := map[string]string{} // dossierID → name var accessible []AccessEntry for _, entry := range allDossiers { if entry.DossierID == p.DossierID { continue } d := lib.DossierFromEntry(entry) + nameMap[d.DossierID] = d.Name e := AccessEntry{ DossierID: d.DossierID, Name: d.Name, DateOfBirth: d.DateOfBirth, + Sex: d.Sex, + Lang: d.Preferences.Language, Stats: getDossierStats(d.DossierID), } if g := grantMap[d.DossierID]; g != nil { @@ -824,6 +1011,93 @@ func handleDashboard(w http.ResponseWriter, r *http.Request) { } 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}) } @@ -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) { p := getLoggedInDossier(r) if p == nil { http.Redirect(w, r, "/", http.StatusSeeOther); return } lang := getLang(r) + targets := loadRelationTargets(p.DossierID) 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 } @@ -880,12 +1166,18 @@ func handleAddDossier(w http.ResponseWriter, r *http.Request) { dob := r.FormValue("dob") sex := r.FormValue("sex") email := strings.ToLower(strings.TrimSpace(r.FormValue("email"))) - emailLang := r.FormValue("email_lang") + emailLang := r.FormValue("lang") relation := r.FormValue("relation") + relationTo := r.FormValue("relation_to") 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 == "" { - 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 } @@ -902,14 +1194,16 @@ func handleAddDossier(w http.ResponseWriter, r *http.Request) { // Check duplicate: same email already exists if email != "" { 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 } } // Create cat-0 entry for the new dossier 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{ EntryID: newID, DossierID: newID, @@ -921,16 +1215,28 @@ func handleAddDossier(w http.ResponseWriter, r *http.Request) { Timestamp: time.Now().Unix(), } 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 } - // Grant creator access to the new dossier - ops := lib.PermRead | lib.PermWrite - if relationInt == 1 || relationInt == 4 { // parent or guardian - ops = lib.PermRead | lib.PermWrite | lib.PermDelete | lib.PermManage + // 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 + inverted := invertRelation(relationInt) + if inverted == 1 || inverted == 5 { // I am parent or guardian + ops = lib.PermRead | lib.PermWrite | lib.PermDelete | lib.PermManage + } + 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.GrantAccess(newID, p.DossierID, newID, ops, relationInt) + lib.AuditLogFull(p.DossierID, "", newID, "dossier_added", "", relationInt) 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, EditMode: true, EditDossier: target, IsSelf: isSelf, Name: target.Name, DOB: target.DateOfBirth, Sex: sexStr, Email: target.Email, + EmailLang: target.Preferences.Language, Relation: relationStr, IsCareReceiver: false, CanEdit: canEdit, - Error: errMsg, + Languages: langOptions, Error: errMsg, }) return } @@ -1001,8 +1308,9 @@ func handleEditDossier(w http.ResponseWriter, r *http.Request) { Page: "add_dossier", Lang: lang, Dossier: p, EditMode: true, EditDossier: target, IsSelf: isSelf, Name: name, DOB: dob, Sex: sex, Email: email, + EmailLang: r.FormValue("lang"), 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.Email = email + target.Preferences.Language = r.FormValue("lang") if err := lib.DossierWrite(p.DossierID, target); err != nil { renderErr("Failed to update dossier") diff --git a/portal/static/style.css b/portal/static/style.css index 8e09c52..0c9c314 100644 --- a/portal/static/style.css +++ b/portal/static/style.css @@ -1559,15 +1559,29 @@ a:hover { .sg-section-header { border-bottom: 1px solid var(--border); padding-bottom: 12px; margin-bottom: 0; } .sg-card-content { padding: 32px; } .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; } .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: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-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; } +/* 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:hover { background: var(--accent-light); } .sg-supp-dose { font-size: 0.85rem; color: var(--text-muted); } @@ -1698,7 +1712,8 @@ a:hover { /* Profile cards tighter */ .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 */ .sg-llm-option { padding: 12px; } @@ -2043,9 +2058,23 @@ a:hover { min-width: 100px; 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 { color: var(--text); 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 */ diff --git a/portal/templates/add_dossier.tmpl b/portal/templates/add_dossier.tmpl index 10828bf..a1784fe 100644 --- a/portal/templates/add_dossier.tmpl +++ b/portal/templates/add_dossier.tmpl @@ -1,38 +1,46 @@ {{define "add_dossier"}} -
- -
-
-

{{if .EditMode}}{{.T.edit_dossier}}{{else}}{{.T.add_dossier}}{{end}}

-

{{if .EditMode}}Update dossier information{{else}}Create a dossier for a family member{{end}}

- - {{if .Error}} -
{{.Error}}
- {{end}} - -
-
- - +
+
+

{{if .EditMode}}{{.T.edit_dossier}}{{else}}{{.T.add_dossier}}{{end}}

+

{{if .EditMode}}Update dossier information{{else}}Create a dossier for a family member{{end}}

+ + {{if .Error}} +
{{.Error}}
+ {{end}} + + +
+ + +
+ +
+ + + +
+ +
+ +
+ +
- -
- - - -
- -
- -
- - -
-
- - {{if or (not .EditMode) (and .EditMode (not .IsSelf))}} -
- +
+ +
+ + +
+ + {{if or (not .EditMode) (and .EditMode (not .IsSelf))}} +
+
+
- {{end}} - -
- - +
+ +
- - {{if or (not .EditMode) (and .EditMode (not .IsSelf))}} -
- -
-
- -
- {{end}} - - {{if and .ConfirmDuplicate (not .EditMode)}} -
- -
- {{end}} -
- {{.T.cancel}} - -
- -
+
+ {{end}} + +
+ + +
+ + {{if or (not .EditMode) (and .EditMode (not .IsSelf))}} +
+ +
+
+ +
+ {{end}} + + {{if and .ConfirmDuplicate (not .EditMode)}} +
+ +
+ {{end}} +
+ {{.T.cancel}} + +
+
- + {{template "footer"}}
{{end}} \ No newline at end of file diff --git a/portal/templates/dashboard.tmpl b/portal/templates/dashboard.tmpl index 8c755aa..7d16135 100644 --- a/portal/templates/dashboard.tmpl +++ b/portal/templates/dashboard.tmpl @@ -2,72 +2,72 @@

{{.T.dossiers}}

{{.T.dossiers_intro}}

- +
-
+ {{range .AccessibleDossiers}} -
+ {{if .NewGroup}}
{{end}} +
{{if .CanEdit}}{{end}} {{if eq .RelationInt 99}}
{{end}} -

{{.Name}}

-

{{if eq .RelationInt 99}}{{$.T.role}}: {{.Relation}}{{else}}{{$.T.my_role}}: {{.Relation}}{{if .IsCareReceiver}} · {{$.T.care}}{{end}}{{end}}

-

{{printf "%.10s" .DateOfBirth}}

+
+
{{initials .Name}}
+
+

{{.Name}}{{with langFlag .Lang}} {{.}}{{end}}

+

{{if eq .RelationInt 99}}{{$.T.role}}: {{.Relation}}{{else}}{{$.T.my_role}}: {{.Relation}}{{if .IsCareReceiver}} · {{$.T.care}}{{end}}{{end}}

+ {{if .Context}}

{{.Context}}

{{end}} +
+
+

{{printf "%.10s" .DateOfBirth}}{{with age .DateOfBirth}} · {{.}}{{end}}{{if .Sex}} · {{sexT .Sex $.Lang}}{{end}}

- {{if or .Stats.Imaging .Stats.Labs .Stats.Genome .Stats.Documents .Stats.Vitals .Stats.Medications .Stats.Supplements}} - {{if .Stats.Imaging}}📷 {{.Stats.Imaging}} {{if eq .Stats.Imaging 1}}study{{else}}studies{{end}}{{end}} - {{if .Stats.Labs}}🧪 {{.Stats.Labs}} {{if eq .Stats.Labs 1}}lab{{else}}labs{{end}}{{end}} - {{if .Stats.Genome}}🧬 genome{{end}} - {{if .Stats.Documents}}📄 {{.Stats.Documents}} {{if eq .Stats.Documents 1}}doc{{else}}docs{{end}}{{end}} - {{if .Stats.Vitals}}❤️ {{.Stats.Vitals}} vitals{{end}} - {{if .Stats.Medications}}💊 {{.Stats.Medications}} meds{{end}} - {{if .Stats.Supplements}}🌿 {{.Stats.Supplements}} supps{{end}} + {{if .Stats.Chips}} + {{range .Stats.Chips}}{{.Icon}} {{if .Count}}{{.Count}} {{end}}{{.Label}}{{end}} + {{if .Stats.OverflowCount}}+{{.Stats.OverflowCount}} more{{end}} {{else}} - No data yet + No data yet {{end}}
- {{$.T.view}}
{{end}} - + + {{.T.add_dossier}}
- + {{template "footer"}}
{{end}} diff --git a/portal/templates/dossier.tmpl b/portal/templates/dossier.tmpl index 8e3772b..0185b6c 100644 --- a/portal/templates/dossier.tmpl +++ b/portal/templates/dossier.tmpl @@ -1021,7 +1021,10 @@ loadGeneticsCategories(); {{range $item.Children}}
{{.Label}} - {{if .Value}}{{.Value}}{{end}} +
+ {{if .Value}}{{.Value}}{{end}} + {{if .Meta}}{{.Meta}}{{end}} +
{{if .LinkURL}}{{end}}
{{end}} diff --git a/portal/upload.go b/portal/upload.go index ea2287a..010d2e3 100644 --- a/portal/upload.go +++ b/portal/upload.go @@ -763,6 +763,7 @@ type extractedEntry struct { SearchKey string `json:"search_key,omitempty"` Timestamp string `json:"timestamp,omitempty"` Data map[string]interface{} `json:"data"` + DataTranslated map[string]interface{} `json:"data_translated,omitempty"` SourceSpans []sourceSpan `json:"source_spans,omitempty"` } @@ -771,6 +772,20 @@ type sourceSpan struct { 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. func extractionPreamble(targetLang string) string { s := `IMPORTANT RULES (apply to all entries you return): @@ -780,6 +795,7 @@ func extractionPreamble(targetLang string) string { ` if 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 @@ -962,7 +978,7 @@ func processDocumentUpload(uploadID, dossierID, filePath, fileName string) { // Get dossier language for translations var targetLang string if d, err := lib.DossierGet("", dossierID); err == nil && d.Preferences.Language != "" { - targetLang = d.Preferences.Language + targetLang = langName(d.Preferences.Language) } preamble := extractionPreamble(targetLang) @@ -1069,6 +1085,9 @@ func processDocumentUpload(uploadID, dossierID, filePath, fileName string) { if e.SummaryTranslated != "" { dataMap["summary_translated"] = e.SummaryTranslated } + if len(e.DataTranslated) > 0 { + dataMap["data_translated"] = e.DataTranslated + } dataJSON, _ := json.Marshal(dataMap) ts := now diff --git a/tools/fix-lang/main.go b/tools/fix-lang/main.go deleted file mode 100644 index 7cee542..0000000 --- a/tools/fix-lang/main.go +++ /dev/null @@ -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 ") - fmt.Println(" fix-data - 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) -}