diff --git a/app.go b/app.go index df43f39..8dbc4d9 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 = ?, + 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 { @@ -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. @@ -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) @@ -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. @@ -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) { @@ -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") diff --git a/elabftw_client.go b/elabftw_client.go new file mode 100644 index 0000000..28f1618 --- /dev/null +++ b/elabftw_client.go @@ -0,0 +1,194 @@ +/* + * 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{ + // 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, + } +} + +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 +} + +// 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) + + 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 +} diff --git a/frontend/src/components/Alert.svelte b/frontend/src/components/Alert.svelte index f6081dd..4deb0f9 100644 --- a/frontend/src/components/Alert.svelte +++ b/frontend/src/components/Alert.svelte @@ -18,6 +18,6 @@ {#if message && visible}
{message} - +
{/if} diff --git a/frontend/src/components/Instances/InstancesPushModal.svelte b/frontend/src/components/Instances/InstancesPushModal.svelte new file mode 100644 index 0000000..c43f44e --- /dev/null +++ b/frontend/src/components/Instances/InstancesPushModal.svelte @@ -0,0 +1,85 @@ + + + + + {#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..1569a73 --- /dev/null +++ b/frontend/src/components/Instances/InstancesView.svelte @@ -0,0 +1,212 @@ + + + +
+
+ + Add the site URL and your API key to allow communication between the desktop app and your eLabFTW instance. +
+ See the + + 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 1f9c87f..f2c1282 100644 --- a/frontend/src/components/MainApp.svelte +++ b/frontend/src/components/MainApp.svelte @@ -1,11 +1,22 @@
@@ -113,29 +185,31 @@ {#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}
{profileName.slice(0, 2).toUpperCase()} - {profileName} + {profileName}
- +
+ {#if view === 'index'}
- +

Saved entries

@@ -160,46 +234,68 @@
{#each entries as e (e.id)}
- - + +
{/each}
- -
- - -
{/if} +
+ {#if entries.length !== 0} + + {/if} + + + + +
+ + {:else if view === 'instances'} + alert = nextAlert} + /> + {:else if view === 'editor'}
- +
+ + +
{/if} + {#if pushModalOpen} + alert = nextAlert} + onPush={confirmPush} + /> + {/if} {#if alert} - + {/if} diff --git a/frontend/src/components/Modal.svelte b/frontend/src/components/Modal.svelte new file mode 100644 index 0000000..bd91565 --- /dev/null +++ b/frontend/src/components/Modal.svelte @@ -0,0 +1,29 @@ + + + diff --git a/frontend/src/components/ProfileSelector/ProfileSelectorCreateForm.svelte b/frontend/src/components/ProfileSelector/ProfileSelectorCreateForm.svelte index 0d9d131..7cc1217 100644 --- a/frontend/src/components/ProfileSelector/ProfileSelectorCreateForm.svelte +++ b/frontend/src/components/ProfileSelector/ProfileSelectorCreateForm.svelte @@ -24,6 +24,7 @@
-

Enter your passphrase to continue.

- + styling for browser usage and regular links. + * In the desktop app, external links should be rendered as + *