feat(v0.2.0): config UI panel + folder picker + S3 bucket lister/tester
- Panel Reglages dans l'UI (modal) - Folder picker natif Wails pour dossier local - ListS3Buckets via aws-cli (profile-aware) - TestS3Access pour verifier acces bucket - Config persistee user config dir (cross-OS): %APPDATA%/field-sync ou ~/.config/field-sync - Header affiche bucket + dest visibles - Auto-open modal si bucket vide au premier run Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
59
app.go
59
app.go
@@ -3,6 +3,8 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -66,12 +68,67 @@ func NewApp() *App {
|
|||||||
// startup is called by Wails when the app starts
|
// startup is called by Wails when the app starts
|
||||||
func (a *App) startup(ctx context.Context) {
|
func (a *App) startup(ctx context.Context) {
|
||||||
a.ctx = ctx
|
a.ctx = ctx
|
||||||
a.cfg = config.Load(".env")
|
a.cfg = config.LoadFromFile()
|
||||||
for i, letter := range []string{"A", "B", "C", "D"} {
|
for i, letter := range []string{"A", "B", "C", "D"} {
|
||||||
a.slots[i] = SlotState{Slot: letter, Status: "idle"}
|
a.slots[i] = SlotState{Slot: letter, Status: "idle"}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SaveConfig persists config to user config dir and updates in-memory config
|
||||||
|
func (a *App) SaveConfig(cfg config.Config) error {
|
||||||
|
if err := config.Save(cfg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.mu.Lock()
|
||||||
|
a.cfg = cfg
|
||||||
|
a.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConfigPath returns where the config is stored (for display)
|
||||||
|
func (a *App) GetConfigPath() string {
|
||||||
|
return config.Path()
|
||||||
|
}
|
||||||
|
|
||||||
|
// PickLocalDest opens native folder picker, returns selected path or "" if cancelled
|
||||||
|
func (a *App) PickLocalDest() (string, error) {
|
||||||
|
path, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{
|
||||||
|
Title: "Dossier de destination locale",
|
||||||
|
})
|
||||||
|
return path, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListS3Buckets runs `aws s3api list-buckets --profile <p>` returns []string
|
||||||
|
func (a *App) ListS3Buckets(profile string) ([]string, error) {
|
||||||
|
args := []string{"s3api", "list-buckets", "--query", "Buckets[].Name", "--output", "text"}
|
||||||
|
if profile != "" {
|
||||||
|
args = append(args, "--profile", profile)
|
||||||
|
}
|
||||||
|
out, err := exec.Command("aws", args...).CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("aws error: %s", strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
raw := strings.TrimSpace(string(out))
|
||||||
|
if raw == "" || raw == "None" {
|
||||||
|
return []string{}, nil
|
||||||
|
}
|
||||||
|
parts := strings.Fields(raw)
|
||||||
|
return parts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestS3Access checks bucket reachable: `aws s3 ls s3://<bucket>/ --profile <p>`
|
||||||
|
func (a *App) TestS3Access(bucket, profile string) error {
|
||||||
|
args := []string{"s3", "ls", "s3://" + bucket + "/"}
|
||||||
|
if profile != "" {
|
||||||
|
args = append(args, "--profile", profile)
|
||||||
|
}
|
||||||
|
out, err := exec.Command("aws", args...).CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("accès refusé: %s", strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ScanCards returns up to 4 detected SD cards
|
// ScanCards returns up to 4 detected SD cards
|
||||||
func (a *App) ScanCards() []detect.SDCard {
|
func (a *App) ScanCards() []detect.SDCard {
|
||||||
cards := detect.Detect()
|
cards := detect.Detect()
|
||||||
|
|||||||
@@ -1,12 +1,25 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { ScanCards, StartIngest, CancelIngest, GetSnapshot, SetDryRun, LoadConfig } from '../wailsjs/go/main/App';
|
import { ScanCards, StartIngest, CancelIngest, GetSnapshot, SetDryRun, LoadConfig, SaveConfig, GetConfigPath, PickLocalDest, ListS3Buckets, TestS3Access } from '../wailsjs/go/main/App';
|
||||||
import { EventsOn } from '../wailsjs/runtime/runtime';
|
import { EventsOn } from '../wailsjs/runtime/runtime';
|
||||||
|
|
||||||
let cards: any[] = [];
|
let cards: any[] = [];
|
||||||
let snapshot: any = { slots: [], running: false, dryRun: false, sessionID: '', config: {} };
|
let snapshot: any = { slots: [], running: false, dryRun: false, sessionID: '', config: {} };
|
||||||
let dryRun = false;
|
let dryRun = false;
|
||||||
|
|
||||||
|
// Settings modal
|
||||||
|
let showSettings = false;
|
||||||
|
let settingsMsg = '';
|
||||||
|
let cfgPath = '';
|
||||||
|
let s3TestResult = '';
|
||||||
|
let s3TestOk: boolean | null = null;
|
||||||
|
let bucketList: string[] = [];
|
||||||
|
let loadingBuckets = false;
|
||||||
|
let savingConfig = false;
|
||||||
|
|
||||||
|
// Local editable config
|
||||||
|
let editCfg = { s3Bucket: '', localDest: '', awsProfile: 'cosma', concurrency: 4 };
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
cards = await ScanCards();
|
cards = await ScanCards();
|
||||||
snapshot = await GetSnapshot();
|
snapshot = await GetSnapshot();
|
||||||
@@ -27,11 +40,74 @@
|
|||||||
SetDryRun(dryRun);
|
SetDryRun(dryRun);
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
async function openSettings() {
|
||||||
refresh();
|
editCfg = {
|
||||||
|
s3Bucket: snapshot.config?.s3Bucket || '',
|
||||||
|
localDest: snapshot.config?.localDest || '',
|
||||||
|
awsProfile: snapshot.config?.awsProfile || 'cosma',
|
||||||
|
concurrency: snapshot.config?.concurrency || 4,
|
||||||
|
};
|
||||||
|
cfgPath = await GetConfigPath();
|
||||||
|
s3TestResult = '';
|
||||||
|
s3TestOk = null;
|
||||||
|
bucketList = [];
|
||||||
|
settingsMsg = '';
|
||||||
|
showSettings = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pickFolder() {
|
||||||
|
const p = await PickLocalDest();
|
||||||
|
if (p) editCfg.localDest = p;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listBuckets() {
|
||||||
|
loadingBuckets = true;
|
||||||
|
bucketList = [];
|
||||||
|
try {
|
||||||
|
bucketList = await ListS3Buckets(editCfg.awsProfile);
|
||||||
|
} catch (e: any) {
|
||||||
|
bucketList = [];
|
||||||
|
}
|
||||||
|
loadingBuckets = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testS3() {
|
||||||
|
s3TestResult = 'Test en cours...';
|
||||||
|
s3TestOk = null;
|
||||||
|
try {
|
||||||
|
await TestS3Access(editCfg.s3Bucket, editCfg.awsProfile);
|
||||||
|
s3TestResult = 'Acces OK';
|
||||||
|
s3TestOk = true;
|
||||||
|
} catch (e: any) {
|
||||||
|
s3TestResult = e?.message || 'Erreur acces S3';
|
||||||
|
s3TestOk = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSettings() {
|
||||||
|
savingConfig = true;
|
||||||
|
try {
|
||||||
|
await SaveConfig(editCfg);
|
||||||
|
await refresh();
|
||||||
|
showSettings = false;
|
||||||
|
} catch (e: any) {
|
||||||
|
settingsMsg = 'Erreur: ' + (e?.message || String(e));
|
||||||
|
}
|
||||||
|
savingConfig = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await refresh();
|
||||||
const t = setInterval(refresh, 3000);
|
const t = setInterval(refresh, 3000);
|
||||||
EventsOn('slot-update', () => refresh());
|
EventsOn('slot-update', () => refresh());
|
||||||
EventsOn('session-done', () => refresh());
|
EventsOn('session-done', () => refresh());
|
||||||
|
|
||||||
|
// Auto-open settings if no bucket configured
|
||||||
|
if (!snapshot.config?.s3Bucket) {
|
||||||
|
settingsMsg = 'Configure d abord ton bucket S3 et le dossier local.';
|
||||||
|
await openSettings();
|
||||||
|
}
|
||||||
|
|
||||||
return () => clearInterval(t);
|
return () => clearInterval(t);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -52,12 +128,29 @@
|
|||||||
<header class="flex items-center justify-between border-b border-slate-700 pb-4">
|
<header class="flex items-center justify-between border-b border-slate-700 pb-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-3xl font-bold text-cyan-400">field-sync</h1>
|
<h1 class="text-3xl font-bold text-cyan-400">field-sync</h1>
|
||||||
<p class="text-sm text-slate-400">Cosma terrain · SD → local + S3</p>
|
<p class="text-sm text-slate-400">Cosma terrain · SD → local + S3</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-right text-xs text-slate-400">
|
<div class="flex flex-col items-end gap-1">
|
||||||
<div>Session: <span class="text-slate-200">{snapshot.sessionID || '—'}</span></div>
|
<div class="text-sm font-semibold text-cyan-300">
|
||||||
<div>Bucket: <span class="text-slate-200">{snapshot.config?.s3Bucket || '—'}</span></div>
|
<span class="text-slate-400 font-normal">Local:</span>
|
||||||
<div>Dest: <span class="text-slate-200">{snapshot.config?.localDest || '—'}</span></div>
|
{#if snapshot.config?.localDest}
|
||||||
|
{snapshot.config.localDest}
|
||||||
|
{:else}
|
||||||
|
<span class="italic text-slate-500">non configure</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm font-semibold text-cyan-300">
|
||||||
|
<span class="text-slate-400 font-normal">S3:</span>
|
||||||
|
{#if snapshot.config?.s3Bucket}
|
||||||
|
s3://{snapshot.config.s3Bucket}/
|
||||||
|
{:else}
|
||||||
|
<span class="italic text-slate-500">non configure</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-slate-500">Session: {snapshot.sessionID || '—'}</div>
|
||||||
|
<button on:click={openSettings} class="mt-1 px-3 py-1 bg-slate-700 hover:bg-slate-600 rounded-lg text-sm font-semibold">
|
||||||
|
Reglages
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -99,26 +192,116 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if s.speedMBs}<div class="text-xs text-slate-400">{s.speedMBs.toFixed(1)} MB/s</div>{/if}
|
{#if s.speedMBs}<div class="text-xs text-slate-400">{s.speedMBs.toFixed(1)} MB/s</div>{/if}
|
||||||
{#if s.error}<div class="text-xs text-red-400 mt-1">⚠ {s.error}</div>{/if}
|
{#if s.error}<div class="text-xs text-red-400 mt-1">Erreur: {s.error}</div>{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<footer class="flex gap-3 items-center mt-auto border-t border-slate-700 pt-4">
|
<footer class="flex gap-3 items-center mt-auto border-t border-slate-700 pt-4">
|
||||||
<button on:click={refresh} class="px-5 py-3 bg-slate-700 hover:bg-slate-600 rounded-lg font-semibold">↻ Rescan</button>
|
<button on:click={refresh} class="px-5 py-3 bg-slate-700 hover:bg-slate-600 rounded-lg font-semibold">Rescan</button>
|
||||||
<button on:click={start} disabled={snapshot.running || cards.length===0}
|
<button on:click={start} disabled={snapshot.running || cards.length===0}
|
||||||
class="px-6 py-3 bg-cyan-600 hover:bg-cyan-500 disabled:bg-slate-700 disabled:text-slate-500 rounded-lg font-bold text-lg">
|
class="px-6 py-3 bg-cyan-600 hover:bg-cyan-500 disabled:bg-slate-700 disabled:text-slate-500 rounded-lg font-bold text-lg">
|
||||||
▶ START INGEST
|
START INGEST
|
||||||
</button>
|
</button>
|
||||||
<button on:click={cancel} disabled={!snapshot.running}
|
<button on:click={cancel} disabled={!snapshot.running}
|
||||||
class="px-5 py-3 bg-red-700 hover:bg-red-600 disabled:bg-slate-700 disabled:text-slate-500 rounded-lg font-semibold">
|
class="px-5 py-3 bg-red-700 hover:bg-red-600 disabled:bg-slate-700 disabled:text-slate-500 rounded-lg font-semibold">
|
||||||
✕ Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<label class="flex items-center gap-2 ml-4 text-sm cursor-pointer">
|
<label class="flex items-center gap-2 ml-4 text-sm cursor-pointer">
|
||||||
<input type="checkbox" bind:checked={dryRun} on:change={toggleDryRun} class="w-4 h-4">
|
<input type="checkbox" bind:checked={dryRun} on:change={toggleDryRun} class="w-4 h-4">
|
||||||
Dry-run
|
Dry-run
|
||||||
</label>
|
</label>
|
||||||
<div class="ml-auto text-xs text-slate-500">{cards.length} carte(s) détectée(s)</div>
|
<div class="ml-auto text-xs text-slate-500">{cards.length} carte(s) detectee(s)</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Settings Modal -->
|
||||||
|
{#if showSettings}
|
||||||
|
<div class="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
|
||||||
|
<div class="bg-slate-900 rounded-2xl border border-slate-600 shadow-2xl w-full max-w-lg p-6 flex flex-col gap-4">
|
||||||
|
<h2 class="text-xl font-bold text-cyan-300">Reglages</h2>
|
||||||
|
|
||||||
|
{#if settingsMsg}
|
||||||
|
<div class="text-sm text-amber-400 bg-amber-900/30 rounded-lg px-3 py-2">{settingsMsg}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Config path -->
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-slate-500 mb-1 block">Fichier config (info)</label>
|
||||||
|
<div class="text-xs text-slate-400 bg-slate-800 rounded px-3 py-2 font-mono truncate">{cfgPath || '...'}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Local dest -->
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-slate-400 mb-1 block font-semibold">Dossier local</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input type="text" bind:value={editCfg.localDest} placeholder="ex: C:\field-sync-data"
|
||||||
|
class="flex-1 bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-cyan-500" />
|
||||||
|
<button on:click={pickFolder} class="px-3 py-2 bg-slate-700 hover:bg-slate-600 rounded-lg text-sm font-semibold whitespace-nowrap">
|
||||||
|
Parcourir...
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- AWS Profile -->
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-slate-400 mb-1 block font-semibold">Profil AWS</label>
|
||||||
|
<input type="text" bind:value={editCfg.awsProfile} placeholder="cosma"
|
||||||
|
class="w-full bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-cyan-500" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- S3 Bucket -->
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-slate-400 mb-1 block font-semibold">Bucket S3</label>
|
||||||
|
<div class="flex gap-2 mb-2">
|
||||||
|
<input type="text" bind:value={editCfg.s3Bucket} placeholder="mon-bucket"
|
||||||
|
class="flex-1 bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-cyan-500" />
|
||||||
|
<button on:click={listBuckets} disabled={loadingBuckets}
|
||||||
|
class="px-3 py-2 bg-slate-700 hover:bg-slate-600 disabled:opacity-50 rounded-lg text-sm font-semibold whitespace-nowrap">
|
||||||
|
{loadingBuckets ? '...' : 'Lister buckets'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{#if bucketList.length > 0}
|
||||||
|
<select bind:value={editCfg.s3Bucket}
|
||||||
|
class="w-full bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-cyan-500">
|
||||||
|
{#each bucketList as b}
|
||||||
|
<option value={b}>{b}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{/if}
|
||||||
|
<button on:click={testS3} disabled={!editCfg.s3Bucket}
|
||||||
|
class="mt-2 px-3 py-1.5 bg-slate-700 hover:bg-slate-600 disabled:opacity-50 rounded-lg text-sm font-semibold">
|
||||||
|
Tester acces S3
|
||||||
|
</button>
|
||||||
|
{#if s3TestResult}
|
||||||
|
<span class="ml-3 text-sm" class:text-emerald-400={s3TestOk === true} class:text-red-400={s3TestOk === false}>
|
||||||
|
{s3TestResult}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Concurrency -->
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-slate-400 mb-1 block font-semibold">Concurrence (workers)</label>
|
||||||
|
<input type="number" min="1" max="16" bind:value={editCfg.concurrency}
|
||||||
|
class="w-24 bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-cyan-500" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Buttons -->
|
||||||
|
{#if settingsMsg && settingsMsg.includes('Erreur')}
|
||||||
|
<div class="text-sm text-red-400">{settingsMsg}</div>
|
||||||
|
{/if}
|
||||||
|
<div class="flex gap-3 pt-2 border-t border-slate-700">
|
||||||
|
<button on:click={saveSettings} disabled={savingConfig}
|
||||||
|
class="flex-1 px-4 py-2 bg-cyan-600 hover:bg-cyan-500 disabled:opacity-50 rounded-lg font-bold text-sm">
|
||||||
|
{savingConfig ? 'Enregistrement...' : 'Enregistrer'}
|
||||||
|
</button>
|
||||||
|
<button on:click={() => { showSettings = false; settingsMsg = ''; }}
|
||||||
|
class="px-4 py-2 bg-slate-700 hover:bg-slate-600 rounded-lg font-semibold text-sm">
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
@@ -15,7 +17,52 @@ type Config struct {
|
|||||||
Concurrency int `json:"concurrency"`
|
Concurrency int `json:"concurrency"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load reads config from ENV (with optional .env file)
|
// Path returns the cross-OS config file path
|
||||||
|
func Path() string {
|
||||||
|
dir, _ := os.UserConfigDir()
|
||||||
|
return filepath.Join(dir, "field-sync", "config.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save writes config to disk (atomic)
|
||||||
|
func Save(cfg Config) error {
|
||||||
|
p := Path()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, _ := json.MarshalIndent(cfg, "", " ")
|
||||||
|
tmp := p + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, data, 0644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(tmp, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadFromFile reads from JSON config file (no .env)
|
||||||
|
func LoadFromFile() Config {
|
||||||
|
data, err := os.ReadFile(Path())
|
||||||
|
if err != nil {
|
||||||
|
return Config{AWSProfile: "cosma", Concurrency: 4, LocalDest: defaultLocalDest()}
|
||||||
|
}
|
||||||
|
var c Config
|
||||||
|
json.Unmarshal(data, &c)
|
||||||
|
if c.Concurrency == 0 {
|
||||||
|
c.Concurrency = 4
|
||||||
|
}
|
||||||
|
if c.AWSProfile == "" {
|
||||||
|
c.AWSProfile = "cosma"
|
||||||
|
}
|
||||||
|
if c.LocalDest == "" {
|
||||||
|
c.LocalDest = defaultLocalDest()
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultLocalDest() string {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
return filepath.Join(home, "field-sync-data")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load reads config from ENV (with optional .env file) — kept for compat
|
||||||
func Load(envFile string) Config {
|
func Load(envFile string) Config {
|
||||||
_ = godotenv.Load(envFile) // ignore if missing
|
_ = godotenv.Load(envFile) // ignore if missing
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user