Skip to content
Merged
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
ac6ef95
feat: sync: add tables to enable querying elabftw instances
MoustaphaCamara May 21, 2026
887af2b
lint
MoustaphaCamara May 21, 2026
c93e6b8
sync_instances.go to add & delete elabftw instances to db
MoustaphaCamara May 22, 2026
70df27e
encrypt api key
MoustaphaCamara May 22, 2026
d65efc7
interface: add elabftw instances page
MoustaphaCamara May 22, 2026
4ec2a0c
normalize url
MoustaphaCamara May 22, 2026
5e94cdc
elabftw client to fetch information & test instance in UI
MoustaphaCamara May 22, 2026
1130461
test info endpoint with many instances :D
MoustaphaCamara May 22, 2026
0e820e8
style checkbox
MoustaphaCamara May 22, 2026
64e74df
lint
MoustaphaCamara May 22, 2026
1e90b4f
remove changes on sqlite permissions
MoustaphaCamara May 22, 2026
d60f468
remove changes on sqlite permissions
MoustaphaCamara May 22, 2026
2fdf9e2
less padding because the app gets too much below
MoustaphaCamara May 22, 2026
e97f9bf
remove description class for the doc, unreadable not a11y friendly
MoustaphaCamara May 22, 2026
bac7e6c
edit instances
MoustaphaCamara May 26, 2026
f2073c4
feat: push modal to push entries to eLabFTW
MoustaphaCamara Jun 2, 2026
84c30f5
use component for Modal
MoustaphaCamara Jun 2, 2026
93362f5
feat: UPDATE (add update Entry & also update to eLabFTW existing entr…
MoustaphaCamara Jun 3, 2026
a821e41
refactor: move instances related code into separated components
MoustaphaCamara Jun 3, 2026
1331cba
replace updated_at UpdatedAd with modified_at to keep consistency wit…
MoustaphaCamara Jun 3, 2026
a59a5fa
compare remote modified_at and local to see if we push or warn
MoustaphaCamara Jun 3, 2026
c36c628
go vet fails fmt.Errorf expects a constant format string
MoustaphaCamara Jun 3, 2026
0f65cdf
go vet fails fmt.Errorf expects a constant format string
MoustaphaCamara Jun 3, 2026
b0e23ec
disable fetch button
MoustaphaCamara Jun 3, 2026
f292cda
:rabbit: & use required on inputs
MoustaphaCamara Jun 3, 2026
d7f392d
new helper for urls, fix anchor, some comments
MoustaphaCamara Jun 3, 2026
57ef356
some finishingg touches
MoustaphaCamara Jun 3, 2026
5d97515
some clarifications
MoustaphaCamara Jun 3, 2026
3a7bc9c
remove unused
MoustaphaCamara Jun 3, 2026
e2e7ab0
seems nice
MoustaphaCamara Jun 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 72 additions & 14 deletions app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ?,
modified_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 {
Expand Down Expand Up @@ -329,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.
Expand All @@ -354,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)
Expand All @@ -366,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.
Expand All @@ -386,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) {
Expand All @@ -415,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")
Expand Down
237 changes: 237 additions & 0 deletions elabftw_client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
/*
* 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/%d", 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
}
2 changes: 1 addition & 1 deletion frontend/src/components/Alert.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,6 @@
{#if message && visible}
<div class={`alert alert-${type} flex justify-between items-center`}>
<strong>{message}</strong>
<button class='alert-close' type='button' aria-label='Close alert' onclick={() => visible = false}>×</button>
<button class='alert-close' type='button' aria-label='Close alert' onclick={() => visible = false}>&#x2717;</button>
</div>
{/if}
Loading
Loading