From ac6ef9524a729def74c7beec879f84e067529358 Mon Sep 17 00:00:00 2001 From: MoustaphaCamara Date: Thu, 21 May 2026 17:11:02 +0200 Subject: [PATCH 01/30] feat: sync: add tables to enable querying elabftw instances --- .../ProfileSelectorUnlockForm.svelte | 5 +- frontend/src/css/inputs.css | 2 +- sqlite.go | 47 ++++++++++++++++++- 3 files changed, 49 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/ProfileSelector/ProfileSelectorUnlockForm.svelte b/frontend/src/components/ProfileSelector/ProfileSelectorUnlockForm.svelte index da8a6ed..4b7582f 100644 --- a/frontend/src/components/ProfileSelector/ProfileSelectorUnlockForm.svelte +++ b/frontend/src/components/ProfileSelector/ProfileSelectorUnlockForm.svelte @@ -23,13 +23,12 @@
-

Enter your passphrase to continue.

- + Date: Thu, 21 May 2026 17:11:56 +0200 Subject: [PATCH 02/30] lint --- sqlite.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sqlite.go b/sqlite.go index b6af464..317a67a 100644 --- a/sqlite.go +++ b/sqlite.go @@ -15,7 +15,7 @@ func OpenProfileDB(profileDir string) (*sql.DB, error) { } dbPath := filepath.Join(profileDir, "data.sqlite3") - fmt.Println("Opening profile DB:", dbPath) + fmt.Println("Opening profile DB:", dbPath) db, err := sql.Open("sqlite", dbPath) if err != nil { From c93e6b8734c95db60558902993872513422dd5f5 Mon Sep 17 00:00:00 2001 From: MoustaphaCamara Date: Fri, 22 May 2026 10:27:09 +0200 Subject: [PATCH 03/30] sync_instances.go to add & delete elabftw instances to db --- frontend/src/components/MainApp.svelte | 6 +- frontend/wailsjs/go/main/App.d.ts | 6 ++ frontend/wailsjs/go/main/App.js | 12 +++ frontend/wailsjs/go/models.ts | 18 ++++ sqlite.go | 1 - sync_instances.go | 126 +++++++++++++++++++++++++ 6 files changed, 167 insertions(+), 2 deletions(-) create mode 100644 sync_instances.go diff --git a/frontend/src/components/MainApp.svelte b/frontend/src/components/MainApp.svelte index 1f9c87f..587530f 100644 --- a/frontend/src/components/MainApp.svelte +++ b/frontend/src/components/MainApp.svelte @@ -181,12 +181,16 @@
{/each} + {/if}
+ {#if entries.length !== 0} + {/if} + +
- {/if}
{:else if view === 'editor'}
diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 8d810e5..ec7b7fe 100755 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -2,8 +2,12 @@ // This file is automatically generated. DO NOT EDIT import {main} from '../models'; +export function AddElabftwInstance(arg1:string,arg2:string,arg3:string,arg4:boolean):Promise; + export function AddProfile(arg1:string,arg2:string):Promise; +export function DeleteElabftwInstance(arg1:string,arg2:number):Promise; + export function DeleteEntry(arg1:string,arg2:number):Promise; export function DeleteProfile(arg1:string,arg2:string):Promise; @@ -12,6 +16,8 @@ export function GetEntry(arg1:string,arg2:number):Promise; export function GetProfileIndex():Promise; +export function ListElabftwInstances(arg1:string):Promise>; + export function ListEntries(arg1:string):Promise>; export function LockProfile():Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 72c9e4a..d7daa23 100755 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -2,10 +2,18 @@ // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // This file is automatically generated. DO NOT EDIT +export function AddElabftwInstance(arg1, arg2, arg3, arg4) { + return window['go']['main']['App']['AddElabftwInstance'](arg1, arg2, arg3, arg4); +} + export function AddProfile(arg1, arg2) { return window['go']['main']['App']['AddProfile'](arg1, arg2); } +export function DeleteElabftwInstance(arg1, arg2) { + return window['go']['main']['App']['DeleteElabftwInstance'](arg1, arg2); +} + export function DeleteEntry(arg1, arg2) { return window['go']['main']['App']['DeleteEntry'](arg1, arg2); } @@ -22,6 +30,10 @@ export function GetProfileIndex() { return window['go']['main']['App']['GetProfileIndex'](); } +export function ListElabftwInstances(arg1) { + return window['go']['main']['App']['ListElabftwInstances'](arg1); +} + export function ListEntries(arg1) { return window['go']['main']['App']['ListEntries'](arg1); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 74fa275..b4616e4 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -1,5 +1,23 @@ export namespace main { + export class ElabftwInstance { + id: number; + siteUrl: string; + apiKey?: string; + verifyTls: boolean; + + static createFrom(source: any = {}) { + return new ElabftwInstance(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.id = source["id"]; + this.siteUrl = source["siteUrl"]; + this.apiKey = source["apiKey"]; + this.verifyTls = source["verifyTls"]; + } + } export class Entry { id: number; title: string; diff --git a/sqlite.go b/sqlite.go index 317a67a..d31d367 100644 --- a/sqlite.go +++ b/sqlite.go @@ -15,7 +15,6 @@ func OpenProfileDB(profileDir string) (*sql.DB, error) { } dbPath := filepath.Join(profileDir, "data.sqlite3") - fmt.Println("Opening profile DB:", dbPath) db, err := sql.Open("sqlite", dbPath) if err != nil { diff --git a/sync_instances.go b/sync_instances.go new file mode 100644 index 0000000..429ae80 --- /dev/null +++ b/sync_instances.go @@ -0,0 +1,126 @@ +package main + +import ( + "fmt" + "strings" +) + +type ElabftwInstance struct { + ID int64 `json:"id"` + SiteURL string `json:"siteUrl"` + APIKey string `json:"apiKey,omitempty"` + VerifyTLS bool `json:"verifyTls"` +} + +func (a *App) ListElabftwInstances(profileUUID string) ([]ElabftwInstance, error) { + profileUUID, err := a.requireUnlockedProfile(profileUUID) + if err != nil { + return nil, err + } + + pdir, err := profileDir(profileUUID) + if err != nil { + return nil, err + } + + db, err := OpenProfileDB(pdir) + if err != nil { + return nil, fmt.Errorf("open profile db: %w", err) + } + defer db.Close() + + rows, err := db.Query(` + SELECT id, site_url, verify_tls + FROM elabftw_instances + ORDER BY site_url ASC + `) + if err != nil { + return nil, fmt.Errorf("query elabftw instances: %w", err) + } + defer rows.Close() + + out := []ElabftwInstance{} + for rows.Next() { + var inst ElabftwInstance + if err := rows.Scan(&inst.ID, &inst.SiteURL, &inst.VerifyTLS); err != nil { + return nil, fmt.Errorf("scan elabftw instance: %w", err) + } + out = append(out, inst) + } + + return out, rows.Err() +} + +func (a *App) AddElabftwInstance(profileUUID string, siteURL string, apiKey string, verifyTLS bool) (int64, error) { + profileUUID, err := a.requireUnlockedProfile(profileUUID) + if err != nil { + return 0, err + } + + siteURL = strings.TrimSpace(siteURL) + apiKey = strings.TrimSpace(apiKey) + + if siteURL == "" { + return 0, fmt.Errorf("Site URL is empty") + } + if apiKey == "" { + return 0, fmt.Errorf("API key is empty") + } + + pdir, err := profileDir(profileUUID) + if err != nil { + return 0, err + } + + db, err := OpenProfileDB(pdir) + if err != nil { + return 0, fmt.Errorf("open profile db: %w", err) + } + defer db.Close() + + res, err := db.Exec(` + INSERT INTO elabftw_instances (site_url, api_key, verify_tls) + VALUES (?, ?, ?) + `, siteURL, apiKey, verifyTLS) + if err != nil { + return 0, fmt.Errorf("insert elabftw instance: %w", err) + } + + return res.LastInsertId() +} + +func (a *App) DeleteElabftwInstance(profileUUID string, id int64) error { + profileUUID, err := a.requireUnlockedProfile(profileUUID) + if err != nil { + return err + } + if id <= 0 { + return fmt.Errorf("Invalid instance id") + } + + pdir, err := profileDir(profileUUID) + if err != nil { + return err + } + + db, err := OpenProfileDB(pdir) + if err != nil { + return fmt.Errorf("open profile db: %w", err) + } + defer db.Close() + + res, err := db.Exec(`DELETE FROM elabftw_instances WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("delete elabftw instance: %w", err) + } + + n, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("get deleted rows count: %w", err) + } + if n == 0 { + return fmt.Errorf("Instance not found") + } + + return nil +} From 70df27eefee3ede90a796aa52469f2e421470e6a Mon Sep 17 00:00:00 2001 From: MoustaphaCamara Date: Fri, 22 May 2026 10:28:07 +0200 Subject: [PATCH 04/30] encrypt api key --- sync_instances.go | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/sync_instances.go b/sync_instances.go index 429ae80..20ea952 100644 --- a/sync_instances.go +++ b/sync_instances.go @@ -48,7 +48,11 @@ func (a *App) ListElabftwInstances(profileUUID string) ([]ElabftwInstance, error out = append(out, inst) } - return out, rows.Err() + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("rows error: %w", err) + } + + return out, nil } func (a *App) AddElabftwInstance(profileUUID string, siteURL string, apiKey string, verifyTLS bool) (int64, error) { @@ -67,6 +71,11 @@ func (a *App) AddElabftwInstance(profileUUID string, siteURL string, apiKey stri return 0, fmt.Errorf("API key is empty") } + encryptedAPIKey, err := encryptString(a.activeKey, apiKey) + if err != nil { + return 0, fmt.Errorf("encrypt api key: %w", err) + } + pdir, err := profileDir(profileUUID) if err != nil { return 0, err @@ -81,12 +90,17 @@ func (a *App) AddElabftwInstance(profileUUID string, siteURL string, apiKey stri res, err := db.Exec(` INSERT INTO elabftw_instances (site_url, api_key, verify_tls) VALUES (?, ?, ?) - `, siteURL, apiKey, verifyTLS) + `, siteURL, encryptedAPIKey, verifyTLS) if err != nil { return 0, fmt.Errorf("insert elabftw instance: %w", err) } - return res.LastInsertId() + id, err := res.LastInsertId() + if err != nil { + return 0, fmt.Errorf("get instance id: %w", err) + } + + return id, nil } func (a *App) DeleteElabftwInstance(profileUUID string, id int64) error { @@ -109,16 +123,19 @@ func (a *App) DeleteElabftwInstance(profileUUID string, id int64) error { } defer db.Close() - res, err := db.Exec(`DELETE FROM elabftw_instances WHERE id = ?`, id) + res, err := db.Exec(` + DELETE FROM elabftw_instances + WHERE id = ? + `, id) if err != nil { return fmt.Errorf("delete elabftw instance: %w", err) } - n, err := res.RowsAffected() + rowsAffected, err := res.RowsAffected() if err != nil { return fmt.Errorf("get deleted rows count: %w", err) } - if n == 0 { + if rowsAffected == 0 { return fmt.Errorf("Instance not found") } From d65efc7f491077116d7727ec8fd2e68291eb46dd Mon Sep 17 00:00:00 2001 From: MoustaphaCamara Date: Fri, 22 May 2026 10:45:27 +0200 Subject: [PATCH 05/30] interface: add elabftw instances page Add elabftw instance page link to documentation on API --- frontend/src/components/MainApp.svelte | 163 ++++++++++++++++++++++++- frontend/src/css/main.css | 5 + 2 files changed, 164 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/MainApp.svelte b/frontend/src/components/MainApp.svelte index 587530f..86cfe4e 100644 --- a/frontend/src/components/MainApp.svelte +++ b/frontend/src/components/MainApp.svelte @@ -1,7 +1,16 @@
@@ -113,9 +182,12 @@ {#if view === 'index'}

My Entries

Manage your saved entries.

- {:else} + {:else if view === 'editor'}

{entryTitle.trim() || 'Untitled entry'}

Write, edit, and save your entry.

+ {:else} +

eLabFTW instances

+

Add the server you want to sync with

{/if}
@@ -131,6 +203,7 @@ + {#if view === 'index'}
@@ -188,10 +261,92 @@ {/if} - +
+ + {:else if view === 'instances'} +
+
+
+ Add the site URL and your API key to allow communication between the desktop app and your eLabFTW instance. +
+ See the Documentation to learn how to create a new API key. +
+ +
+ + +
+ + +
+ +
+ + +
+ + + +
+ +
+ + +
+ + {#if loading} +
Loading instances...
+ {:else if instances.length === 0} +
+

No eLabFTW instances yet

+

Add one above before pushing entries.

+
+ {:else} +
+ {#each instances as instance (instance.id)} +
+
+ {instance.siteUrl} + + TLS verification: {instance.verifyTls ? 'enabled' : 'disabled'} + +
+ + +
+ {/each} +
+ {/if} +
+ {:else if view === 'editor'}
diff --git a/frontend/src/css/main.css b/frontend/src/css/main.css index ebec76d..7dfa253 100644 --- a/frontend/src/css/main.css +++ b/frontend/src/css/main.css @@ -127,6 +127,11 @@ h2 { font-size: 1.12rem; } +a { + color: var(--info-text); + font-weight: 800; +} + /* Layout helpers */ .container { width: min(1120px, calc(100vw - 3rem)); From 4ec2a0c9033800023b718090215e5363092f3842 Mon Sep 17 00:00:00 2001 From: MoustaphaCamara Date: Fri, 22 May 2026 10:52:58 +0200 Subject: [PATCH 06/30] normalize url --- sync_instances.go | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/sync_instances.go b/sync_instances.go index 20ea952..cf89dd3 100644 --- a/sync_instances.go +++ b/sync_instances.go @@ -55,13 +55,23 @@ func (a *App) ListElabftwInstances(profileUUID string) ([]ElabftwInstance, error return out, nil } +func normalizeElabftwSiteURL(siteURL string) string { + siteURL = strings.TrimSpace(siteURL) + siteURL = strings.TrimRight(siteURL, "/") + return siteURL +} + +func elabftwAPIBaseURL(siteURL string) string { + return normalizeElabftwSiteURL(siteURL) + "/api/v2" +} + func (a *App) AddElabftwInstance(profileUUID string, siteURL string, apiKey string, verifyTLS bool) (int64, error) { profileUUID, err := a.requireUnlockedProfile(profileUUID) if err != nil { return 0, err } - siteURL = strings.TrimSpace(siteURL) + siteURL = normalizeElabftwSiteURL(siteURL) apiKey = strings.TrimSpace(apiKey) if siteURL == "" { From 5e94cdc7927b493f1bf6d90d4a92f7bb19acdf1f Mon Sep 17 00:00:00 2001 From: MoustaphaCamara Date: Fri, 22 May 2026 11:03:20 +0200 Subject: [PATCH 07/30] elabftw client to fetch information & test instance in UI --- elabftw_client.go | 235 +++++++++++++++++++++++++ frontend/src/components/MainApp.svelte | 22 +++ frontend/wailsjs/go/main/App.d.ts | 6 + frontend/wailsjs/go/main/App.js | 12 ++ frontend/wailsjs/go/models.ts | 12 ++ 5 files changed, 287 insertions(+) create mode 100644 elabftw_client.go diff --git a/elabftw_client.go b/elabftw_client.go new file mode 100644 index 0000000..dde364a --- /dev/null +++ b/elabftw_client.go @@ -0,0 +1,235 @@ +/* + * To query eLabFTW API easily +*/ +package main + +import ( + "bytes" + "crypto/tls" + "database/sql" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +type elabftwClientConfig struct { + InstanceID int64 + SiteURL string + APIKey string + VerifyTLS bool +} + +type ElabftwInfo struct { + Raw map[string]any `json:"raw"` +} + +func (a *App) loadElabftwClientConfig(profileUUID string, instanceID int64) (*elabftwClientConfig, error) { + profileUUID, err := a.requireUnlockedProfile(profileUUID) + if err != nil { + return nil, err + } + if instanceID <= 0 { + return nil, fmt.Errorf("Invalid instance id") + } + + pdir, err := profileDir(profileUUID) + if err != nil { + return nil, err + } + + db, err := OpenProfileDB(pdir) + if err != nil { + return nil, fmt.Errorf("open profile db: %w", err) + } + defer db.Close() + + var cfg elabftwClientConfig + var encryptedAPIKey string + + err = db.QueryRow(` + SELECT id, site_url, api_key, verify_tls + FROM elabftw_instances + WHERE id = ? + `, instanceID).Scan( + &cfg.InstanceID, + &cfg.SiteURL, + &encryptedAPIKey, + &cfg.VerifyTLS, + ) + + if err == sql.ErrNoRows { + return nil, fmt.Errorf("eLabFTW instance not found") + } + if err != nil { + return nil, fmt.Errorf("query elabftw instance: %w", err) + } + + apiKey, err := decryptString(a.activeKey, encryptedAPIKey) + if err != nil { + return nil, fmt.Errorf("decrypt api key: %w", err) + } + + cfg.SiteURL = normalizeElabftwSiteURL(cfg.SiteURL) + cfg.APIKey = strings.TrimSpace(apiKey) + + if cfg.SiteURL == "" { + return nil, fmt.Errorf("eLabFTW site URL is empty") + } + if cfg.APIKey == "" { + return nil, fmt.Errorf("eLabFTW API key is empty") + } + + return &cfg, nil +} + +func elabftwHTTPClient(verifyTLS bool) *http.Client { + transport := &http.Transport{ + TLSClientConfig: &tls.Config{ + // Needed for local/self-signed HTTPS dev instances. + // Only false when the user explicitly disabled TLS verification. + InsecureSkipVerify: !verifyTLS, + }, + } + + return &http.Client{ + Timeout: 30 * time.Second, + Transport: transport, + } +} + +func (a *App) elabftwRequest( + profileUUID string, + instanceID int64, + method string, + apiPath string, + body io.Reader, +) (*http.Response, error) { + cfg, err := a.loadElabftwClientConfig(profileUUID, instanceID) + if err != nil { + return nil, err + } + + apiPath = "/" + strings.TrimLeft(apiPath, "/") + url := elabftwAPIBaseURL(cfg.SiteURL) + apiPath + + req, err := http.NewRequest(method, url, body) + if err != nil { + return nil, fmt.Errorf("create elabftw request: %w", err) + } + + req.Header.Set("Authorization", cfg.APIKey) + req.Header.Set("Accept", "application/json") + + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := elabftwHTTPClient(cfg.VerifyTLS).Do(req) + if err != nil { + return nil, fmt.Errorf("call elabftw %s %s: %w", method, apiPath, err) + } + + return resp, nil +} + +func closeResponseBody(resp *http.Response) { + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } +} + +func decodeElabftwJSONResponse(resp *http.Response, target any) error { + defer closeResponseBody(resp) + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + msg := strings.TrimSpace(string(body)) + if msg == "" { + return fmt.Errorf("elabftw returned HTTP %d", resp.StatusCode) + } + return fmt.Errorf("elabftw returned HTTP %d: %s", resp.StatusCode, msg) + } + + if target == nil { + return nil + } + + if err := json.NewDecoder(resp.Body).Decode(target); err != nil { + return fmt.Errorf("decode elabftw response: %w", err) + } + + return nil +} + +func jsonBody(v any) (*bytes.Reader, error) { + b, err := json.Marshal(v) + if err != nil { + return nil, fmt.Errorf("marshal json body: %w", err) + } + return bytes.NewReader(b), nil +} + +/* ---------- INFO ENDPOINT ---------- */ +func (a *App) FetchElabftwInfo(profileUUID string, instanceID int64) (*ElabftwInfo, error) { + resp, err := a.elabftwRequest(profileUUID,instanceID,http.MethodGet,"/info",nil) + if err != nil { + return nil, err + } + + var out ElabftwInfo + out.Raw = map[string]any{} + + if err := decodeElabftwJSONResponse(resp, &out.Raw); err != nil { + return nil, err + } + + return &out, nil +} +/* ---------- EXP ENDPOINT ---------- */ +func (a *App) FetchElabftwExperiment(profileUUID string, instanceID int64, remoteID int64) (map[string]any, error) { + resp, err := a.elabftwRequest( + profileUUID, + instanceID, + http.MethodGet, + fmt.Sprintf("/experiments", remoteID), + nil, + ) + if err != nil { + return nil, err + } + + var out map[string]any + if err := decodeElabftwJSONResponse(resp, &out); err != nil { + return nil, err + } + + return out, nil +} +/* POC post experiments */ +func (a *App) CreateElabftwExperiment(profileUUID string, instanceID int64, payload any) (map[string]any, error) { + body, err := jsonBody(payload) + if err != nil { + return nil, err + } + + resp, err := a.elabftwRequest( + profileUUID, + instanceID, + http.MethodPost, + "/experiments", + body, + ) + if err != nil { + return nil, err + } + + var out map[string]any + if err := decodeElabftwJSONResponse(resp, &out); err != nil { + return nil, err + } + + return out, nil +} diff --git a/frontend/src/components/MainApp.svelte b/frontend/src/components/MainApp.svelte index 86cfe4e..9dfaacd 100644 --- a/frontend/src/components/MainApp.svelte +++ b/frontend/src/components/MainApp.svelte @@ -10,6 +10,7 @@ ListElabftwInstances, AddElabftwInstance, DeleteElabftwInstance, + FetchElabftwInfo } from '../../wailsjs/go/main/App'; import type { main } from '../../wailsjs/go/models'; import { autofocus, errorMessage, preventDefaultSubmit } from '../utils/helpers'; @@ -173,6 +174,20 @@ } const handleInstanceSubmit = preventDefaultSubmit(addInstance); + + // test elabftw instances + + async function testInstance(id: number): Promise { + alert = { type: 'info', message: 'Fetching eLabFTW /info...' }; + + try { + const info = await FetchElabftwInfo(profileUuid, id); + console.log('eLabFTW /info:', info.raw); + alert = { type: 'success', message: 'Connected to eLabFTW ✔' }; + } catch (e: unknown) { + alert = { type: 'error', message: errorMessage(e) }; + } + }
@@ -334,6 +349,8 @@
+
+ + +
{/each} {/if} +
{:else if view === 'editor'} diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index ec7b7fe..94a716f 100755 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -6,12 +6,18 @@ export function AddElabftwInstance(arg1:string,arg2:string,arg3:string,arg4:bool export function AddProfile(arg1:string,arg2:string):Promise; +export function CreateElabftwExperiment(arg1:string,arg2:number,arg3:any):Promise>; + export function DeleteElabftwInstance(arg1:string,arg2:number):Promise; export function DeleteEntry(arg1:string,arg2:number):Promise; export function DeleteProfile(arg1:string,arg2:string):Promise; +export function FetchElabftwExperiment(arg1:string,arg2:number,arg3:number):Promise>; + +export function FetchElabftwInfo(arg1:string,arg2:number):Promise; + export function GetEntry(arg1:string,arg2:number):Promise; export function GetProfileIndex():Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index d7daa23..dbd2dcb 100755 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -10,6 +10,10 @@ export function AddProfile(arg1, arg2) { return window['go']['main']['App']['AddProfile'](arg1, arg2); } +export function CreateElabftwExperiment(arg1, arg2, arg3) { + return window['go']['main']['App']['CreateElabftwExperiment'](arg1, arg2, arg3); +} + export function DeleteElabftwInstance(arg1, arg2) { return window['go']['main']['App']['DeleteElabftwInstance'](arg1, arg2); } @@ -22,6 +26,14 @@ export function DeleteProfile(arg1, arg2) { return window['go']['main']['App']['DeleteProfile'](arg1, arg2); } +export function FetchElabftwExperiment(arg1, arg2, arg3) { + return window['go']['main']['App']['FetchElabftwExperiment'](arg1, arg2, arg3); +} + +export function FetchElabftwInfo(arg1, arg2) { + return window['go']['main']['App']['FetchElabftwInfo'](arg1, arg2); +} + export function GetEntry(arg1, arg2) { return window['go']['main']['App']['GetEntry'](arg1, arg2); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index b4616e4..03ddff5 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -1,5 +1,17 @@ export namespace main { + export class ElabftwInfo { + raw: Record; + + static createFrom(source: any = {}) { + return new ElabftwInfo(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.raw = source["raw"]; + } + } export class ElabftwInstance { id: number; siteUrl: string; From 1130461c60af87b8b2310b36a884d4425858363c Mon Sep 17 00:00:00 2001 From: MoustaphaCamara Date: Fri, 22 May 2026 11:05:59 +0200 Subject: [PATCH 08/30] test info endpoint with many instances :D --- elabftw_client.go | 6 ++++-- frontend/src/components/MainApp.svelte | 11 ++++++++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/elabftw_client.go b/elabftw_client.go index dde364a..4bc592f 100644 --- a/elabftw_client.go +++ b/elabftw_client.go @@ -1,6 +1,6 @@ /* * To query eLabFTW API easily -*/ + */ package main import ( @@ -174,7 +174,7 @@ func jsonBody(v any) (*bytes.Reader, error) { /* ---------- INFO ENDPOINT ---------- */ func (a *App) FetchElabftwInfo(profileUUID string, instanceID int64) (*ElabftwInfo, error) { - resp, err := a.elabftwRequest(profileUUID,instanceID,http.MethodGet,"/info",nil) + resp, err := a.elabftwRequest(profileUUID, instanceID, http.MethodGet, "/info", nil) if err != nil { return nil, err } @@ -188,6 +188,7 @@ func (a *App) FetchElabftwInfo(profileUUID string, instanceID int64) (*ElabftwIn return &out, nil } + /* ---------- EXP ENDPOINT ---------- */ func (a *App) FetchElabftwExperiment(profileUUID string, instanceID int64, remoteID int64) (map[string]any, error) { resp, err := a.elabftwRequest( @@ -208,6 +209,7 @@ func (a *App) FetchElabftwExperiment(profileUUID string, instanceID int64, remot return out, nil } + /* POC post experiments */ func (a *App) CreateElabftwExperiment(profileUUID string, instanceID int64, payload any) (map[string]any, error) { body, err := jsonBody(payload) diff --git a/frontend/src/components/MainApp.svelte b/frontend/src/components/MainApp.svelte index 9dfaacd..98f620b 100644 --- a/frontend/src/components/MainApp.svelte +++ b/frontend/src/components/MainApp.svelte @@ -38,6 +38,8 @@ let instanceSiteUrl = $state(''); let instanceApiKey = $state(''); let instanceVerifyTls = $state(true); + // test info endpoint + let elabftwInfoOutput = $state(''); function toRelativeTime(iso: string, locale = 'en'): string { return DateTime.fromISO(iso).setLocale(locale).toRelative() ?? 'now'; @@ -179,10 +181,11 @@ async function testInstance(id: number): Promise { alert = { type: 'info', message: 'Fetching eLabFTW /info...' }; + elabftwInfoOutput = ''; try { const info = await FetchElabftwInfo(profileUuid, id); - console.log('eLabFTW /info:', info.raw); + elabftwInfoOutput = JSON.stringify(info.raw, null, 2); alert = { type: 'success', message: 'Connected to eLabFTW ✔' }; } catch (e: unknown) { alert = { type: 'error', message: errorMessage(e) }; @@ -364,6 +367,12 @@ {/each} + {#if elabftwInfoOutput} +
+

eLabFTW /info response

+
{elabftwInfoOutput}
+
+ {/if} {/if} From 0e820e8128028aace1356f1f8d326309f486ceaa Mon Sep 17 00:00:00 2001 From: MoustaphaCamara Date: Fri, 22 May 2026 11:25:01 +0200 Subject: [PATCH 09/30] style checkbox --- frontend/src/components/MainApp.svelte | 5 ++-- frontend/src/css/inputs.css | 35 ++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/MainApp.svelte b/frontend/src/components/MainApp.svelte index 98f620b..f97e995 100644 --- a/frontend/src/components/MainApp.svelte +++ b/frontend/src/components/MainApp.svelte @@ -320,9 +320,10 @@ /> -
{/if} {#if pushModalOpen} - + + + + + + + + {/if} {#if alert} diff --git a/frontend/src/components/Modal.svelte b/frontend/src/components/Modal.svelte new file mode 100644 index 0000000..7364232 --- /dev/null +++ b/frontend/src/components/Modal.svelte @@ -0,0 +1,37 @@ + + + diff --git a/frontend/src/css/main.css b/frontend/src/css/main.css index 703c6dc..ced937f 100644 --- a/frontend/src/css/main.css +++ b/frontend/src/css/main.css @@ -260,3 +260,7 @@ a { .modal { width: min(520px, calc(100vw - 2rem)); } + +.modal h1 { + font-size: 2rem; +} From 93362f5c96b7a09536583e13fdfcca58613724c5 Mon Sep 17 00:00:00 2001 From: MoustaphaCamara Date: Wed, 3 Jun 2026 10:22:55 +0200 Subject: [PATCH 18/30] feat: UPDATE (add update Entry & also update to eLabFTW existing entry now works) --- app.go | 58 ++++++++++++++++++++++++++ frontend/src/components/MainApp.svelte | 15 +++++-- frontend/wailsjs/go/main/App.d.ts | 2 + frontend/wailsjs/go/main/App.js | 4 ++ frontend/wailsjs/go/models.ts | 32 +++++++------- 5 files changed, 92 insertions(+), 19 deletions(-) diff --git a/app.go b/app.go index df43f39..c56ad08 100644 --- a/app.go +++ b/app.go @@ -289,6 +289,64 @@ func (a *App) SaveEntry(profileUUID string, title string, body string) (int64, e return id, nil } +func (a *App) UpdateEntry(profileUUID string, id int64, title string, body string) error { + profileUUID, err := a.requireUnlockedProfile(profileUUID) + if err != nil { + return err + } + if id <= 0 { + return fmt.Errorf("Invalid id") + } + + pdir, err := profileDir(profileUUID) + if err != nil { + return err + } + + db, err := OpenProfileDB(pdir) + if err != nil { + return fmt.Errorf("Open profile db: %w", err) + } + defer func() { _ = db.Close() }() + + title = strings.TrimSpace(title) + body = strings.TrimSpace(body) + if title == "" { + return fmt.Errorf("Title is empty") + } + + encryptedTitle, err := encryptString(a.activeKey, title) + if err != nil { + return fmt.Errorf("Encrypt title: %w", err) + } + + encryptedBody, err := encryptString(a.activeKey, body) + if err != nil { + return fmt.Errorf("Encrypt body: %w", err) + } + + res, err := db.Exec(` + UPDATE entries + SET title = ?, + body = ?, + updated_at = (strftime('%Y-%m-%dT%H:%M:%fZ','now')) + WHERE id = ? + `, encryptedTitle, encryptedBody, id) + if err != nil { + return fmt.Errorf("Update entry: %w", err) + } + + rowsAffected, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("get updated rows count: %w", err) + } + if rowsAffected == 0 { + return fmt.Errorf("Entry not found") + } + + return nil +} + func (a *App) DeleteEntry(profileUUID string, id int64) error { profileUUID, err := a.requireUnlockedProfile(profileUUID) if err != nil { diff --git a/frontend/src/components/MainApp.svelte b/frontend/src/components/MainApp.svelte index b554f0c..0c1f626 100644 --- a/frontend/src/components/MainApp.svelte +++ b/frontend/src/components/MainApp.svelte @@ -5,6 +5,7 @@ ListEntries, GetEntry, SaveEntry, + UpdateEntry, DeleteEntry, LockProfile, ListElabftwInstances, @@ -107,11 +108,19 @@ currentEntryId = null; } + // Save an entry // Update an existing entry async function saveEntry(): Promise { - alert = { type: 'info', message: 'Saving...' }; + alert = { type: 'info', message: currentEntryId ? 'Updating...' : 'Saving...' }; try { - const id = await SaveEntry(profileUuid, entryTitle, entryMainText); - alert = { type: 'success', message: `Saved with id ${id} ✔` }; + if (currentEntryId) { + await UpdateEntry(profileUuid, currentEntryId, entryTitle, entryMainText); + alert = { type: 'success', message: 'Entry updated ✔' }; + } else { + const id = await SaveEntry(profileUuid, entryTitle, entryMainText); + currentEntryId = id; + alert = { type: 'success', message: `Saved with id ${id} ✔` }; + } + await refreshEntries(); } catch (e: unknown) { alert = { type: 'error', message: errorMessage(e) }; diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 578f015..15fc42f 100755 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -37,3 +37,5 @@ export function SaveEntry(arg1:string,arg2:string,arg3:string):Promise; export function UnlockProfile(arg1:string,arg2:string):Promise; export function UpdateElabftwInstance(arg1:string,arg2:number,arg3:string,arg4:string,arg5:boolean):Promise; + +export function UpdateEntry(arg1:string,arg2:number,arg3:string,arg4:string):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 351209d..83caf53 100755 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -73,3 +73,7 @@ export function UnlockProfile(arg1, arg2) { export function UpdateElabftwInstance(arg1, arg2, arg3, arg4, arg5) { return window['go']['main']['App']['UpdateElabftwInstance'](arg1, arg2, arg3, arg4, arg5); } + +export function UpdateEntry(arg1, arg2, arg3, arg4) { + return window['go']['main']['App']['UpdateEntry'](arg1, arg2, arg3, arg4); +} diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 84bac63..ee70d70 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -1,12 +1,12 @@ export namespace main { - + export class ElabftwInfo { raw: Record; - + static createFrom(source: any = {}) { return new ElabftwInfo(source); } - + constructor(source: any = {}) { if ('string' === typeof source) source = JSON.parse(source); this.raw = source["raw"]; @@ -17,11 +17,11 @@ export namespace main { siteUrl: string; apiKey?: string; verifyTls: boolean; - + static createFrom(source: any = {}) { return new ElabftwInstance(source); } - + constructor(source: any = {}) { if ('string' === typeof source) source = JSON.parse(source); this.id = source["id"]; @@ -36,11 +36,11 @@ export namespace main { body: string; createdAt: string; updatedAt: string; - + static createFrom(source: any = {}) { return new Entry(source); } - + constructor(source: any = {}) { if ('string' === typeof source) source = JSON.parse(source); this.id = source["id"]; @@ -55,11 +55,11 @@ export namespace main { title: string; createdAt: string; updatedAt: string; - + static createFrom(source: any = {}) { return new EntrySummary(source); } - + constructor(source: any = {}) { if ('string' === typeof source) source = JSON.parse(source); this.id = source["id"]; @@ -74,11 +74,11 @@ export namespace main { created_at: string; salt?: string; encrypted_verifier?: string; - + static createFrom(source: any = {}) { return new ProfileEntry(source); } - + constructor(source: any = {}) { if ('string' === typeof source) source = JSON.parse(source); this.uuid = source["uuid"]; @@ -91,17 +91,17 @@ export namespace main { export class ProfileIndex { version: number; profiles: ProfileEntry[]; - + static createFrom(source: any = {}) { return new ProfileIndex(source); } - + constructor(source: any = {}) { if ('string' === typeof source) source = JSON.parse(source); this.version = source["version"]; this.profiles = this.convertValues(source["profiles"], ProfileEntry); } - + convertValues(a: any, classs: any, asMap: boolean = false): any { if (!a) { return a; @@ -125,11 +125,11 @@ export namespace main { remoteId: number; action: string; type: string; - + static createFrom(source: any = {}) { return new PushEntryResult(source); } - + constructor(source: any = {}) { if ('string' === typeof source) source = JSON.parse(source); this.localId = source["localId"]; From a821e41960af5ebb52d15141a524d0cc8c7eb644 Mon Sep 17 00:00:00 2001 From: MoustaphaCamara Date: Wed, 3 Jun 2026 11:04:05 +0200 Subject: [PATCH 19/30] refactor: move instances related code into separated components --- .../Instances/InstancesPushModal.svelte | 77 ++++ .../components/Instances/InstancesView.svelte | 204 ++++++++++ frontend/src/components/MainApp.svelte | 352 +++--------------- 3 files changed, 335 insertions(+), 298 deletions(-) create mode 100644 frontend/src/components/Instances/InstancesPushModal.svelte create mode 100644 frontend/src/components/Instances/InstancesView.svelte diff --git a/frontend/src/components/Instances/InstancesPushModal.svelte b/frontend/src/components/Instances/InstancesPushModal.svelte new file mode 100644 index 0000000..acd74db --- /dev/null +++ b/frontend/src/components/Instances/InstancesPushModal.svelte @@ -0,0 +1,77 @@ + + + + {#if loading} +

Loading eLabFTW instances...

+ {:else if instances.length > 1} + + + {:else if instances.length === 1} +

Instance: {instances[0].siteUrl}

+ {:else} +

No eLabFTW instances configured.

+ {/if} + + + + + + + + +
+ diff --git a/frontend/src/components/Instances/InstancesView.svelte b/frontend/src/components/Instances/InstancesView.svelte new file mode 100644 index 0000000..e5d71e0 --- /dev/null +++ b/frontend/src/components/Instances/InstancesView.svelte @@ -0,0 +1,204 @@ + + +
+
+ + Add the site URL and your API key to allow communication between the desktop app and your eLabFTW instance. +
+ See the Documentation to learn how to create a new API key. +
+ + +
+ +
+
+ + +
+ +
+ + +
+ + + +
+ {#if editingInstanceId} + + {/if} + + +
+
+ +
+ + {#if loading} +
Loading instances...
+ {:else if instances.length === 0} +
+

No eLabFTW instances yet

+

Add one above before pushing entries.

+
+ {:else} +
+ {#each instances as instance (instance.id)} +
+
+ {instance.siteUrl} + + TLS verification: {instance.verifyTls ? 'enabled' : 'disabled'} + +
+ +
+ + + +
+
+ {/each} + + {#if elabftwInfoOutput} +
+

eLabFTW /info response

+
{elabftwInfoOutput}
+
+ {/if} +
+ {/if} +
diff --git a/frontend/src/components/MainApp.svelte b/frontend/src/components/MainApp.svelte index 0c1f626..1711a45 100644 --- a/frontend/src/components/MainApp.svelte +++ b/frontend/src/components/MainApp.svelte @@ -8,19 +8,15 @@ UpdateEntry, DeleteEntry, LockProfile, - ListElabftwInstances, - AddElabftwInstance, - UpdateElabftwInstance, - DeleteElabftwInstance, - FetchElabftwInfo, PushEntryToElabftw, - PushAllEntriesToElabftw + PushAllEntriesToElabftw, } from '../../wailsjs/go/main/App'; import type { main } from '../../wailsjs/go/models'; import { autofocus, errorMessage, preventDefaultSubmit } from '../utils/helpers'; import Alert from './Alert.svelte'; import type { AlertState } from './Alert.svelte'; - import Modal from './Modal.svelte'; + import InstancesView from './Instances/InstancesView.svelte'; + import InstancesPushModal from './Instances/InstancesPushModal.svelte'; type Props = { profileUuid: string; @@ -38,22 +34,9 @@ let view = $state('index'); let loading = $state(false); let alert = $state(null); - // elabftw instances - let instances = $state([]); - let instanceSiteUrl = $state(''); - let instanceApiKey = $state(''); - let instanceVerifyTls = $state(true); - // test info endpoint - let elabftwInfoOutput = $state(''); - // update elabftw instance - let editingInstanceId = $state(null); - // push entries to elabftw - // TODO: separate into components because main app is handling everything right now let currentEntryId = $state(null); let pushModalOpen = $state(false); let pushMode = $state<'single' | 'all'>('single'); - let pushEntityType = $state<'experiment' | 'resource'>('experiment'); - let pushInstanceId = $state(null); let pushEntryId = $state(null); function toRelativeTime(iso: string, locale = 'en'): string { @@ -69,7 +52,7 @@ entryMainText = e.body; view = 'editor'; } catch (e: unknown) { - alert = { type: 'error', message: errorMessage(e) }; + alert = {type: 'error', message: errorMessage(e)}; } } @@ -79,7 +62,7 @@ entries = await ListEntries(profileUuid); } catch (e: unknown) { console.error(e); - alert = { type: 'error', message: errorMessage(e) }; + alert = {type: 'error', message: errorMessage(e)}; } finally { loading = false; } @@ -96,7 +79,7 @@ await LockProfile(); onLogout?.(); } catch (e: unknown) { - alert = { type: 'error', message: errorMessage(e) }; + alert = {type: 'error', message: errorMessage(e)}; } } @@ -110,20 +93,20 @@ // Save an entry // Update an existing entry async function saveEntry(): Promise { - alert = { type: 'info', message: currentEntryId ? 'Updating...' : 'Saving...' }; + alert = {type: 'info', message: currentEntryId ? 'Updating...' : 'Saving...'}; try { if (currentEntryId) { await UpdateEntry(profileUuid, currentEntryId, entryTitle, entryMainText); - alert = { type: 'success', message: 'Entry updated ✔' }; + alert = {type: 'success', message: 'Entry updated ✔'}; } else { const id = await SaveEntry(profileUuid, entryTitle, entryMainText); currentEntryId = id; - alert = { type: 'success', message: `Saved with id ${id} ✔` }; + alert = {type: 'success', message: `Saved with id ${id} ✔`}; } await refreshEntries(); } catch (e: unknown) { - alert = { type: 'error', message: errorMessage(e) }; + alert = {type: 'error', message: errorMessage(e)}; } } @@ -137,7 +120,7 @@ await DeleteEntry(profileUuid, id); await refreshEntries(); } catch (e: unknown) { - alert = { type: 'error', message: errorMessage(e) }; + alert = {type: 'error', message: errorMessage(e)}; } } @@ -147,122 +130,16 @@ void refreshEntries(); }); - // elabftw instances - - async function refreshInstances(): Promise { - loading = true; - try { - instances = await ListElabftwInstances(profileUuid); - } catch (e: unknown) { - alert = { type: 'error', message: errorMessage(e) }; - } finally { - loading = false; - } - } - - async function openInstances(): Promise { + function openInstances(): void { alert = null; - await refreshInstances(); view = 'instances'; } - async function saveInstance(): Promise { - alert = { type: 'info', message: editingInstanceId ? 'Updating instance...' : 'Adding instance...' }; - - try { - if (editingInstanceId) { - await UpdateElabftwInstance( - profileUuid, - editingInstanceId, - instanceSiteUrl, - instanceApiKey, - instanceVerifyTls, - ); - - alert = { type: 'success', message: 'eLabFTW instance updated ✔' }; - } else { - await AddElabftwInstance( - profileUuid, - instanceSiteUrl, - instanceApiKey, - instanceVerifyTls, - ); - - alert = { type: 'success', message: 'eLabFTW instance added ✔' }; - } - - editingInstanceId = null; - instanceSiteUrl = ''; - instanceApiKey = ''; - instanceVerifyTls = true; - - await refreshInstances(); - } catch (e: unknown) { - alert = { type: 'error', message: errorMessage(e) }; - } - } - - const handleInstanceSubmit = preventDefaultSubmit(saveInstance); - - async function deleteInstance(id: number, siteUrl: string): Promise { - const confirmed = window.confirm(`Delete eLabFTW instance "${siteUrl}"?`); - if (!confirmed) return; - - try { - await DeleteElabftwInstance(profileUuid, id); - await refreshInstances(); - } catch (e: unknown) { - alert = { type: 'error', message: errorMessage(e) }; - } - } - - // const handleInstanceSubmit = preventDefaultSubmit(addInstance); - - // test elabftw instances - - async function testInstance(id: number): Promise { - alert = { type: 'info', message: 'Fetching eLabFTW /info...' }; - elabftwInfoOutput = ''; - - try { - const info = await FetchElabftwInfo(profileUuid, id); - elabftwInfoOutput = JSON.stringify(info.raw, null, 2); - alert = { type: 'success', message: 'Connected to eLabFTW ✔' }; - } catch (e: unknown) { - alert = { type: 'error', message: errorMessage(e) }; - } - } - - // TODO: move all instance related to another component - function editInstance(instance: main.ElabftwInstance): void { - editingInstanceId = instance.id; - instanceSiteUrl = instance.siteUrl; - instanceApiKey = ''; - instanceVerifyTls = instance.verifyTls; - alert = { - type: 'info', - message: 'Editing instance. Leave API key empty to keep the current key.', - }; - } - - function cancelEditInstance(): void { - editingInstanceId = null; - instanceSiteUrl = ''; - instanceApiKey = ''; - instanceVerifyTls = true; - alert = null; - } - // modal helpers - async function openPushModal(mode: 'single' | 'all', entryId: number | null = null): Promise { + function openPushModal(mode: 'single' | 'all', entryId: number | null = null): void { pushMode = mode; pushEntryId = entryId; - pushEntityType = 'experiment'; alert = null; - - await refreshInstances(); - - pushInstanceId = instances.length === 1 ? instances[0].id : null; pushModalOpen = true; } @@ -270,29 +147,27 @@ pushModalOpen = false; } - async function confirmPush(): Promise { - if (!pushInstanceId) { - alert = { type: 'error', message: 'Select an eLabFTW instance.' }; - return; - } - + async function confirmPush(instanceId: number, entityType: 'experiment' | 'resource'): Promise { try { if (pushMode === 'all') { - const results = await PushAllEntriesToElabftw(profileUuid, pushInstanceId, pushEntityType); - alert = { type: 'success', message: `Pushed ${results.length} entries ✔` }; + const results = await PushAllEntriesToElabftw(profileUuid, instanceId, entityType); + alert = {type: 'success', message: `Pushed ${results.length} entries ✔`}; } else { if (!pushEntryId) { - alert = { type: 'error', message: 'No entry selected.' }; + alert = {type: 'error', message: 'No entry selected.'}; return; } - const result = await PushEntryToElabftw(profileUuid, pushEntryId, pushInstanceId, pushEntityType); - alert = { type: 'success', message: `Entry ${result.action} as ${result.type} #${result.remoteId} ✔` }; + const result = await PushEntryToElabftw(profileUuid, pushEntryId, instanceId, entityType); + alert = { + type: 'success', + message: `Entry ${result.action} as ${result.type} #${result.remoteId} ✔`, + }; } pushModalOpen = false; } catch (e: unknown) { - alert = { type: 'error', message: errorMessage(e) }; + alert = {type: 'error', message: errorMessage(e)}; } } @@ -316,7 +191,7 @@
{profileName.slice(0, 2).toUpperCase()} - {profileName} + {profileName}
- + +
{/each} {/if} - -
- {#if entries.length !== 0} +
+ {#if entries.length !== 0} - {/if} - - + {/if} + + -
+
{:else if view === 'instances'} -
-
- - Add the site URL and your API key to allow communication between the desktop app and your eLabFTW instance. -
- See the Documentation to learn how to create a new API key. -
- -
- -
-
- - -
- -
- - -
- - - -
- {#if editingInstanceId} - - {/if} - - -
-
- -
- - {#if loading} -
Loading instances...
- {:else if instances.length === 0} -
-

No eLabFTW instances yet

-

Add one above before pushing entries.

-
- {:else} -
- {#each instances as instance (instance.id)} -
-
- {instance.siteUrl} - - TLS verification: {instance.verifyTls ? 'enabled' : 'disabled'} - -
- -
- - - - -
-
- {/each} - {#if elabftwInfoOutput} -
-

eLabFTW /info response

-
{elabftwInfoOutput}
-
- {/if} -
- {/if} - -
+ alert = nextAlert} + /> {:else if view === 'editor'}
@@ -503,7 +279,7 @@
@@ -528,34 +304,14 @@
{/if} {#if pushModalOpen} - - {#if instances.length > 1} - - - {:else if instances.length === 1} -

Instance: {instances[0].siteUrl}

- {:else} -

No eLabFTW instances configured.

- {/if} - - - - - - - - -
+ alert = nextAlert} + onPush={confirmPush} + /> {/if} {#if alert} - + {/if} From 1331cba29449d9bec91a792bb89a33cf62856af5 Mon Sep 17 00:00:00 2001 From: MoustaphaCamara Date: Wed, 3 Jun 2026 11:33:21 +0200 Subject: [PATCH 20/30] replace updated_at UpdatedAd with modified_at to keep consistency with elabftw --- app.go | 30 ++--- frontend/src/components/MainApp.svelte | 10 +- frontend/wailsjs/go/models.ts | 8 +- sqlite.go | 6 +- sync_instances.go | 4 +- sync_push.go | 155 +++++++++++++++++++++---- 6 files changed, 165 insertions(+), 48 deletions(-) diff --git a/app.go b/app.go index c56ad08..8dbc4d9 100644 --- a/app.go +++ b/app.go @@ -329,7 +329,7 @@ func (a *App) UpdateEntry(profileUUID string, id int64, title string, body strin UPDATE entries SET title = ?, body = ?, - updated_at = (strftime('%Y-%m-%dT%H:%M:%fZ','now')) + modified_at = (strftime('%Y-%m-%dT%H:%M:%fZ','now')) WHERE id = ? `, encryptedTitle, encryptedBody, id) if err != nil { @@ -387,10 +387,10 @@ func (a *App) DeleteEntry(profileUUID string, id int64) error { } type EntrySummary struct { - ID int64 `json:"id"` - Title string `json:"title"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` + ID int64 `json:"id"` + Title string `json:"title"` + CreatedAt string `json:"createdAt"` + ModifiedAt string `json:"modifiedAt"` } // Titles are stored encrypted, so decrypt them before returning the summaries to frontend. @@ -412,9 +412,9 @@ func (a *App) ListEntries(profileUUID string) ([]EntrySummary, error) { defer func() { _ = db.Close() }() rows, err := db.Query(` - SELECT id, title, created_at, updated_at + SELECT id, title, created_at, modified_at FROM entries - ORDER BY updated_at DESC, id DESC + ORDER BY modified_at DESC, id DESC `) if err != nil { return nil, fmt.Errorf("query entries: %w", err) @@ -424,7 +424,7 @@ func (a *App) ListEntries(profileUUID string) ([]EntrySummary, error) { out := make([]EntrySummary, 0) for rows.Next() { var e EntrySummary - if err := rows.Scan(&e.ID, &e.Title, &e.CreatedAt, &e.UpdatedAt); err != nil { + if err := rows.Scan(&e.ID, &e.Title, &e.CreatedAt, &e.ModifiedAt); err != nil { return nil, fmt.Errorf("scan entry: %w", err) } // The row stores encrypted title/body values. @@ -444,11 +444,11 @@ func (a *App) ListEntries(profileUUID string) ([]EntrySummary, error) { } type Entry struct { - ID int64 `json:"id"` - Title string `json:"title"` - Body string `json:"body"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` + ID int64 `json:"id"` + Title string `json:"title"` + Body string `json:"body"` + CreatedAt string `json:"createdAt"` + ModifiedAt string `json:"modifiedAt"` } func (a *App) GetEntry(profileUUID string, id int64) (*Entry, error) { @@ -473,10 +473,10 @@ func (a *App) GetEntry(profileUUID string, id int64) (*Entry, error) { var e Entry err = db.QueryRow(` - SELECT id, title, body, created_at, updated_at + SELECT id, title, body, created_at, modified_at FROM entries WHERE id = ? - `, id).Scan(&e.ID, &e.Title, &e.Body, &e.CreatedAt, &e.UpdatedAt) + `, id).Scan(&e.ID, &e.Title, &e.Body, &e.CreatedAt, &e.ModifiedAt) if err == sql.ErrNoRows { return nil, fmt.Errorf("Entry not found") diff --git a/frontend/src/components/MainApp.svelte b/frontend/src/components/MainApp.svelte index 1711a45..ae65968 100644 --- a/frontend/src/components/MainApp.svelte +++ b/frontend/src/components/MainApp.svelte @@ -167,7 +167,13 @@ pushModalOpen = false; } catch (e: unknown) { - alert = {type: 'error', message: errorMessage(e)}; + const message = errorMessage(e); + // warning if remote data is more recent than desktop + if (message.includes('was modified after your last sync')) { + alert = { type: 'warning', message }; + } else { + alert = { type: 'error', message }; + } } } @@ -237,7 +243,7 @@ {e.title || 'Untitled entry'} - Last edited {toRelativeTime(e.updatedAt)} + Last edited {toRelativeTime(e.modifiedAt)} diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index ee70d70..e08f37a 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -35,7 +35,7 @@ export namespace main { title: string; body: string; createdAt: string; - updatedAt: string; + modifiedAt: string; static createFrom(source: any = {}) { return new Entry(source); @@ -47,14 +47,14 @@ export namespace main { this.title = source["title"]; this.body = source["body"]; this.createdAt = source["createdAt"]; - this.updatedAt = source["updatedAt"]; + this.modifiedAt = source["modifiedAt"]; } } export class EntrySummary { id: number; title: string; createdAt: string; - updatedAt: string; + modifiedAt: string; static createFrom(source: any = {}) { return new EntrySummary(source); @@ -65,7 +65,7 @@ export namespace main { this.id = source["id"]; this.title = source["title"]; this.createdAt = source["createdAt"]; - this.updatedAt = source["updatedAt"]; + this.modifiedAt = source["modifiedAt"]; } } export class ProfileEntry { diff --git a/sqlite.go b/sqlite.go index 9842b3d..1ef93f3 100644 --- a/sqlite.go +++ b/sqlite.go @@ -54,7 +54,7 @@ CREATE TABLE IF NOT EXISTS entries ( title TEXT NOT NULL, body TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), - updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) + modified_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) ); PRAGMA user_version = 1; @@ -75,7 +75,7 @@ CREATE TABLE IF NOT EXISTS elabftw_instances ( api_key TEXT NOT NULL, verify_tls INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), - updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) + modified_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) ); CREATE TABLE IF NOT EXISTS local2remote ( @@ -85,7 +85,7 @@ CREATE TABLE IF NOT EXISTS local2remote ( local_id INTEGER NOT NULL, type TEXT NOT NULL CHECK (type IN ('experiment', 'resource', 'template')), created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), - updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + modified_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), FOREIGN KEY (instance) REFERENCES elabftw_instances(id) ON DELETE CASCADE, UNIQUE(instance, local_id, type), diff --git a/sync_instances.go b/sync_instances.go index 1953b33..5b303b6 100644 --- a/sync_instances.go +++ b/sync_instances.go @@ -189,7 +189,7 @@ func (a *App) UpdateElabftwInstance(profileUUID string, id int64, siteURL string if apiKey == "" { res, err := db.Exec(` UPDATE elabftw_instances - SET site_url = ?, verify_tls = ?, updated_at = (strftime('%Y-%m-%dT%H:%M:%fZ','now')) + SET site_url = ?, verify_tls = ?, modified_at = (strftime('%Y-%m-%dT%H:%M:%fZ','now')) WHERE id = ? `, siteURL, verifyTLS, id) if err != nil { @@ -214,7 +214,7 @@ func (a *App) UpdateElabftwInstance(profileUUID string, id int64, siteURL string res, err := db.Exec(` UPDATE elabftw_instances - SET site_url = ?, api_key = ?, verify_tls = ?, updated_at = (strftime('%Y-%m-%dT%H:%M:%fZ','now')) + SET site_url = ?, api_key = ?, verify_tls = ?, modified_at = (strftime('%Y-%m-%dT%H:%M:%fZ','now')) WHERE id = ? `, siteURL, encryptedAPIKey, verifyTLS, id) if err != nil { diff --git a/sync_push.go b/sync_push.go index 0fb417c..b81d2ff 100644 --- a/sync_push.go +++ b/sync_push.go @@ -6,10 +6,12 @@ package main import ( + "database/sql" "fmt" "net/http" "strconv" "strings" + "time" ) type PushEntryResult struct { @@ -84,38 +86,82 @@ func (a *App) PushEntryToElabftw(profileUUID string, entryID int64, instanceID i } var remoteID int64 + var lastSyncModifiedAt string + err = db.QueryRow(` - SELECT remote_id - FROM local2remote - WHERE instance = ? AND local_id = ? AND type = ? - `, instanceID, entryID, entityType).Scan(&remoteID) + SELECT remote_id, modified_at + FROM local2remote + WHERE instance = ? AND local_id = ? AND type = ? + `, instanceID, entryID, entityType).Scan(&remoteID, &lastSyncModifiedAt) + + if err != nil && err != sql.ErrNoRows { + return nil, fmt.Errorf("query local2remote: %w", err) + } if err == nil { + // Existing local2remote row means this should PATCH. + // First GET the remote entity and check whether it changed since last sync. + resp, err := a.elabftwRequest( + profileUUID, + instanceID, + http.MethodGet, + fmt.Sprintf("%s/%d", basePath, remoteID), + nil, + ) + if err != nil { + return nil, err + } + + var remote map[string]any + if err := decodeElabftwJSONResponse(resp, &remote); err != nil { + return nil, err + } + + remoteModifiedAt, err := parseElabftwModifiedAt(remote["modified_at"]) + if err != nil { + return nil, err + } + + lastSyncAt, err := parseLocalModifiedAt(lastSyncModifiedAt) + if err != nil { + return nil, err + } + + if remoteModifiedAt.After(lastSyncAt) { + return nil, fmt.Errorf(remoteModifiedConflictMessage(entityType, remoteID)) + } + reqBody, err := jsonBody(payload) if err != nil { return nil, err } - resp, err := a.elabftwRequest( + resp, err = a.elabftwRequest( profileUUID, instanceID, - http.MethodPatch, + http.MethodGet, fmt.Sprintf("%s/%d", basePath, remoteID), - reqBody, + nil, ) if err != nil { return nil, err } - if err := decodeElabftwJSONResponse(resp, nil); err != nil { + var patchedRemote map[string]any + if err := decodeElabftwJSONResponse(resp, &patchedRemote); err != nil { + return nil, err + } + + patchedRemoteModifiedAt, err := parseElabftwModifiedAt(patchedRemote["modified_at"]) + if err != nil { return nil, err } _, err = db.Exec(` - UPDATE local2remote - SET updated_at = (strftime('%Y-%m-%dT%H:%M:%fZ','now')) - WHERE instance = ? AND local_id = ? AND type = ? - `, instanceID, entryID, entityType) + UPDATE local2remote + SET modified_at = ? + WHERE instance = ? AND local_id = ? AND type = ? + `, patchedRemoteModifiedAt.Format(time.RFC3339Nano), instanceID, entryID, entityType) if err != nil { return nil, fmt.Errorf("update local2remote: %w", err) } @@ -132,31 +178,31 @@ func (a *App) PushEntryToElabftw(profileUUID string, entryID int64, instanceID i if err != nil { return nil, err } - - resp, err := a.elabftwRequest( + resp, err = a.elabftwRequest( profileUUID, instanceID, - http.MethodPost, - basePath, - reqBody, + http.MethodGet, + fmt.Sprintf("%s/%d", basePath, remoteID), + nil, ) if err != nil { return nil, err } - if err := decodeElabftwJSONResponse(resp, nil); err != nil { + var createdRemote map[string]any + if err := decodeElabftwJSONResponse(resp, &createdRemote); err != nil { return nil, err } - remoteID, err = remoteIDFromLocation(resp.Header.Get("Location")) + createdRemoteModifiedAt, err := parseElabftwModifiedAt(createdRemote["modified_at"]) if err != nil { return nil, err } _, err = db.Exec(` - INSERT INTO local2remote (instance, remote_id, local_id, type) - VALUES (?, ?, ?, ?) - `, instanceID, remoteID, entryID, entityType) + INSERT INTO local2remote (instance, remote_id, local_id, type, modified_at) + VALUES (?, ?, ?, ?, ?) + `, instanceID, remoteID, entryID, entityType, createdRemoteModifiedAt.Format(time.RFC3339Nano)) if err != nil { return nil, fmt.Errorf("insert local2remote: %w", err) } @@ -229,3 +275,68 @@ func remoteIDFromLocation(location string) (int64, error) { return id, nil } + +/* + * The behaviour we want, ON PATCH, is: + * GET remote first. Read remote's modified_at. + * If remote's modified_at is more recent then local modified_at, stop and return a warning + * else patch. We want to keep remote as source of truth and warn + */ + +func parseElabftwModifiedAt(value any) (time.Time, error) { + s, ok := value.(string) + if !ok || strings.TrimSpace(s) == "" { + return time.Time{}, fmt.Errorf("remote response does not contain a valid modified_at") + } + + s = strings.TrimSpace(s) + + formats := []string{ + time.RFC3339Nano, + time.RFC3339, + "2006-01-02 15:04:05", + "2006-01-02T15:04:05Z", + "2006-01-02T15:04:05.000000Z", + } + + for _, layout := range formats { + t, err := time.Parse(layout, s) + if err == nil { + return t.UTC(), nil + } + } + + return time.Time{}, fmt.Errorf("cannot parse remote modified_at %q", s) +} + +func parseLocalModifiedAt(value string) (time.Time, error) { + value = strings.TrimSpace(value) + if value == "" { + return time.Time{}, fmt.Errorf("local modified_at is empty") + } + + formats := []string{ + time.RFC3339Nano, + time.RFC3339, + "2006-01-02 15:04:05", + "2006-01-02T15:04:05Z", + "2006-01-02T15:04:05.000000Z", + } + + for _, layout := range formats { + t, err := time.Parse(layout, value) + if err == nil { + return t.UTC(), nil + } + } + + return time.Time{}, fmt.Errorf("cannot parse local modified_at %q", value) +} + +func remoteModifiedConflictMessage(entityType string, remoteID int64) string { + return fmt.Sprintf( + "Remote %s #%d was modified after your last sync. Pull or review the online version before pushing.", + entityType, + remoteID, + ) +} From a59a5faf4f07853680f2a86aa409e73dcfdcf4ad Mon Sep 17 00:00:00 2001 From: MoustaphaCamara Date: Wed, 3 Jun 2026 11:38:09 +0200 Subject: [PATCH 21/30] compare remote modified_at and local to see if we push or warn --- sync_push.go | 251 ++++++++++++++++++++++++++++++++------------------- 1 file changed, 158 insertions(+), 93 deletions(-) diff --git a/sync_push.go b/sync_push.go index b81d2ff..3da395c 100644 --- a/sync_push.go +++ b/sync_push.go @@ -1,6 +1,5 @@ /* - * This file will handle the PUSHing of entries to configured eLabFTW instance - * + * This file handles the PUSHing of entries to configured eLabFTW instance */ package main @@ -100,109 +99,152 @@ func (a *App) PushEntryToElabftw(profileUUID string, entryID int64, instanceID i if err == nil { // Existing local2remote row means this should PATCH. - // First GET the remote entity and check whether it changed since last sync. - resp, err := a.elabftwRequest( + return a.patchExistingRemoteEntry( profileUUID, + db, instanceID, - http.MethodGet, - fmt.Sprintf("%s/%d", basePath, remoteID), - nil, + entryID, + entityType, + basePath, + remoteID, + lastSyncModifiedAt, + payload, ) - if err != nil { - return nil, err - } + } - var remote map[string]any - if err := decodeElabftwJSONResponse(resp, &remote); err != nil { - return nil, err - } + return a.postNewRemoteEntry( + profileUUID, + db, + instanceID, + entryID, + entityType, + basePath, + payload, + ) +} - remoteModifiedAt, err := parseElabftwModifiedAt(remote["modified_at"]) - if err != nil { - return nil, err - } +func (a *App) patchExistingRemoteEntry( + profileUUID string, + db *sql.DB, + instanceID int64, + entryID int64, + entityType string, + basePath string, + remoteID int64, + lastSyncModifiedAt string, + payload map[string]any, +) (*PushEntryResult, error) { + // First GET remote to check if someone edited it after our last successful sync. + resp, err := a.elabftwRequest( + profileUUID, + instanceID, + http.MethodGet, + fmt.Sprintf("%s/%d", basePath, remoteID), + nil, + ) + if err != nil { + return nil, err + } - lastSyncAt, err := parseLocalModifiedAt(lastSyncModifiedAt) - if err != nil { - return nil, err - } + var remote map[string]any + if err := decodeElabftwJSONResponse(resp, &remote); err != nil { + return nil, err + } - if remoteModifiedAt.After(lastSyncAt) { - return nil, fmt.Errorf(remoteModifiedConflictMessage(entityType, remoteID)) - } + remoteModifiedAt, err := parseElabftwModifiedAt(remote["modified_at"]) + if err != nil { + return nil, err + } - reqBody, err := jsonBody(payload) - if err != nil { - return nil, err - } + lastSyncAt, err := parseLocalModifiedAt(lastSyncModifiedAt) + if err != nil { + return nil, err + } - resp, err = a.elabftwRequest( - profileUUID, - instanceID, - http.MethodGet, - fmt.Sprintf("%s/%d", basePath, remoteID), - nil, - ) - if err != nil { - return nil, err - } + if remoteModifiedAt.After(lastSyncAt) { + return nil, fmt.Errorf(remoteModifiedConflictMessage(entityType, remoteID)) + } - var patchedRemote map[string]any - if err := decodeElabftwJSONResponse(resp, &patchedRemote); err != nil { - return nil, err - } + reqBody, err := jsonBody(payload) + if err != nil { + return nil, err + } - patchedRemoteModifiedAt, err := parseElabftwModifiedAt(patchedRemote["modified_at"]) - if err != nil { - return nil, err - } + resp, err = a.elabftwRequest( + profileUUID, + instanceID, + http.MethodPatch, + fmt.Sprintf("%s/%d", basePath, remoteID), + reqBody, + ) + if err != nil { + return nil, err + } - _, err = db.Exec(` - UPDATE local2remote - SET modified_at = ? - WHERE instance = ? AND local_id = ? AND type = ? - `, patchedRemoteModifiedAt.Format(time.RFC3339Nano), instanceID, entryID, entityType) - if err != nil { - return nil, fmt.Errorf("update local2remote: %w", err) - } + if err := decodeElabftwJSONResponse(resp, nil); err != nil { + return nil, err + } - return &PushEntryResult{ - LocalID: entryID, - RemoteID: remoteID, - Action: "patched", - Type: entityType, - }, nil + patchedRemoteModifiedAt, err := a.fetchRemoteModifiedAt(profileUUID, instanceID, basePath, remoteID) + if err != nil { + return nil, err + } + + if err := updateLocalRemoteModifiedAt(db, instanceID, entryID, entityType, patchedRemoteModifiedAt); err != nil { + return nil, err } + return &PushEntryResult{ + LocalID: entryID, + RemoteID: remoteID, + Action: "patched", + Type: entityType, + }, nil +} + +func (a *App) postNewRemoteEntry( + profileUUID string, + db *sql.DB, + instanceID int64, + entryID int64, + entityType string, + basePath string, + payload map[string]any, +) (*PushEntryResult, error) { reqBody, err := jsonBody(payload) if err != nil { return nil, err } - resp, err = a.elabftwRequest( + + resp, err := a.elabftwRequest( profileUUID, instanceID, - http.MethodGet, - fmt.Sprintf("%s/%d", basePath, remoteID), - nil, + http.MethodPost, + basePath, + reqBody, ) if err != nil { return nil, err } - var createdRemote map[string]any - if err := decodeElabftwJSONResponse(resp, &createdRemote); err != nil { + if err := decodeElabftwJSONResponse(resp, nil); err != nil { return nil, err } - createdRemoteModifiedAt, err := parseElabftwModifiedAt(createdRemote["modified_at"]) + remoteID, err := remoteIDFromLocation(resp.Header.Get("Location")) + if err != nil { + return nil, err + } + + createdRemoteModifiedAt, err := a.fetchRemoteModifiedAt(profileUUID, instanceID, basePath, remoteID) if err != nil { return nil, err } _, err = db.Exec(` - INSERT INTO local2remote (instance, remote_id, local_id, type, modified_at) - VALUES (?, ?, ?, ?, ?) - `, instanceID, remoteID, entryID, entityType, createdRemoteModifiedAt.Format(time.RFC3339Nano)) + INSERT INTO local2remote (instance, remote_id, local_id, type, modified_at) + VALUES (?, ?, ?, ?, ?) + `, instanceID, remoteID, entryID, entityType, createdRemoteModifiedAt.Format(time.RFC3339Nano)) if err != nil { return nil, fmt.Errorf("insert local2remote: %w", err) } @@ -215,6 +257,39 @@ func (a *App) PushEntryToElabftw(profileUUID string, entryID int64, instanceID i }, nil } +func (a *App) fetchRemoteModifiedAt(profileUUID string, instanceID int64, basePath string, remoteID int64) (time.Time, error) { + resp, err := a.elabftwRequest( + profileUUID, + instanceID, + http.MethodGet, + fmt.Sprintf("%s/%d", basePath, remoteID), + nil, + ) + if err != nil { + return time.Time{}, err + } + + var remote map[string]any + if err := decodeElabftwJSONResponse(resp, &remote); err != nil { + return time.Time{}, err + } + + return parseElabftwModifiedAt(remote["modified_at"]) +} + +func updateLocalRemoteModifiedAt(db *sql.DB, instanceID int64, entryID int64, entityType string, modifiedAt time.Time) error { + _, err := db.Exec(` + UPDATE local2remote + SET modified_at = ? + WHERE instance = ? AND local_id = ? AND type = ? + `, modifiedAt.Format(time.RFC3339Nano), instanceID, entryID, entityType) + if err != nil { + return fmt.Errorf("update local2remote: %w", err) + } + + return nil +} + func (a *App) PushAllEntriesToElabftw(profileUUID string, instanceID int64, entityType string) ([]PushEntryResult, error) { profileUUID, err := a.requireUnlockedProfile(profileUUID) if err != nil { @@ -289,38 +364,28 @@ func parseElabftwModifiedAt(value any) (time.Time, error) { return time.Time{}, fmt.Errorf("remote response does not contain a valid modified_at") } - s = strings.TrimSpace(s) - - formats := []string{ - time.RFC3339Nano, - time.RFC3339, - "2006-01-02 15:04:05", - "2006-01-02T15:04:05Z", - "2006-01-02T15:04:05.000000Z", - } + return parseSyncTime(s, "remote modified_at") +} - for _, layout := range formats { - t, err := time.Parse(layout, s) - if err == nil { - return t.UTC(), nil - } +func parseLocalModifiedAt(value string) (time.Time, error) { + if strings.TrimSpace(value) == "" { + return time.Time{}, fmt.Errorf("local modified_at is empty") } - return time.Time{}, fmt.Errorf("cannot parse remote modified_at %q", s) + return parseSyncTime(value, "local modified_at") } -func parseLocalModifiedAt(value string) (time.Time, error) { +func parseSyncTime(value string, label string) (time.Time, error) { value = strings.TrimSpace(value) - if value == "" { - return time.Time{}, fmt.Errorf("local modified_at is empty") - } formats := []string{ time.RFC3339Nano, time.RFC3339, - "2006-01-02 15:04:05", + "2006-01-02T15:04:05.999999Z", + "2006-01-02T15:04:05.999Z", "2006-01-02T15:04:05Z", - "2006-01-02T15:04:05.000000Z", + "2006-01-02 15:04:05.999999", + "2006-01-02 15:04:05", } for _, layout := range formats { @@ -330,7 +395,7 @@ func parseLocalModifiedAt(value string) (time.Time, error) { } } - return time.Time{}, fmt.Errorf("cannot parse local modified_at %q", value) + return time.Time{}, fmt.Errorf("cannot parse %s %q", label, value) } func remoteModifiedConflictMessage(entityType string, remoteID int64) string { From c36c6286fdd7254c93ed9c726cc94915ec6d9217 Mon Sep 17 00:00:00 2001 From: MoustaphaCamara Date: Wed, 3 Jun 2026 11:44:28 +0200 Subject: [PATCH 22/30] go vet fails fmt.Errorf expects a constant format string --- sync_push.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sync_push.go b/sync_push.go index 3da395c..09ea328 100644 --- a/sync_push.go +++ b/sync_push.go @@ -6,6 +6,7 @@ package main import ( "database/sql" + "errors" "fmt" "net/http" "strconv" @@ -162,7 +163,7 @@ func (a *App) patchExistingRemoteEntry( } if remoteModifiedAt.After(lastSyncAt) { - return nil, fmt.Errorf(remoteModifiedConflictMessage(entityType, remoteID)) + return nil, errors.New(remoteModifiedConflictMessage(entityType, remoteID)) } reqBody, err := jsonBody(payload) From 0f65cdf356ffe9611d7b465fb1ebd0674a2e2764 Mon Sep 17 00:00:00 2001 From: MoustaphaCamara Date: Wed, 3 Jun 2026 11:44:36 +0200 Subject: [PATCH 23/30] go vet fails fmt.Errorf expects a constant format string --- sync_push.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sync_push.go b/sync_push.go index 09ea328..24d017d 100644 --- a/sync_push.go +++ b/sync_push.go @@ -163,7 +163,7 @@ func (a *App) patchExistingRemoteEntry( } if remoteModifiedAt.After(lastSyncAt) { - return nil, errors.New(remoteModifiedConflictMessage(entityType, remoteID)) + return nil, errors.New(remoteModifiedConflictMessage(entityType, remoteID)) } reqBody, err := jsonBody(payload) From b0e23ecef858a47d4e334b0c0ccca617f6498837 Mon Sep 17 00:00:00 2001 From: MoustaphaCamara Date: Wed, 3 Jun 2026 12:00:33 +0200 Subject: [PATCH 24/30] disable fetch button --- frontend/src/components/MainApp.svelte | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/MainApp.svelte b/frontend/src/components/MainApp.svelte index ae65968..efdef94 100644 --- a/frontend/src/components/MainApp.svelte +++ b/frontend/src/components/MainApp.svelte @@ -263,7 +263,8 @@ {#if entries.length !== 0} {/if} - + + From f292cdabc967218098a3c67016cad94896518b59 Mon Sep 17 00:00:00 2001 From: MoustaphaCamara Date: Wed, 3 Jun 2026 14:28:55 +0200 Subject: [PATCH 25/30] :rabbit: & use required on inputs --- frontend/src/components/Instances/InstancesView.svelte | 2 ++ frontend/src/components/MainApp.svelte | 1 + frontend/src/components/Modal.svelte | 2 +- .../components/ProfileSelector/ProfileSelectorCreateForm.svelte | 2 ++ .../components/ProfileSelector/ProfileSelectorUnlockForm.svelte | 1 + 5 files changed, 7 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/Instances/InstancesView.svelte b/frontend/src/components/Instances/InstancesView.svelte index e5d71e0..d9d44d3 100644 --- a/frontend/src/components/Instances/InstancesView.svelte +++ b/frontend/src/components/Instances/InstancesView.svelte @@ -127,6 +127,7 @@
Entry title
-

{title}

+

{title}

+
From 5d97515c1a4ddb311110452e20865ea832c3f1f4 Mon Sep 17 00:00:00 2001 From: MoustaphaCamara Date: Wed, 3 Jun 2026 15:43:52 +0200 Subject: [PATCH 28/30] some clarifications --- elabftw_client.go | 10 +++++++--- frontend/src/css/main.css | 10 ++++++---- frontend/src/utils/helpers.ts | 1 + 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/elabftw_client.go b/elabftw_client.go index 1f5e0b4..b3608b4 100644 --- a/elabftw_client.go +++ b/elabftw_client.go @@ -88,12 +88,13 @@ func (a *App) loadElabftwClientConfig(profileUUID string, instanceID int64) (*el func elabftwHTTPClient(verifyTLS bool) *http.Client { transport := &http.Transport{ TLSClientConfig: &tls.Config{ - // Needed for local/self-signed HTTPS dev instances. - // Only false when the user explicitly disabled TLS verification. + // Only false when the user explicitly disables TLS verification. InsecureSkipVerify: !verifyTLS, }, } + // timeout prevents the desktop app from hanging forever if the server is unreachable + // Transport carries our TLS configuration, including whether to verify certificates return &http.Client{ Timeout: 30 * time.Second, Transport: transport, @@ -135,12 +136,15 @@ func (a *App) elabftwRequest( return resp, nil } +// http.Response is an open stream, so we need to close it when done reading func closeResponseBody(resp *http.Response) { if resp != nil && resp.Body != nil { _ = resp.Body.Close() } } +// this function is responsible for reading the response body, +// so it also owns closing it before returning func decodeElabftwJSONResponse(resp *http.Response, target any) error { defer closeResponseBody(resp) @@ -210,7 +214,7 @@ func (a *App) FetchElabftwExperiment(profileUUID string, instanceID int64, remot return out, nil } -/* POC post experiments */ +/* POST experiments */ func (a *App) CreateElabftwExperiment(profileUUID string, instanceID int64, payload any) (map[string]any, error) { body, err := jsonBody(payload) if err != nil { diff --git a/frontend/src/css/main.css b/frontend/src/css/main.css index 5649313..0235227 100644 --- a/frontend/src/css/main.css +++ b/frontend/src/css/main.css @@ -127,12 +127,14 @@ h2 { font-size: 1.12rem; } -/* i'm keeping a in case we have links sometime or people use it in browser, - but it should be a