Tooltip hint, loading bar, edit shortcut, export/import .lazy
- Keterangan tombol saat hover: baris hint di footer window dan di dalam dialog (bukan popup: overlay Fyne menelan klik pertama, tombol jadi perlu 2x klik) - Dialog proses sekarang pakai progress bar berjalan (bukan spinner diam) - Tombol edit per shortcut: ganti binary/URL, ikon (file/online), nama, kategori, dan lokasi; berkas .desktop + salinan Desktop ditulis ulang, berkas lama dibuang, jenis aplikasi dideteksi ulang, binary lama dihapus kalau nama berubah - Export/Import daftar shortcut ke berkas SQLite .lazy (driver murni Go modernc), skema + meta format versi, impor tidak menimpa shortcut yang sudah ada - Tes: detect kind, edit (rename/ganti binary/ikon/target), db roundtrip, merge impor, tooltip (hint muncul/hilang + klik tetap sampai ke tombol), semua paket hijau - README: bagian backup + hint tombol
This commit is contained in:
1 parent
c1aed1e286
commit
deaa30689c
19 files changed
+1362
-61
No files matched your search
@@ -0,0 +1,162 @@
|
||||
// Package db menyimpan daftar shortcut LazyRun dalam satu berkas SQLite (.lazy)
|
||||
// supaya bisa dipindah antar komputer.
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"lazyrun/internal/store"
|
||||
)
|
||||
|
||||
// FormatVersion dipakai untuk mengenali berkas .lazy buatan LazyRun.
|
||||
const FormatVersion = "1"
|
||||
|
||||
// DefaultName adalah nama berkas cadangan yang disarankan.
|
||||
const DefaultName = "lazyrun-shortcuts.lazy"
|
||||
|
||||
const schema = `
|
||||
CREATE TABLE meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE shortcuts (
|
||||
slug TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
kind TEXT,
|
||||
url TEXT,
|
||||
browser TEXT,
|
||||
exec_args TEXT,
|
||||
categories TEXT,
|
||||
binary_path TEXT,
|
||||
icon_path TEXT,
|
||||
icon_source TEXT,
|
||||
desktop_path TEXT,
|
||||
desktop_copy_path TEXT,
|
||||
menu INTEGER NOT NULL,
|
||||
desktop INTEGER NOT NULL,
|
||||
installed_at TEXT
|
||||
);`
|
||||
|
||||
// Export menulis seluruh daftar shortcut ke berkas SQLite baru.
|
||||
func Export(path string, appVersion string, items []store.Shortcut) error {
|
||||
if path == "" {
|
||||
return errors.New("lokasi berkas ekspor kosong")
|
||||
}
|
||||
_ = os.Remove(path) // mulai dari berkas baru
|
||||
|
||||
conn, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if _, err := conn.Exec(schema); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
meta := map[string]string{
|
||||
"format": FormatVersion,
|
||||
"app_version": appVersion,
|
||||
"exported_at": time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
for key, value := range meta {
|
||||
if _, err := conn.Exec("INSERT INTO meta(key, value) VALUES(?, ?)", key, value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
tx, err := conn.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
insert, err := tx.Prepare(`INSERT INTO shortcuts(
|
||||
slug, name, type, kind, url, browser, exec_args, categories,
|
||||
binary_path, icon_path, icon_source, desktop_path, desktop_copy_path,
|
||||
menu, desktop, installed_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
defer insert.Close()
|
||||
|
||||
for _, item := range items {
|
||||
_, err := insert.Exec(
|
||||
item.Slug, item.Name, item.Type, item.Kind, item.URL, item.Browser,
|
||||
strings.Join(item.ExecArgs, "\x00"), item.Categories,
|
||||
item.BinaryPath, item.IconPath, item.IconSource, item.DesktopPath,
|
||||
item.DesktopCopyPath, boolToInt(item.Targets.Menu), boolToInt(item.Targets.Desktop),
|
||||
item.InstalledAt.Format(time.RFC3339),
|
||||
)
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// Import membaca daftar shortcut dari berkas .lazy.
|
||||
func Import(path string) ([]store.Shortcut, error) {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
conn, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
var format string
|
||||
if err := conn.QueryRow("SELECT value FROM meta WHERE key = 'format'").Scan(&format); err != nil {
|
||||
return nil, fmt.Errorf("bukan basis data LazyRun: %w", err)
|
||||
}
|
||||
if format != FormatVersion {
|
||||
return nil, fmt.Errorf("versi basis data %s tidak didukung (mendukung %s)", format, FormatVersion)
|
||||
}
|
||||
|
||||
rows, err := conn.Query(`SELECT slug, name, type, kind, url, browser, exec_args,
|
||||
categories, binary_path, icon_path, icon_source, desktop_path,
|
||||
desktop_copy_path, menu, desktop, installed_at FROM shortcuts ORDER BY name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var items []store.Shortcut
|
||||
for rows.Next() {
|
||||
var item store.Shortcut
|
||||
var args, installed string
|
||||
var menu, desktop int
|
||||
if err := rows.Scan(&item.Slug, &item.Name, &item.Type, &item.Kind, &item.URL,
|
||||
&item.Browser, &args, &item.Categories, &item.BinaryPath, &item.IconPath,
|
||||
&item.IconSource, &item.DesktopPath, &item.DesktopCopyPath,
|
||||
&menu, &desktop, &installed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if args != "" {
|
||||
item.ExecArgs = strings.Split(args, "\x00")
|
||||
}
|
||||
item.Targets = store.Targets{Menu: menu != 0, Desktop: desktop != 0}
|
||||
if t, err := time.Parse(time.RFC3339, installed); err == nil {
|
||||
item.InstalledAt = t
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func boolToInt(v bool) int {
|
||||
if v {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"lazyrun/internal/store"
|
||||
)
|
||||
|
||||
func sampleItems() []store.Shortcut {
|
||||
return []store.Shortcut{
|
||||
{
|
||||
Slug: "winbox",
|
||||
Name: "Winbox",
|
||||
Type: store.TypeExecutable,
|
||||
Kind: "windows",
|
||||
ExecArgs: []string{"/usr/bin/wine", "/home/u/.local/bin/winbox"},
|
||||
Categories: "Network;",
|
||||
BinaryPath: "/home/u/.local/bin/winbox",
|
||||
IconPath: "/home/u/.local/share/icons/winbox.png",
|
||||
IconSource: "search:winbox",
|
||||
DesktopPath: "/home/u/.local/share/applications/winbox.desktop",
|
||||
Targets: store.Targets{Menu: true, Desktop: true},
|
||||
InstalledAt: time.Date(2026, 10, 13, 10, 0, 0, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
Slug: "whatsapp-web",
|
||||
Name: "WhatsApp Web",
|
||||
Type: store.TypeWebApp,
|
||||
Kind: "pwa",
|
||||
URL: "https://web.whatsapp.com",
|
||||
Browser: "flatpak run org.chromium.Chromium",
|
||||
ExecArgs: []string{"flatpak", "run", "org.chromium.Chromium", "--app=https://web.whatsapp.com"},
|
||||
Categories: "Network;WebBrowser;",
|
||||
DesktopPath: "/home/u/.local/share/applications/whatsapp-web.desktop",
|
||||
DesktopCopyPath: "/home/u/Desktop/whatsapp-web.desktop",
|
||||
Targets: store.Targets{Menu: true},
|
||||
InstalledAt: time.Date(2026, 10, 13, 10, 5, 0, 0, time.UTC),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportImportRoundtrip(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "db.lazy")
|
||||
items := sampleItems()
|
||||
|
||||
if err := Export(path, "1.2.0", items); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("berkas ekspor tidak ada: %v", err)
|
||||
}
|
||||
|
||||
got, err := Import(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != len(items) {
|
||||
t.Fatalf("impor %d item, mau %d", len(got), len(items))
|
||||
}
|
||||
|
||||
// urutan hasil impor berdasarkan nama: WhatsApp Web lalu Winbox
|
||||
web, exe := got[0], got[1]
|
||||
if web.Slug != "whatsapp-web" || web.URL != "https://web.whatsapp.com" || !web.Targets.Menu || web.Targets.Desktop {
|
||||
t.Errorf("data webapp tidak utuh: %+v", web)
|
||||
}
|
||||
if exe.Slug != "winbox" || exe.Kind != "windows" || len(exe.ExecArgs) != 2 || exe.ExecArgs[0] != "/usr/bin/wine" {
|
||||
t.Errorf("data executable tidak utuh: %+v", exe)
|
||||
}
|
||||
if exe.Categories != "Network;" || !exe.Targets.Desktop {
|
||||
t.Errorf("kategori/target hilang: %+v", exe)
|
||||
}
|
||||
if !exe.InstalledAt.Equal(items[0].InstalledAt) {
|
||||
t.Errorf("installed_at berubah: %v", exe.InstalledAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportOverwritesOldFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "db.lazy")
|
||||
if err := os.WriteFile(path, []byte("bukan sqlite"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Export(path, "1.2.0", sampleItems()[:1]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items, err := Import(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Errorf("isi setelah timpa: %d item", len(items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportRejectsForeignFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
plain := filepath.Join(dir, "catatan.lazy")
|
||||
if err := os.WriteFile(plain, []byte("halo dunia"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Import(plain); err == nil {
|
||||
t.Error("berkas bukan SQLite harus ditolak")
|
||||
}
|
||||
|
||||
if _, err := Import(filepath.Join(dir, "tidak-ada.lazy")); err == nil {
|
||||
t.Error("berkas tidak ada harus error")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user