Compare commits

..

33 Commits

Author SHA1 Message Date
c8d8298274 chore(owlry-core): bump version to 1.2.0 2026-03-28 12:26:15 +01:00
62f6e1d4b0 docs: update README for built-in providers migration
Calculator, converter, and system are now built into owlry-core.
Remove meta package references. Update install instructions and
package tables.
2026-03-28 12:25:33 +01:00
bf1d759cb2 chore: remove retired meta package AUR dirs
owlry-meta-essentials, owlry-meta-full, owlry-meta-tools, and
owlry-meta-widgets have been deleted from AUR. Remove local dirs.
2026-03-28 12:24:27 +01:00
3f9f4bb112 feat(core): skip native plugins that conflict with built-in providers
When users upgrade owlry-core but still have old .so plugins installed,
the conflict detection skips the native plugin to prevent duplicate
results.
2026-03-28 12:22:13 +01:00
c5f1f35167 feat(core): register built-in providers in ProviderManager
Calculator and converter registered as built-in dynamic providers.
System registered as built-in static provider. All gated by config
toggles (calculator, converter, system — default true).
2026-03-28 12:19:12 +01:00
81626c33dd feat(core): add built-in converter provider 2026-03-28 12:14:31 +01:00
99d38a66b8 feat(core): add built-in system provider 2026-03-28 12:09:19 +01:00
8b4c704501 feat(core): add built-in calculator provider 2026-03-28 12:07:43 +01:00
27e296e333 feat(core): add DynamicProvider trait and builtin_dynamic support
Foundation for built-in calculator, converter, and system providers.
DynamicProvider trait for per-keystroke providers. ProviderManager
iterates builtin_dynamic alongside native dynamic plugins in search.
2026-03-28 12:03:45 +01:00
173d72ad43 docs: add built-in providers migration implementation plan 2026-03-28 11:59:00 +01:00
3eea902c7f docs: add built-in providers migration design spec 2026-03-28 11:52:58 +01:00
a12e850c94 fix(ui): remove periodic re-query that reset selection position
The 5-second timer emitted 'changed' on the search entry in daemon
mode, triggering a full re-query that rebuilt the result list and
selected row 0 — jumping the user back to the top while browsing.

Widget refresh is a daemon-side concern; the UI gets updated data
on the next user-initiated search. Only keep the timer for local
(dmenu) mode where the UI owns the providers directly.
2026-03-28 11:41:37 +01:00
eccfb217d4 chore(aur): update owlry 1.0.5, owlry-core 1.1.3 2026-03-28 11:35:57 +01:00
c3c35611fd chore(owlry-core): bump version to 1.1.3 2026-03-28 11:35:23 +01:00
5ecd0a6412 chore(owlry): bump version to 1.0.5 2026-03-28 11:35:22 +01:00
6fe7213b6f fix(core): group auto-detect plugin results together in ranking
Calculator and converter results now get a 10k grouping bonus so all
their results stay together above websearch/filesearch. Previously
websearch (priority 9000) would interleave with converter results
(9000, 8999, 8998...) since they had the same base priority.
2026-03-28 11:34:26 +01:00
b768bfd181 chore(ui): remove dead update_results method 2026-03-28 11:30:40 +01:00
c9a1ff28f4 fix(ui): only highlight calc and converter, not websearch/filesearch
Websearch is a generic fallback — it always shows a result, so
highlighting it adds no signal. Filesearch returns fuzzy matches,
not auto-detected conversions. Only calc and conv produce direct
answers that deserve highlighting.
2026-03-28 11:28:37 +01:00
623572ec14 fix: use git add -A in aur-publish-pkg 2026-03-28 11:20:57 +01:00
5196255594 chore(aur): update owlry 1.0.4, owlry-core 1.1.2 2026-03-28 11:19:05 +01:00
b87447156e chore(owlry-core): bump version to 1.1.2 2026-03-28 11:18:27 +01:00
12d554959a chore(owlry): bump version to 1.0.4 2026-03-28 11:18:26 +01:00
83fa22d84c feat(ui): add result highlighting and remove window shadow
Highlighting:
- Dynamic plugin results (calculator, converter, websearch, filesearch)
  get a subtle accent left-border + background tint when auto-detected
- Exact name matches (case-insensitive) are highlighted the same way
- Exact match on apps gets a higher score boost (50k) than other
  providers (30k), so apps rank first when names match exactly

Shadow:
- Removed hardcoded box-shadow from all theme CSS files
- Added --owlry-shadow variable in base.css (defaults to none)
- Themes can opt into shadow via --owlry-shadow if desired

CSS class: .owlry-result-highlight on ResultRow
2026-03-28 11:17:45 +01:00
ade5d3aeef fix(ui): check icon theme exists on disk before fallback
has_icon() returns true even for broken themes since it checks all
search paths. Instead, verify the theme directory actually exists
in the search path. Falls back to Adwaita only when the configured
theme is genuinely missing from disk.
2026-03-28 11:08:01 +01:00
617c943147 fix: aur-stage glob handling for packages without .install files 2026-03-28 10:51:46 +01:00
1b1e12124b chore(aur): update owlry PKGBUILD to 1.0.3 2026-03-28 10:49:57 +01:00
94556f1fe0 chore(owlry): bump version to 1.0.3 2026-03-28 10:48:55 +01:00
2b98f0651c fix(ui): fall back to Adwaita when system icon theme is broken
If the configured icon theme (e.g. Sweet-Blue) doesn't exist on disk,
GTK falls back to hicolor which has almost no icons. Detect this by
probing for a standard icon, and set Adwaita as the theme — it's
guaranteed to exist as a GTK4 dependency.

This replaces the broken add_search_path("/usr/share/icons/Adwaita")
approach which doesn't work because search paths are scoped to the
active theme name, not the directory name.
2026-03-28 10:48:39 +01:00
75fa770c94 chore: overhaul justfile for current deployment pipeline
Key fixes:
- aur-update-pkg uses correct per-crate tag URLs ({crate}-v{version})
- tag-crate creates per-crate tags instead of generic v{version}
- aur-stage handles embedded .git dirs in AUR subdirectories
- aur-commit stages all AUR files with .git workaround
- release-crate does full pipeline: bump → push → tag → AUR update → publish
- Removed stale release-core recipe that used wrong tag format
2026-03-28 10:31:32 +01:00
c6ba91f06d fix(aur): restrict owlry-core check() to unit tests
The integration test (server_test) loads native plugins which segfault
in the clean makepkg build environment. Use --lib to run only unit tests.
2026-03-28 10:06:07 +01:00
235103e854 fix(aur): correct b2sums for owlry and owlry-core tarballs 2026-03-28 09:54:12 +01:00
8ccaaf28c8 docs: update README for client/daemon package split
- Separate package tables for core, plugins, and meta bundles
- Add owlry-plugin-converter to plugin list and meta-essentials
- Fix build instructions: plugins are in owlry-plugins repo
- Update plugin count to 14
- Remove dead link to gitignored CLAUDE.md
2026-03-28 09:51:29 +01:00
cfd143fe4a chore: track AUR package files (PKGBUILD, .SRCINFO)
The aur/ directory was entirely gitignored, preventing PKGBUILD and
.SRCINFO files from being tracked. Fix .gitignore to only ignore
build artifacts and nested .git dirs, matching the owlry-plugins
repo convention.
2026-03-28 09:34:21 +01:00
39 changed files with 4193 additions and 470 deletions

4
.gitignore vendored
View File

@@ -4,6 +4,7 @@ CLAUDE.md
media.md
# AUR packages (each is its own git repo for aur.archlinux.org)
# Track PKGBUILD and .SRCINFO, ignore build artifacts and sub-repo .git
aur/*/.git/
aur/*/pkg/
aur/*/src/
@@ -11,6 +12,3 @@ aur/*/*.tar.zst
aur/*/*.tar.gz
aur/*/*.tar.xz
aur/*/*.pkg.tar.*
# Keep PKGBUILD and .SRCINFO tracked
.SRCINFO
aur/

4
Cargo.lock generated
View File

@@ -2536,7 +2536,7 @@ dependencies = [
[[package]]
name = "owlry"
version = "1.0.2"
version = "1.0.5"
dependencies = [
"chrono",
"clap",
@@ -2557,7 +2557,7 @@ dependencies = [
[[package]]
name = "owlry-core"
version = "1.1.1"
version = "1.2.0"
dependencies = [
"chrono",
"ctrlc",

View File

@@ -13,7 +13,8 @@ A lightweight, owl-themed application launcher for Wayland, built with GTK4 and
- **Client/daemon architecture** — Instant window appearance, providers stay loaded in memory
- **Modular plugin architecture** — Install only what you need
- **Fuzzy search with tags** — Fast matching across names, descriptions, and category tags
- **13 native plugins** — Calculator, clipboard, emoji, weather, media, and more
- **Built-in calculator, converter, and system actions** — Works out of the box
- **11 optional plugins** — Clipboard, emoji, weather, media, and more
- **Widget providers** — Weather, media controls, and pomodoro timer at the top of results
- **Config profiles** — Named mode presets for different workflows
- **Filter prefixes** — Scope searches with `:app`, `:cmd`, `:tag:development`, etc.
@@ -28,17 +29,11 @@ A lightweight, owl-themed application launcher for Wayland, built with GTK4 and
### Arch Linux (AUR)
```bash
# Minimal core (applications + commands only)
# Core (includes calculator, converter, system actions)
yay -S owlry
# Add individual plugins
yay -S owlry-plugin-calculator owlry-plugin-weather
# Or install bundles:
yay -S owlry-meta-essentials # calculator, system, ssh, scripts, bookmarks
yay -S owlry-meta-widgets # weather, media, pomodoro
yay -S owlry-meta-tools # clipboard, emoji, websearch, filesearch, systemd
yay -S owlry-meta-full # everything
# Add individual plugins as needed
yay -S owlry-plugin-bookmarks owlry-plugin-weather owlry-plugin-clipboard
# For custom Lua/Rune plugins
yay -S owlry-lua # Lua 5.4 runtime
@@ -47,22 +42,32 @@ yay -S owlry-rune # Rune runtime
### Available Packages
**Core packages** (this repo):
| Package | Description |
|---------|-------------|
| `owlry` | Core: UI client (`owlry`) and daemon (`owlry-core`) |
| `owlry-plugin-calculator` | Math expressions (`= 5+3`) |
| `owlry-plugin-system` | Shutdown, reboot, suspend, lock |
| `owlry-plugin-ssh` | SSH hosts from `~/.ssh/config` |
| `owlry` | GTK4 UI client |
| `owlry-core` | Headless daemon with built-in calculator, converter, and system providers |
| `owlry-lua` | Lua 5.4 script runtime for user plugins |
| `owlry-rune` | Rune script runtime for user plugins |
**Plugin packages** ([owlry-plugins](https://somegit.dev/Owlibou/owlry-plugins) repo):
| Package | Description |
|---------|-------------|
| `owlry-plugin-bookmarks` | Firefox, Chrome, Brave, Edge bookmarks |
| `owlry-plugin-clipboard` | History via cliphist |
| `owlry-plugin-emoji` | 400+ searchable emoji |
| `owlry-plugin-scripts` | User scripts |
| `owlry-plugin-bookmarks` | Firefox, Chrome, Brave, Edge bookmarks |
| `owlry-plugin-websearch` | Web search (`? query`) |
| `owlry-plugin-filesearch` | File search (`/ filename`) |
| `owlry-plugin-systemd` | User services with actions |
| `owlry-plugin-weather` | Weather widget |
| `owlry-plugin-media` | MPRIS media controls |
| `owlry-plugin-pomodoro` | Pomodoro timer widget |
| `owlry-plugin-scripts` | User scripts |
| `owlry-plugin-ssh` | SSH hosts from `~/.ssh/config` |
| `owlry-plugin-systemd` | User services with actions |
| `owlry-plugin-weather` | Weather widget |
| `owlry-plugin-websearch` | Web search (`? query`) |
> **Note:** Calculator, converter, and system actions are built into `owlry-core` and no longer require separate plugin packages.
### Build from Source
@@ -83,22 +88,29 @@ sudo dnf install gtk4-devel gtk4-layer-shell-devel
git clone https://somegit.dev/Owlibou/owlry.git
cd owlry
# Build core only (daemon + UI)
# Build daemon + UI
cargo build --release -p owlry -p owlry-core
# Build specific plugin
cargo build --release -p owlry-plugin-calculator
# Build runtimes (for user plugins)
cargo build --release -p owlry-lua -p owlry-rune
# Build everything
# Build everything in this workspace
cargo build --release --workspace
```
**Plugins** are in a [separate repo](https://somegit.dev/Owlibou/owlry-plugins):
```bash
git clone https://somegit.dev/Owlibou/owlry-plugins.git
cd owlry-plugins
cargo build --release -p owlry-plugin-calculator # or any plugin
```
**Install locally:**
```bash
just install-local
```
This installs both binaries, all plugins, runtimes, and the systemd service files.
This installs the UI, daemon, runtimes, and systemd service files.
## Getting Started
@@ -457,8 +469,6 @@ owlry-core (daemon) owlry (GTK4 UI client)
The daemon keeps providers and plugins loaded in memory, so the UI appears instantly when launched. The UI client is a thin GTK4 layer that sends queries and receives results over the socket.
For detailed architecture information, see [CLAUDE.md](CLAUDE.md).
## License
GNU General Public License v3.0 — see [LICENSE](LICENSE).

13
aur/owlry-core/.SRCINFO Normal file
View File

@@ -0,0 +1,13 @@
pkgbase = owlry-core
pkgdesc = Core daemon for the Owlry application launcher — manages plugins, providers, and search
pkgver = 1.1.3
pkgrel = 1
url = https://somegit.dev/Owlibou/owlry
arch = x86_64
license = GPL-3.0-or-later
makedepends = cargo
depends = gcc-libs
source = owlry-core-1.1.3.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-core-v1.1.3.tar.gz
b2sums = 3f7b9cde30a06d96f8c1fda1be72514ac5b0e835402c1a287bfd2d8ea92284874b5fcccfcd08d249eb7f28a3b5be6a3b77c495e610fe9742a6c7b3d5084c9894
pkgname = owlry-core

41
aur/owlry-core/PKGBUILD Normal file
View File

@@ -0,0 +1,41 @@
# Maintainer: vikingowl <christian@nachtigall.dev>
pkgname=owlry-core
pkgver=1.1.3
pkgrel=1
pkgdesc='Core daemon for the Owlry application launcher — manages plugins, providers, and search'
arch=('x86_64')
url='https://somegit.dev/Owlibou/owlry'
license=('GPL-3.0-or-later')
depends=('gcc-libs')
makedepends=('cargo')
source=("$pkgname-$pkgver.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-core-v$pkgver.tar.gz")
b2sums=('3f7b9cde30a06d96f8c1fda1be72514ac5b0e835402c1a287bfd2d8ea92284874b5fcccfcd08d249eb7f28a3b5be6a3b77c495e610fe9742a6c7b3d5084c9894')
prepare() {
cd "owlry"
export RUSTUP_TOOLCHAIN=stable
cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')"
}
build() {
cd "owlry"
export RUSTUP_TOOLCHAIN=stable
export CARGO_TARGET_DIR=target
cargo build -p owlry-core --frozen --release
}
check() {
cd "owlry"
export RUSTUP_TOOLCHAIN=stable
export CARGO_TARGET_DIR=target
cargo test -p owlry-core --frozen --lib
}
package() {
cd "owlry"
install -Dm755 "target/release/owlry-core" "$pkgdir/usr/bin/owlry-core"
install -Dm644 "systemd/owlry-core.service" "$pkgdir/usr/lib/systemd/user/owlry-core.service"
install -Dm644 "systemd/owlry-core.socket" "$pkgdir/usr/lib/systemd/user/owlry-core.socket"
install -dm755 "$pkgdir/usr/lib/owlry/plugins"
install -dm755 "$pkgdir/usr/lib/owlry/runtimes"
}

13
aur/owlry-lua/.SRCINFO Normal file
View File

@@ -0,0 +1,13 @@
pkgbase = owlry-lua
pkgdesc = Lua scripting runtime for Owlry — enables user-created Lua plugins
pkgver = 1.1.0
pkgrel = 1
url = https://somegit.dev/Owlibou/owlry
arch = x86_64
license = GPL-3.0-or-later
makedepends = cargo
depends = owlry-core
source = owlry-lua-1.1.0.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-lua-v1.1.0.tar.gz
b2sums = d4b200446a31301b1240fd8eede6e10764d7bbc551f2e5549bfdbdcc0fa4a717677c3c2c69778d2dfa336711ac5b74d4987e46082ea589fed961c9d2ff95af76
pkgname = owlry-lua

40
aur/owlry-lua/PKGBUILD Normal file
View File

@@ -0,0 +1,40 @@
# Maintainer: vikingowl <christian@nachtigall.dev>
pkgname=owlry-lua
pkgver=1.1.0
pkgrel=1
pkgdesc="Lua scripting runtime for Owlry — enables user-created Lua plugins"
arch=('x86_64')
url="https://somegit.dev/Owlibou/owlry"
license=('GPL-3.0-or-later')
depends=('owlry-core')
makedepends=('cargo')
source=("$pkgname-$pkgver.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-lua-v$pkgver.tar.gz")
b2sums=('d4b200446a31301b1240fd8eede6e10764d7bbc551f2e5549bfdbdcc0fa4a717677c3c2c69778d2dfa336711ac5b74d4987e46082ea589fed961c9d2ff95af76')
_cratename=owlry-lua
prepare() {
cd "owlry"
export RUSTUP_TOOLCHAIN=stable
cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')"
}
build() {
cd "owlry"
export RUSTUP_TOOLCHAIN=stable
export CARGO_TARGET_DIR=target
cargo build -p $_cratename --frozen --release
}
check() {
cd "owlry"
export RUSTUP_TOOLCHAIN=stable
export CARGO_TARGET_DIR=target
cargo test -p $_cratename --frozen --release
}
package() {
cd "owlry"
install -Dm755 "target/release/lib${_cratename//-/_}.so" \
"$pkgdir/usr/lib/owlry/runtimes/liblua.so"
}

13
aur/owlry-rune/.SRCINFO Normal file
View File

@@ -0,0 +1,13 @@
pkgbase = owlry-rune
pkgdesc = Rune scripting runtime for Owlry — enables user-created Rune plugins
pkgver = 1.1.0
pkgrel = 1
url = https://somegit.dev/Owlibou/owlry
arch = x86_64
license = GPL-3.0-or-later
makedepends = cargo
depends = owlry-core
source = owlry-rune-1.1.0.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-rune-v1.1.0.tar.gz
b2sums = d4b200446a31301b1240fd8eede6e10764d7bbc551f2e5549bfdbdcc0fa4a717677c3c2c69778d2dfa336711ac5b74d4987e46082ea589fed961c9d2ff95af76
pkgname = owlry-rune

40
aur/owlry-rune/PKGBUILD Normal file
View File

@@ -0,0 +1,40 @@
# Maintainer: vikingowl <christian@nachtigall.dev>
pkgname=owlry-rune
pkgver=1.1.0
pkgrel=1
pkgdesc="Rune scripting runtime for Owlry — enables user-created Rune plugins"
arch=('x86_64')
url="https://somegit.dev/Owlibou/owlry"
license=('GPL-3.0-or-later')
depends=('owlry-core')
makedepends=('cargo')
source=("$pkgname-$pkgver.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-rune-v$pkgver.tar.gz")
b2sums=('d4b200446a31301b1240fd8eede6e10764d7bbc551f2e5549bfdbdcc0fa4a717677c3c2c69778d2dfa336711ac5b74d4987e46082ea589fed961c9d2ff95af76')
_cratename=owlry-rune
prepare() {
cd "owlry"
export RUSTUP_TOOLCHAIN=stable
cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')"
}
build() {
cd "owlry"
export RUSTUP_TOOLCHAIN=stable
export CARGO_TARGET_DIR=target
cargo build -p $_cratename --frozen --release
}
check() {
cd "owlry"
export RUSTUP_TOOLCHAIN=stable
export CARGO_TARGET_DIR=target
cargo test -p $_cratename --frozen --release
}
package() {
cd "owlry"
install -Dm755 "target/release/lib${_cratename//-/_}.so" \
"$pkgdir/usr/lib/owlry/runtimes/librune.so"
}

34
aur/owlry/.SRCINFO Normal file
View File

@@ -0,0 +1,34 @@
pkgbase = owlry
pkgdesc = Lightweight Wayland application launcher with plugin support
pkgver = 1.0.5
pkgrel = 1
url = https://somegit.dev/Owlibou/owlry
arch = x86_64
license = GPL-3.0-or-later
makedepends = cargo
depends = owlry-core
depends = gcc-libs
depends = gtk4
depends = gtk4-layer-shell
optdepends = cliphist: clipboard provider support
optdepends = wl-clipboard: clipboard and emoji copy support
optdepends = fd: fast file search
optdepends = owlry-plugin-calculator: calculator provider
optdepends = owlry-plugin-clipboard: clipboard provider
optdepends = owlry-plugin-emoji: emoji picker
optdepends = owlry-plugin-bookmarks: browser bookmarks
optdepends = owlry-plugin-ssh: SSH host launcher
optdepends = owlry-plugin-scripts: custom scripts provider
optdepends = owlry-plugin-system: system actions (shutdown, reboot, etc.)
optdepends = owlry-plugin-websearch: web search provider
optdepends = owlry-plugin-filesearch: file search provider
optdepends = owlry-plugin-systemd: systemd service management
optdepends = owlry-plugin-weather: weather widget
optdepends = owlry-plugin-media: media player controls
optdepends = owlry-plugin-pomodoro: pomodoro timer widget
optdepends = owlry-lua: Lua runtime for user plugins
optdepends = owlry-rune: Rune runtime for user plugins
source = owlry-1.0.5.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-v1.0.5.tar.gz
b2sums = 3f7b9cde30a06d96f8c1fda1be72514ac5b0e835402c1a287bfd2d8ea92284874b5fcccfcd08d249eb7f28a3b5be6a3b77c495e610fe9742a6c7b3d5084c9894
pkgname = owlry

76
aur/owlry/PKGBUILD Normal file
View File

@@ -0,0 +1,76 @@
# Maintainer: vikingowl <christian@nachtigall.dev>
pkgname=owlry
pkgver=1.0.5
pkgrel=1
pkgdesc="Lightweight Wayland application launcher with plugin support"
arch=('x86_64')
url="https://somegit.dev/Owlibou/owlry"
license=('GPL-3.0-or-later')
depends=('owlry-core' 'gcc-libs' 'gtk4' 'gtk4-layer-shell')
makedepends=('cargo')
optdepends=(
'cliphist: clipboard provider support'
'wl-clipboard: clipboard and emoji copy support'
'fd: fast file search'
'owlry-plugin-calculator: calculator provider'
'owlry-plugin-clipboard: clipboard provider'
'owlry-plugin-emoji: emoji picker'
'owlry-plugin-bookmarks: browser bookmarks'
'owlry-plugin-ssh: SSH host launcher'
'owlry-plugin-scripts: custom scripts provider'
'owlry-plugin-system: system actions (shutdown, reboot, etc.)'
'owlry-plugin-websearch: web search provider'
'owlry-plugin-filesearch: file search provider'
'owlry-plugin-systemd: systemd service management'
'owlry-plugin-weather: weather widget'
'owlry-plugin-media: media player controls'
'owlry-plugin-pomodoro: pomodoro timer widget'
'owlry-lua: Lua runtime for user plugins'
'owlry-rune: Rune runtime for user plugins'
)
source=("$pkgname-$pkgver.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-v$pkgver.tar.gz")
b2sums=('3f7b9cde30a06d96f8c1fda1be72514ac5b0e835402c1a287bfd2d8ea92284874b5fcccfcd08d249eb7f28a3b5be6a3b77c495e610fe9742a6c7b3d5084c9894')
prepare() {
cd "owlry"
export RUSTUP_TOOLCHAIN=stable
cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')"
}
build() {
cd "owlry"
export RUSTUP_TOOLCHAIN=stable
export CARGO_TARGET_DIR=target
# Build only the core binary without embedded Lua (Lua runtime is separate package)
cargo build -p owlry --frozen --release --no-default-features
}
check() {
cd "owlry"
export RUSTUP_TOOLCHAIN=stable
export CARGO_TARGET_DIR=target
cargo test -p owlry --frozen --no-default-features
}
package() {
cd "owlry"
# Core binary
install -Dm755 "target/release/$pkgname" "$pkgdir/usr/bin/$pkgname"
# Documentation
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
# Example configuration files
install -Dm644 data/config.example.toml "$pkgdir/usr/share/doc/$pkgname/config.example.toml"
install -Dm644 data/style.example.css "$pkgdir/usr/share/doc/$pkgname/style.example.css"
install -Dm755 data/scripts/example.sh "$pkgdir/usr/share/doc/$pkgname/scripts/example.sh"
# Install themes
install -d "$pkgdir/usr/share/$pkgname/themes"
install -Dm644 data/themes/*.css "$pkgdir/usr/share/$pkgname/themes/"
# Example plugins (for user plugin development)
install -d "$pkgdir/usr/share/$pkgname/examples/plugins"
cp -r examples/plugins/* "$pkgdir/usr/share/$pkgname/examples/plugins/"
}

View File

@@ -1,6 +1,6 @@
[package]
name = "owlry-core"
version = "1.1.1"
version = "1.2.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
@@ -48,15 +48,17 @@ log = "0.4"
env_logger = "0.11"
notify-rust = "4"
# Built-in providers
meval = "0.2"
reqwest = { version = "0.13", default-features = false, features = ["rustls", "json", "blocking"] }
# Optional: embedded Lua runtime
mlua = { version = "0.11", features = ["lua54", "vendored", "send", "serialize"], optional = true }
meval = { version = "0.2", optional = true }
reqwest = { version = "0.13", default-features = false, features = ["rustls", "json", "blocking"], optional = true }
[dev-dependencies]
tempfile = "3"
[features]
default = []
lua = ["dep:mlua", "dep:meval", "dep:reqwest"]
lua = ["dep:mlua"]
dev-logging = []

View File

@@ -162,6 +162,9 @@ pub struct ProvidersConfig {
/// Enable calculator provider (= expression or calc expression)
#[serde(default = "default_true")]
pub calculator: bool,
/// Enable converter provider (> expression or auto-detect)
#[serde(default = "default_true")]
pub converter: bool,
/// Enable frecency-based result ranking
#[serde(default = "default_true")]
pub frecency: bool,
@@ -239,6 +242,7 @@ impl Default for ProvidersConfig {
commands: true,
uuctl: true,
calculator: true,
converter: true,
frecency: true,
frecency_weight: 0.3,
websearch: true,

View File

@@ -0,0 +1,237 @@
use super::{DynamicProvider, LaunchItem, ProviderType};
/// Built-in calculator provider. Evaluates mathematical expressions via `meval`.
///
/// Triggered by:
/// - `= expr` / `=expr` / `calc expr` (explicit prefix)
/// - Raw math expressions containing operators or known functions (auto-detect)
pub(crate) struct CalculatorProvider;
impl DynamicProvider for CalculatorProvider {
fn name(&self) -> &str {
"Calculator"
}
fn provider_type(&self) -> ProviderType {
ProviderType::Plugin("calc".into())
}
fn priority(&self) -> u32 {
10_000
}
fn query(&self, query: &str) -> Vec<LaunchItem> {
let expr = match extract_expression(query) {
Some(e) if !e.is_empty() => e,
_ => return Vec::new(),
};
match meval::eval_str(expr) {
Ok(result) => {
let display = format_result(result);
let copy_cmd = format!(
"printf '%s' '{}' | wl-copy",
display.replace('\'', "'\\''")
);
vec![LaunchItem {
id: format!("calc:{}", expr),
name: display.clone(),
description: Some(format!("= {}", expr)),
icon: Some("accessories-calculator".into()),
provider: ProviderType::Plugin("calc".into()),
command: copy_cmd,
terminal: false,
tags: vec!["math".into(), "calculator".into()],
}]
}
Err(_) => Vec::new(),
}
}
}
/// Extract the math expression from a query string.
///
/// Handles:
/// - `= expr` and `=expr` (explicit calculator prefix)
/// - `calc expr` (word prefix)
/// - Raw expressions if they look like math (auto-detect)
///
/// Returns `None` only when input is empty after trimming.
fn extract_expression(query: &str) -> Option<&str> {
let trimmed = query.trim();
if trimmed.is_empty() {
return None;
}
// Explicit prefixes
if let Some(rest) = trimmed.strip_prefix("= ") {
return Some(rest.trim());
}
if let Some(rest) = trimmed.strip_prefix('=') {
return Some(rest.trim());
}
if let Some(rest) = trimmed.strip_prefix("calc ") {
return Some(rest.trim());
}
// Auto-detect: only forward if the expression looks like math.
// Plain words like "firefox" should not reach meval.
if looks_like_math(trimmed) {
Some(trimmed)
} else {
None
}
}
/// Heuristic: does this string look like a math expression?
///
/// Returns true when the string contains binary operators, digits mixed with
/// operators, or known function names. Plain alphabetic words return false.
fn looks_like_math(s: &str) -> bool {
// Must contain at least one digit or a known constant/function name
let has_digit = s.chars().any(|c| c.is_ascii_digit());
let has_operator = s.contains('+')
|| s.contains('*')
|| s.contains('/')
|| s.contains('^')
|| s.contains('%');
// Subtraction/negation is ambiguous; only count it as an operator when
// there are already digits present to avoid matching bare words with hyphens.
let has_minus_operator = has_digit && s.contains('-');
// Known math functions that are safe to auto-evaluate
const MATH_FUNCTIONS: &[&str] = &[
"sqrt", "sin", "cos", "tan", "log", "ln", "abs", "floor", "ceil", "round",
];
let has_function = MATH_FUNCTIONS.iter().any(|f| s.contains(f));
has_digit && (has_operator || has_minus_operator) || has_function
}
/// Format a floating-point result for display.
///
/// Integer-valued results are shown as integers with thousands separators.
/// Non-integer results are shown with up to 10 decimal places, trailing zeros trimmed.
fn format_result(result: f64) -> String {
if result.fract() == 0.0 && result.abs() < 1e15 {
format_integer_with_separators(result as i64)
} else {
let formatted = format!("{:.10}", result);
formatted
.trim_end_matches('0')
.trim_end_matches('.')
.to_string()
}
}
fn format_integer_with_separators(n: i64) -> String {
let s = n.unsigned_abs().to_string();
let with_commas = s
.as_bytes()
.rchunks(3)
.rev()
.map(|chunk| std::str::from_utf8(chunk).unwrap())
.collect::<Vec<_>>()
.join(",");
if n < 0 {
format!("-{}", with_commas)
} else {
with_commas
}
}
#[cfg(test)]
mod tests {
use super::*;
fn query(q: &str) -> Vec<LaunchItem> {
CalculatorProvider.query(q)
}
// --- Trigger prefix tests ---
#[test]
fn equals_prefix_addition() {
let results = query("= 5+3");
assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "8");
}
#[test]
fn calc_prefix_multiplication() {
let results = query("calc 10*2");
assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "20");
}
// --- Auto-detect tests ---
#[test]
fn auto_detect_addition() {
let results = query("5+3");
assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "8");
}
#[test]
fn equals_prefix_complex_expression() {
let results = query("= sqrt(16) + 2^3");
assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "12");
}
#[test]
fn decimal_result() {
let results = query("= 10/3");
assert_eq!(results.len(), 1);
assert!(
results[0].name.starts_with("3.333"),
"expected result starting with 3.333, got: {}",
results[0].name
);
}
#[test]
fn large_integer_thousands_separators() {
let results = query("= 1000000");
assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "1,000,000");
}
// --- Invalid / non-math input ---
#[test]
fn invalid_expression_returns_empty() {
let results = query("= 5 +");
assert!(results.is_empty());
}
#[test]
fn plain_text_returns_empty() {
let results = query("firefox");
assert!(results.is_empty());
}
// --- Metadata tests ---
#[test]
fn provider_type_is_calc_plugin() {
assert_eq!(
CalculatorProvider.provider_type(),
ProviderType::Plugin("calc".into())
);
}
#[test]
fn description_shows_expression() {
let results = query("= 5+3");
assert_eq!(results[0].description.as_deref(), Some("= 5+3"));
}
#[test]
fn copy_command_contains_wl_copy() {
let results = query("= 5+3");
assert!(results[0].command.contains("wl-copy"));
}
}

View File

@@ -0,0 +1,313 @@
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::sync::Mutex;
use std::time::SystemTime;
use serde::{Deserialize, Serialize};
const ECB_URL: &str = "https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml";
const CACHE_MAX_AGE_SECS: u64 = 86400; // 24 hours
static CACHED_RATES: Mutex<Option<CurrencyRates>> = Mutex::new(None);
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CurrencyRates {
pub date: String,
pub rates: HashMap<String, f64>,
}
struct CurrencyAlias {
code: &'static str,
aliases: &'static [&'static str],
}
static CURRENCY_ALIASES: &[CurrencyAlias] = &[
CurrencyAlias {
code: "EUR",
aliases: &["eur", "euro", "euros", ""],
},
CurrencyAlias {
code: "USD",
aliases: &["usd", "dollar", "dollars", "$", "us_dollar"],
},
CurrencyAlias {
code: "GBP",
aliases: &["gbp", "pound_sterling", "£", "british_pound", "pounds"],
},
CurrencyAlias {
code: "JPY",
aliases: &["jpy", "yen", "¥", "japanese_yen"],
},
CurrencyAlias {
code: "CHF",
aliases: &["chf", "swiss_franc", "francs"],
},
CurrencyAlias {
code: "CAD",
aliases: &["cad", "canadian_dollar", "c$"],
},
CurrencyAlias {
code: "AUD",
aliases: &["aud", "australian_dollar", "a$"],
},
CurrencyAlias {
code: "CNY",
aliases: &["cny", "yuan", "renminbi", "rmb"],
},
CurrencyAlias {
code: "SEK",
aliases: &["sek", "swedish_krona", "kronor"],
},
CurrencyAlias {
code: "NOK",
aliases: &["nok", "norwegian_krone"],
},
CurrencyAlias {
code: "DKK",
aliases: &["dkk", "danish_krone"],
},
CurrencyAlias {
code: "PLN",
aliases: &["pln", "zloty", "złoty"],
},
CurrencyAlias {
code: "CZK",
aliases: &["czk", "czech_koruna"],
},
CurrencyAlias {
code: "HUF",
aliases: &["huf", "forint"],
},
CurrencyAlias {
code: "TRY",
aliases: &["try", "turkish_lira", "lira"],
},
];
pub fn resolve_currency_code(alias: &str) -> Option<&'static str> {
let lower = alias.to_lowercase();
// Check aliases
for ca in CURRENCY_ALIASES {
if ca.aliases.contains(&lower.as_str()) {
return Some(ca.code); // ca.code is already &'static str
}
}
// Check if it's a raw 3-letter ISO code we know about
let upper = alias.to_uppercase();
if upper.len() == 3 {
if upper == "EUR" {
return Some("EUR");
}
if let Some(rates) = get_rates()
&& rates.rates.contains_key(&upper)
{
for ca in CURRENCY_ALIASES {
if ca.code == upper {
return Some(ca.code);
}
}
}
}
None
}
#[allow(dead_code)]
pub fn is_currency_alias(alias: &str) -> bool {
resolve_currency_code(alias).is_some()
}
pub fn get_rates() -> Option<CurrencyRates> {
// Check memory cache first
{
let cache = CACHED_RATES.lock().ok()?;
if let Some(ref rates) = *cache {
return Some(rates.clone());
}
}
// Try disk cache
if let Some(rates) = load_cache()
&& !is_stale(&rates)
{
let mut cache = CACHED_RATES.lock().ok()?;
*cache = Some(rates.clone());
return Some(rates);
}
// Fetch fresh rates
if let Some(rates) = fetch_rates() {
save_cache(&rates);
let mut cache = CACHED_RATES.lock().ok()?;
*cache = Some(rates.clone());
return Some(rates);
}
// Fall back to stale cache
load_cache()
}
fn cache_path() -> Option<PathBuf> {
let cache_dir = dirs::cache_dir()?.join("owlry");
Some(cache_dir.join("ecb_rates.json"))
}
fn load_cache() -> Option<CurrencyRates> {
let path = cache_path()?;
let content = fs::read_to_string(path).ok()?;
serde_json::from_str(&content).ok()
}
fn save_cache(rates: &CurrencyRates) {
if let Some(path) = cache_path() {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).ok();
}
if let Ok(json) = serde_json::to_string_pretty(rates) {
fs::write(path, json).ok();
}
}
}
fn is_stale(_rates: &CurrencyRates) -> bool {
let path = match cache_path() {
Some(p) => p,
None => return true,
};
let metadata = match fs::metadata(path) {
Ok(m) => m,
Err(_) => return true,
};
let modified = match metadata.modified() {
Ok(t) => t,
Err(_) => return true,
};
match SystemTime::now().duration_since(modified) {
Ok(age) => age.as_secs() > CACHE_MAX_AGE_SECS,
Err(_) => true,
}
}
fn fetch_rates() -> Option<CurrencyRates> {
let response = reqwest::blocking::get(ECB_URL).ok()?;
let body = response.text().ok()?;
parse_ecb_xml(&body)
}
fn parse_ecb_xml(xml: &str) -> Option<CurrencyRates> {
let mut rates = HashMap::new();
let mut date = String::new();
for line in xml.lines() {
let trimmed = line.trim();
// Extract date: <Cube time='2026-03-26'>
if trimmed.contains("time=")
&& let Some(start) = trimmed.find("time='")
{
let rest = &trimmed[start + 6..];
if let Some(end) = rest.find('\'') {
date = rest[..end].to_string();
}
}
// Extract rate: <Cube currency='USD' rate='1.0832'/>
if trimmed.contains("currency=") && trimmed.contains("rate=") {
let currency = extract_attr(trimmed, "currency")?;
let rate_str = extract_attr(trimmed, "rate")?;
let rate: f64 = rate_str.parse().ok()?;
rates.insert(currency, rate);
}
}
if rates.is_empty() {
return None;
}
Some(CurrencyRates { date, rates })
}
fn extract_attr(line: &str, attr: &str) -> Option<String> {
let needle = format!("{}='", attr);
let start = line.find(&needle)? + needle.len();
let rest = &line[start..];
let end = rest.find('\'')?;
Some(rest[..end].to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_resolve_currency_code_iso() {
assert_eq!(resolve_currency_code("usd"), Some("USD"));
assert_eq!(resolve_currency_code("EUR"), Some("EUR"));
}
#[test]
fn test_resolve_currency_code_name() {
assert_eq!(resolve_currency_code("dollar"), Some("USD"));
assert_eq!(resolve_currency_code("euro"), Some("EUR"));
assert_eq!(resolve_currency_code("pounds"), Some("GBP"));
}
#[test]
fn test_resolve_currency_code_symbol() {
assert_eq!(resolve_currency_code("$"), Some("USD"));
assert_eq!(resolve_currency_code(""), Some("EUR"));
assert_eq!(resolve_currency_code("£"), Some("GBP"));
}
#[test]
fn test_resolve_currency_unknown() {
assert_eq!(resolve_currency_code("xyz"), None);
}
#[test]
fn test_is_currency_alias() {
assert!(is_currency_alias("usd"));
assert!(is_currency_alias("euro"));
assert!(is_currency_alias("$"));
assert!(!is_currency_alias("km"));
}
#[test]
fn test_parse_ecb_xml() {
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<gesmes:Envelope xmlns:gesmes="http://www.gesmes.org/xml/2002-08-01" xmlns="http://www.ecb.int/vocabulary/2002-08-01/eurofxref">
<gesmes:subject>Reference rates</gesmes:subject>
<Cube>
<Cube time='2026-03-26'>
<Cube currency='USD' rate='1.0832'/>
<Cube currency='JPY' rate='161.94'/>
<Cube currency='GBP' rate='0.83450'/>
</Cube>
</Cube>
</gesmes:Envelope>"#;
let rates = parse_ecb_xml(xml).unwrap();
assert!((rates.rates["USD"] - 1.0832).abs() < 0.001);
assert!((rates.rates["GBP"] - 0.8345).abs() < 0.001);
assert!((rates.rates["JPY"] - 161.94).abs() < 0.01);
}
#[test]
fn test_cache_roundtrip() {
let rates = CurrencyRates {
date: "2026-03-26".to_string(),
rates: {
let mut m = HashMap::new();
m.insert("USD".to_string(), 1.0832);
m.insert("GBP".to_string(), 0.8345);
m
},
};
let json = serde_json::to_string(&rates).unwrap();
let parsed: CurrencyRates = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.rates["USD"], 1.0832);
}
}

View File

@@ -0,0 +1,183 @@
mod currency;
mod parser;
mod units;
use super::{DynamicProvider, LaunchItem, ProviderType};
const PROVIDER_TYPE_ID: &str = "conv";
const PROVIDER_ICON: &str = "edit-find-replace-symbolic";
pub struct ConverterProvider;
impl ConverterProvider {
pub fn new() -> Self {
Self
}
}
impl DynamicProvider for ConverterProvider {
fn name(&self) -> &str {
"Converter"
}
fn provider_type(&self) -> ProviderType {
ProviderType::Plugin(PROVIDER_TYPE_ID.into())
}
fn priority(&self) -> u32 {
9_000
}
fn query(&self, query: &str) -> Vec<LaunchItem> {
let query_str = query.trim();
// Strip prefix
let input = if let Some(rest) = query_str.strip_prefix('>') {
rest.trim()
} else {
query_str
};
let parsed = match parser::parse_conversion(input) {
Some(p) => p,
None => return Vec::new(),
};
let results = if let Some(ref target) = parsed.target_unit {
units::convert_to(&parsed.value, &parsed.from_unit, target)
.into_iter()
.collect()
} else {
units::convert_common(&parsed.value, &parsed.from_unit)
};
results
.into_iter()
.map(|r| LaunchItem {
id: format!("conv:{}:{}:{}", parsed.from_unit, r.target_symbol, r.value),
name: r.display_value.clone(),
description: Some(format!(
"{} {} = {}",
format_number(parsed.value),
parsed.from_symbol,
r.display_value,
)),
icon: Some(PROVIDER_ICON.into()),
provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()),
command: format!(
"printf '%s' '{}' | wl-copy",
r.raw_value.replace('\'', "'\\''")
),
terminal: false,
tags: vec!["converter".into(), "units".into()],
})
.collect()
}
}
fn format_number(n: f64) -> String {
if n.fract() == 0.0 && n.abs() < 1e15 {
let i = n as i64;
if i.abs() >= 1000 {
format_with_separators(i)
} else {
format!("{}", i)
}
} else {
format!("{:.4}", n)
.trim_end_matches('0')
.trim_end_matches('.')
.to_string()
}
}
pub(crate) fn format_with_separators(n: i64) -> String {
let s = n.abs().to_string();
let mut result = String::new();
for (i, c) in s.chars().rev().enumerate() {
if i > 0 && i % 3 == 0 {
result.push(',');
}
result.push(c);
}
if n < 0 {
result.push('-');
}
result.chars().rev().collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn query(input: &str) -> Vec<LaunchItem> {
ConverterProvider::new().query(input)
}
#[test]
fn test_prefix_trigger() {
let r = query("> 100 km to mi");
assert!(!r.is_empty());
}
#[test]
fn test_auto_detect() {
let r = query("100 km to mi");
assert!(!r.is_empty());
}
#[test]
fn test_common_conversions() {
let r = query("> 100 km");
assert!(r.len() > 1);
}
#[test]
fn test_temperature() {
let r = query("102F to C");
assert!(!r.is_empty());
}
#[test]
fn test_nonsense_returns_empty() {
assert!(query("hello world").is_empty());
}
#[test]
fn test_provider_type() {
assert_eq!(
ConverterProvider::new().provider_type(),
ProviderType::Plugin("conv".into())
);
}
#[test]
fn test_no_double_unit() {
let r = query("100 km to mi");
if let Some(item) = r.first() {
let desc = item.description.as_deref().unwrap();
assert!(!desc.ends_with(" mi mi"), "double unit in: {}", desc);
}
}
#[test]
fn test_format_number_integer() {
assert_eq!(format_number(42.0), "42");
}
#[test]
fn test_format_number_large_integer() {
assert_eq!(format_number(1000000.0), "1,000,000");
}
#[test]
fn test_format_number_decimal() {
assert_eq!(format_number(3.14), "3.14");
}
#[test]
fn test_format_with_separators() {
assert_eq!(format_with_separators(1234567), "1,234,567");
assert_eq!(format_with_separators(999), "999");
assert_eq!(format_with_separators(-1234), "-1,234");
}
}

View File

@@ -0,0 +1,235 @@
use super::units;
pub struct ParsedQuery {
pub value: f64,
pub from_unit: String,
pub from_symbol: String,
pub target_unit: Option<String>,
}
pub fn parse_conversion(input: &str) -> Option<ParsedQuery> {
let input = input.trim();
if input.is_empty() {
return None;
}
// Extract leading number
let (value, rest) = extract_number(input)?;
let rest = rest.trim();
if rest.is_empty() {
return None;
}
// Split on " to " or " in " (case-insensitive)
let (from_str, target_str) = split_on_connector(rest);
// Resolve from unit
let from_lower = from_str.trim().to_lowercase();
let from_symbol = units::find_unit(&from_lower)?;
let from_symbol_str = from_symbol.to_string();
// Resolve target unit if present
let target_unit = target_str.and_then(|t| {
let t_lower = t.trim().to_lowercase();
if t_lower.is_empty() {
None
} else {
units::find_unit(&t_lower).map(|_| t_lower)
}
});
Some(ParsedQuery {
value,
from_unit: from_lower,
from_symbol: from_symbol_str,
target_unit,
})
}
fn extract_number(input: &str) -> Option<(f64, &str)> {
let bytes = input.as_bytes();
let mut i = 0;
// Optional negative sign
if i < bytes.len() && bytes[i] == b'-' {
i += 1;
}
// Must have at least one digit or start with .
if i >= bytes.len() {
return None;
}
let start_digits = i;
// Integer part
while i < bytes.len() && bytes[i].is_ascii_digit() {
i += 1;
}
// Decimal part
if i < bytes.len() && bytes[i] == b'.' {
i += 1;
while i < bytes.len() && bytes[i].is_ascii_digit() {
i += 1;
}
}
if i == start_digits && !(i > 0 && bytes[0] == b'-') {
// No digits found (and not just a negative sign before a dot)
// Handle ".5" case
if bytes[start_digits] == b'.' {
// already advanced past dot above
} else {
return None;
}
}
if i == 0 || (i == 1 && bytes[0] == b'-') {
return None;
}
let num_str = &input[..i];
let value: f64 = num_str.parse().ok()?;
let rest = &input[i..];
Some((value, rest))
}
fn split_on_connector(input: &str) -> (&str, Option<&str>) {
let lower = input.to_lowercase();
// Try " to " first
if let Some(pos) = lower.find(" to ") {
let from = &input[..pos];
let target = &input[pos + 4..];
return (from, Some(target));
}
// Try " in "
if let Some(pos) = lower.find(" in ") {
let from = &input[..pos];
let target = &input[pos + 4..];
return (from, Some(target));
}
(input, None)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_number_and_unit_with_space() {
let p = parse_conversion("100 km").unwrap();
assert!((p.value - 100.0).abs() < 0.001);
assert_eq!(p.from_unit, "km");
assert!(p.target_unit.is_none());
}
#[test]
fn test_number_and_unit_no_space() {
let p = parse_conversion("100km").unwrap();
assert!((p.value - 100.0).abs() < 0.001);
assert_eq!(p.from_unit, "km");
}
#[test]
fn test_with_target_to() {
let p = parse_conversion("100 km to mi").unwrap();
assert!((p.value - 100.0).abs() < 0.001);
assert_eq!(p.from_unit, "km");
assert_eq!(p.target_unit.as_deref(), Some("mi"));
}
#[test]
fn test_with_target_in() {
let p = parse_conversion("100 km in mi").unwrap();
assert_eq!(p.target_unit.as_deref(), Some("mi"));
}
#[test]
fn test_temperature_no_space() {
let p = parse_conversion("102F to C").unwrap();
assert!((p.value - 102.0).abs() < 0.001);
assert_eq!(p.from_unit, "f");
assert_eq!(p.target_unit.as_deref(), Some("c"));
}
#[test]
fn test_temperature_with_space() {
let p = parse_conversion("102 F in K").unwrap();
assert!((p.value - 102.0).abs() < 0.001);
assert_eq!(p.from_unit, "f");
assert_eq!(p.target_unit.as_deref(), Some("k"));
}
#[test]
fn test_decimal_number() {
let p = parse_conversion("3.5 kg to lb").unwrap();
assert!((p.value - 3.5).abs() < 0.001);
}
#[test]
fn test_decimal_starting_with_dot() {
let p = parse_conversion(".5 kg").unwrap();
assert!((p.value - 0.5).abs() < 0.001);
}
#[test]
fn test_full_unit_names() {
let p = parse_conversion("100 kilometers to miles").unwrap();
assert_eq!(p.from_unit, "kilometers");
assert_eq!(p.target_unit.as_deref(), Some("miles"));
}
#[test]
fn test_case_insensitive() {
let p = parse_conversion("100 KM TO MI").unwrap();
assert_eq!(p.from_unit, "km");
assert_eq!(p.target_unit.as_deref(), Some("mi"));
}
#[test]
fn test_currency() {
let p = parse_conversion("100 eur to usd").unwrap();
assert_eq!(p.from_unit, "eur");
assert_eq!(p.target_unit.as_deref(), Some("usd"));
}
#[test]
fn test_no_number_returns_none() {
assert!(parse_conversion("km to mi").is_none());
}
#[test]
fn test_unknown_unit_returns_none() {
assert!(parse_conversion("100 xyz to abc").is_none());
}
#[test]
fn test_empty_returns_none() {
assert!(parse_conversion("").is_none());
}
#[test]
fn test_number_only_returns_none() {
assert!(parse_conversion("100").is_none());
}
#[test]
fn test_compound_unit_alias() {
let p = parse_conversion("100 km/h to mph").unwrap();
assert_eq!(p.from_unit, "km/h");
assert_eq!(p.target_unit.as_deref(), Some("mph"));
}
#[test]
fn test_multi_word_unit() {
let p = parse_conversion("100 fl_oz to ml").unwrap();
assert_eq!(p.from_unit, "fl_oz");
}
}

View File

@@ -0,0 +1,944 @@
use std::collections::HashMap;
use std::sync::LazyLock;
use super::currency;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Category {
Temperature,
Length,
Weight,
Volume,
Speed,
Area,
Data,
Time,
Pressure,
Energy,
Currency,
}
#[derive(Clone)]
enum Conversion {
Factor(f64),
Custom {
to_base: fn(f64) -> f64,
from_base: fn(f64) -> f64,
},
}
#[derive(Clone)]
pub(crate) struct UnitDef {
_id: &'static str,
symbol: &'static str,
aliases: &'static [&'static str],
category: Category,
conversion: Conversion,
}
impl UnitDef {
fn to_base(&self, value: f64) -> f64 {
match &self.conversion {
Conversion::Factor(f) => value * f,
Conversion::Custom { to_base, .. } => to_base(value),
}
}
fn convert_from_base(&self, value: f64) -> f64 {
match &self.conversion {
Conversion::Factor(f) => value / f,
Conversion::Custom { from_base, .. } => from_base(value),
}
}
}
pub struct ConversionResult {
pub value: f64,
pub raw_value: String,
pub display_value: String,
pub target_symbol: String,
}
static UNITS: LazyLock<Vec<UnitDef>> = LazyLock::new(build_unit_table);
static ALIAS_MAP: LazyLock<HashMap<String, usize>> = LazyLock::new(|| {
let mut map = HashMap::new();
for (i, unit) in UNITS.iter().enumerate() {
for alias in unit.aliases {
map.insert(alias.to_lowercase(), i);
}
}
map
});
// Common conversions per category (symbols to show when no target specified)
static COMMON_TARGETS: LazyLock<HashMap<Category, Vec<&'static str>>> = LazyLock::new(|| {
let mut m = HashMap::new();
m.insert(Category::Temperature, vec!["°C", "°F", "K"]);
m.insert(Category::Length, vec!["m", "km", "ft", "mi", "in"]);
m.insert(Category::Weight, vec!["kg", "lb", "oz", "g", "st"]);
m.insert(Category::Volume, vec!["l", "gal", "ml", "cup", "fl oz"]);
m.insert(Category::Speed, vec!["km/h", "mph", "m/s", "kn"]);
m.insert(Category::Area, vec!["", "ft²", "ac", "ha", "km²"]);
m.insert(Category::Data, vec!["MB", "GB", "MiB", "GiB", "TB"]);
m.insert(Category::Time, vec!["s", "min", "h", "d", "wk"]);
m.insert(Category::Pressure, vec!["bar", "psi", "atm", "hPa", "mmHg"]);
m.insert(Category::Energy, vec!["kJ", "kcal", "kWh", "BTU", "Wh"]);
m.insert(Category::Currency, vec!["USD", "EUR", "GBP", "JPY", "CNY"]);
m
});
pub fn find_unit(alias: &str) -> Option<&'static str> {
let lower = alias.to_lowercase();
if let Some(&i) = ALIAS_MAP.get(&lower) {
return Some(UNITS[i].symbol);
}
currency::resolve_currency_code(&lower)
}
pub fn lookup_unit(alias: &str) -> Option<(usize, &UnitDef)> {
let lower = alias.to_lowercase();
ALIAS_MAP.get(&lower).map(|&i| (i, &UNITS[i]))
}
pub fn convert_to(value: &f64, from: &str, to: &str) -> Option<ConversionResult> {
// Try currency first — currency aliases (dollar, euro, etc.) aren't in the UNITS table
if currency::is_currency_alias(from) || currency::is_currency_alias(to) {
return convert_currency(*value, from, to);
}
let (_, from_def) = lookup_unit(from)?;
let (_, to_def) = lookup_unit(to)?;
// Currency via UNITS table (shouldn't reach here, but just in case)
if from_def.category == Category::Currency || to_def.category == Category::Currency {
return convert_currency(*value, from, to);
}
// Must be same category
if from_def.category != to_def.category {
return None;
}
let base_value = from_def.to_base(*value);
let result = to_def.convert_from_base(base_value);
Some(format_result(result, to_def.symbol))
}
pub fn convert_common(value: &f64, from: &str) -> Vec<ConversionResult> {
// Try currency first — currency aliases (dollar, euro, etc.) aren't in the UNITS table
if currency::is_currency_alias(from) {
return convert_currency_common(*value, from);
}
let (_, from_def) = match lookup_unit(from) {
Some(u) => u,
None => return vec![],
};
let category = from_def.category;
let from_symbol = from_def.symbol;
if category == Category::Currency {
return convert_currency_common(*value, from);
}
let targets = match COMMON_TARGETS.get(&category) {
Some(t) => t,
None => return vec![],
};
let base_value = from_def.to_base(*value);
targets
.iter()
.filter(|&&sym| sym != from_symbol)
.filter_map(|&sym| {
let (_, to_def) = lookup_unit(sym)?;
let result = to_def.convert_from_base(base_value);
Some(format_result(result, to_def.symbol))
})
.take(5)
.collect()
}
fn convert_currency(value: f64, from: &str, to: &str) -> Option<ConversionResult> {
let rates = currency::get_rates()?;
let from_code = currency::resolve_currency_code(from)?;
let to_code = currency::resolve_currency_code(to)?;
let from_rate = if from_code == "EUR" { 1.0 } else { *rates.rates.get(from_code)? };
let to_rate = if to_code == "EUR" { 1.0 } else { *rates.rates.get(to_code)? };
let result = value / from_rate * to_rate;
Some(format_currency_result(result, to_code))
}
fn convert_currency_common(value: f64, from: &str) -> Vec<ConversionResult> {
let rates = match currency::get_rates() {
Some(r) => r,
None => return vec![],
};
let from_code = match currency::resolve_currency_code(from) {
Some(c) => c,
None => return vec![],
};
let targets = COMMON_TARGETS.get(&Category::Currency).unwrap();
let from_rate = if from_code == "EUR" {
1.0
} else {
match rates.rates.get(from_code) {
Some(&r) => r,
None => return vec![],
}
};
targets
.iter()
.filter(|&&sym| sym != from_code)
.filter_map(|&sym| {
let to_rate = if sym == "EUR" { 1.0 } else { *rates.rates.get(sym)? };
let result = value / from_rate * to_rate;
Some(format_currency_result(result, sym))
})
.take(5)
.collect()
}
fn format_result(value: f64, symbol: &str) -> ConversionResult {
let raw = if value.fract() == 0.0 && value.abs() < 1e15 {
format!("{}", value as i64)
} else {
format!("{:.4}", value)
.trim_end_matches('0')
.trim_end_matches('.')
.to_string()
};
let display = if value.abs() >= 1000.0 && value.fract() == 0.0 && value.abs() < 1e15 {
super::format_with_separators(value as i64)
} else {
raw.clone()
};
ConversionResult {
value,
raw_value: raw,
display_value: format!("{} {}", display, symbol),
target_symbol: symbol.to_string(),
}
}
fn format_currency_result(value: f64, code: &str) -> ConversionResult {
let raw = format!("{:.2}", value);
let display = raw.clone();
ConversionResult {
value,
raw_value: raw,
display_value: format!("{} {}", display, code),
target_symbol: code.to_string(),
}
}
fn build_unit_table() -> Vec<UnitDef> {
vec![
// Temperature (base: Kelvin)
UnitDef {
_id: "celsius",
symbol: "°C",
aliases: &["c", "°c", "celsius", "degc", "centigrade"],
category: Category::Temperature,
conversion: Conversion::Custom {
to_base: |v| v + 273.15,
from_base: |v| v - 273.15,
},
},
UnitDef {
_id: "fahrenheit",
symbol: "°F",
aliases: &["f", "°f", "fahrenheit", "degf"],
category: Category::Temperature,
conversion: Conversion::Custom {
to_base: |v| (v - 32.0) * 5.0 / 9.0 + 273.15,
from_base: |v| (v - 273.15) * 9.0 / 5.0 + 32.0,
},
},
UnitDef {
_id: "kelvin",
symbol: "K",
aliases: &["k", "kelvin"],
category: Category::Temperature,
conversion: Conversion::Factor(1.0), // base
},
// Length (base: meter)
UnitDef {
_id: "millimeter",
symbol: "mm",
aliases: &["mm", "millimeter", "millimeters", "millimetre"],
category: Category::Length,
conversion: Conversion::Factor(0.001),
},
UnitDef {
_id: "centimeter",
symbol: "cm",
aliases: &["cm", "centimeter", "centimeters", "centimetre"],
category: Category::Length,
conversion: Conversion::Factor(0.01),
},
UnitDef {
_id: "meter",
symbol: "m",
aliases: &["m", "meter", "meters", "metre", "metres"],
category: Category::Length,
conversion: Conversion::Factor(1.0),
},
UnitDef {
_id: "kilometer",
symbol: "km",
aliases: &["km", "kms", "kilometer", "kilometers", "kilometre"],
category: Category::Length,
conversion: Conversion::Factor(1000.0),
},
UnitDef {
_id: "inch",
symbol: "in",
aliases: &["in", "inch", "inches"],
category: Category::Length,
conversion: Conversion::Factor(0.0254),
},
UnitDef {
_id: "foot",
symbol: "ft",
aliases: &["ft", "foot", "feet"],
category: Category::Length,
conversion: Conversion::Factor(0.3048),
},
UnitDef {
_id: "yard",
symbol: "yd",
aliases: &["yd", "yard", "yards"],
category: Category::Length,
conversion: Conversion::Factor(0.9144),
},
UnitDef {
_id: "mile",
symbol: "mi",
aliases: &["mi", "mile", "miles"],
category: Category::Length,
conversion: Conversion::Factor(1609.344),
},
UnitDef {
_id: "nautical_mile",
symbol: "nmi",
aliases: &["nmi", "nautical_mile", "nautical_miles"],
category: Category::Length,
conversion: Conversion::Factor(1852.0),
},
// Weight (base: kg)
UnitDef {
_id: "milligram",
symbol: "mg",
aliases: &["mg", "milligram", "milligrams"],
category: Category::Weight,
conversion: Conversion::Factor(0.000001),
},
UnitDef {
_id: "gram",
symbol: "g",
aliases: &["g", "gram", "grams"],
category: Category::Weight,
conversion: Conversion::Factor(0.001),
},
UnitDef {
_id: "kilogram",
symbol: "kg",
aliases: &["kg", "kilogram", "kilograms", "kilo", "kilos"],
category: Category::Weight,
conversion: Conversion::Factor(1.0),
},
UnitDef {
_id: "tonne",
symbol: "t",
aliases: &["t", "ton", "tons", "tonne", "tonnes", "metric_ton"],
category: Category::Weight,
conversion: Conversion::Factor(1000.0),
},
UnitDef {
_id: "short_ton",
symbol: "short_ton",
aliases: &["short_ton", "ton_us"],
category: Category::Weight,
conversion: Conversion::Factor(907.185),
},
UnitDef {
_id: "ounce",
symbol: "oz",
aliases: &["oz", "ounce", "ounces"],
category: Category::Weight,
conversion: Conversion::Factor(0.0283495),
},
UnitDef {
_id: "pound",
symbol: "lb",
aliases: &["lb", "lbs", "pound", "pounds"],
category: Category::Weight,
conversion: Conversion::Factor(0.453592),
},
UnitDef {
_id: "stone",
symbol: "st",
aliases: &["st", "stone", "stones"],
category: Category::Weight,
conversion: Conversion::Factor(6.35029),
},
// Volume (base: liter)
UnitDef {
_id: "milliliter",
symbol: "ml",
aliases: &["ml", "milliliter", "milliliters", "millilitre"],
category: Category::Volume,
conversion: Conversion::Factor(0.001),
},
UnitDef {
_id: "liter",
symbol: "l",
aliases: &["l", "liter", "liters", "litre", "litres"],
category: Category::Volume,
conversion: Conversion::Factor(1.0),
},
UnitDef {
_id: "us_gallon",
symbol: "gal",
aliases: &["gal", "gallon", "gallons"],
category: Category::Volume,
conversion: Conversion::Factor(3.78541),
},
UnitDef {
_id: "imp_gallon",
symbol: "imp gal",
aliases: &["imp_gal", "gal_uk", "imperial_gallon"],
category: Category::Volume,
conversion: Conversion::Factor(4.54609),
},
UnitDef {
_id: "quart",
symbol: "qt",
aliases: &["qt", "quart", "quarts"],
category: Category::Volume,
conversion: Conversion::Factor(0.946353),
},
UnitDef {
_id: "pint",
symbol: "pt",
aliases: &["pt", "pint", "pints"],
category: Category::Volume,
conversion: Conversion::Factor(0.473176),
},
UnitDef {
_id: "cup",
symbol: "cup",
aliases: &["cup", "cups"],
category: Category::Volume,
conversion: Conversion::Factor(0.236588),
},
UnitDef {
_id: "fluid_ounce",
symbol: "fl oz",
aliases: &["floz", "fl_oz", "fluid_ounce", "fluid_ounces"],
category: Category::Volume,
conversion: Conversion::Factor(0.0295735),
},
UnitDef {
_id: "tablespoon",
symbol: "tbsp",
aliases: &["tbsp", "tablespoon", "tablespoons"],
category: Category::Volume,
conversion: Conversion::Factor(0.0147868),
},
UnitDef {
_id: "teaspoon",
symbol: "tsp",
aliases: &["tsp", "teaspoon", "teaspoons"],
category: Category::Volume,
conversion: Conversion::Factor(0.00492892),
},
// Speed (base: m/s)
UnitDef {
_id: "mps",
symbol: "m/s",
aliases: &["m/s", "mps", "meters_per_second"],
category: Category::Speed,
conversion: Conversion::Factor(1.0),
},
UnitDef {
_id: "kmh",
symbol: "km/h",
aliases: &["km/h", "kmh", "kph", "kilometers_per_hour"],
category: Category::Speed,
conversion: Conversion::Factor(0.277778),
},
UnitDef {
_id: "mph",
symbol: "mph",
aliases: &["mph", "miles_per_hour"],
category: Category::Speed,
conversion: Conversion::Factor(0.44704),
},
UnitDef {
_id: "knot",
symbol: "kn",
aliases: &["kn", "kt", "knot", "knots"],
category: Category::Speed,
conversion: Conversion::Factor(0.514444),
},
UnitDef {
_id: "fps",
symbol: "ft/s",
aliases: &["ft/s", "fps", "feet_per_second"],
category: Category::Speed,
conversion: Conversion::Factor(0.3048),
},
// Area (base: m²)
UnitDef {
_id: "sqmm",
symbol: "mm²",
aliases: &["mm2", "sqmm", "square_millimeter"],
category: Category::Area,
conversion: Conversion::Factor(0.000001),
},
UnitDef {
_id: "sqcm",
symbol: "cm²",
aliases: &["cm2", "sqcm", "square_centimeter"],
category: Category::Area,
conversion: Conversion::Factor(0.0001),
},
UnitDef {
_id: "sqm",
symbol: "",
aliases: &["m2", "sqm", "square_meter", "square_meters"],
category: Category::Area,
conversion: Conversion::Factor(1.0),
},
UnitDef {
_id: "sqkm",
symbol: "km²",
aliases: &["km2", "sqkm", "square_kilometer"],
category: Category::Area,
conversion: Conversion::Factor(1000000.0),
},
UnitDef {
_id: "sqft",
symbol: "ft²",
aliases: &["ft2", "sqft", "square_foot", "square_feet"],
category: Category::Area,
conversion: Conversion::Factor(0.092903),
},
UnitDef {
_id: "acre",
symbol: "ac",
aliases: &["ac", "acre", "acres"],
category: Category::Area,
conversion: Conversion::Factor(4046.86),
},
UnitDef {
_id: "hectare",
symbol: "ha",
aliases: &["ha", "hectare", "hectares"],
category: Category::Area,
conversion: Conversion::Factor(10000.0),
},
// Data (base: byte)
UnitDef {
_id: "byte",
symbol: "B",
aliases: &["b", "byte", "bytes"],
category: Category::Data,
conversion: Conversion::Factor(1.0),
},
UnitDef {
_id: "kilobyte",
symbol: "KB",
aliases: &["kb", "kilobyte", "kilobytes"],
category: Category::Data,
conversion: Conversion::Factor(1000.0),
},
UnitDef {
_id: "megabyte",
symbol: "MB",
aliases: &["mb", "megabyte", "megabytes"],
category: Category::Data,
conversion: Conversion::Factor(1_000_000.0),
},
UnitDef {
_id: "gigabyte",
symbol: "GB",
aliases: &["gb", "gigabyte", "gigabytes"],
category: Category::Data,
conversion: Conversion::Factor(1_000_000_000.0),
},
UnitDef {
_id: "terabyte",
symbol: "TB",
aliases: &["tb", "terabyte", "terabytes"],
category: Category::Data,
conversion: Conversion::Factor(1_000_000_000_000.0),
},
UnitDef {
_id: "kibibyte",
symbol: "KiB",
aliases: &["kib", "kibibyte", "kibibytes"],
category: Category::Data,
conversion: Conversion::Factor(1024.0),
},
UnitDef {
_id: "mebibyte",
symbol: "MiB",
aliases: &["mib", "mebibyte", "mebibytes"],
category: Category::Data,
conversion: Conversion::Factor(1_048_576.0),
},
UnitDef {
_id: "gibibyte",
symbol: "GiB",
aliases: &["gib", "gibibyte", "gibibytes"],
category: Category::Data,
conversion: Conversion::Factor(1_073_741_824.0),
},
UnitDef {
_id: "tebibyte",
symbol: "TiB",
aliases: &["tib", "tebibyte", "tebibytes"],
category: Category::Data,
conversion: Conversion::Factor(1_099_511_627_776.0),
},
// Time (base: second)
UnitDef {
_id: "second",
symbol: "s",
aliases: &["s", "sec", "second", "seconds"],
category: Category::Time,
conversion: Conversion::Factor(1.0),
},
UnitDef {
_id: "minute",
symbol: "min",
aliases: &["min", "minute", "minutes"],
category: Category::Time,
conversion: Conversion::Factor(60.0),
},
UnitDef {
_id: "hour",
symbol: "h",
aliases: &["h", "hr", "hour", "hours"],
category: Category::Time,
conversion: Conversion::Factor(3600.0),
},
UnitDef {
_id: "day",
symbol: "d",
aliases: &["d", "day", "days"],
category: Category::Time,
conversion: Conversion::Factor(86400.0),
},
UnitDef {
_id: "week",
symbol: "wk",
aliases: &["wk", "week", "weeks"],
category: Category::Time,
conversion: Conversion::Factor(604800.0),
},
UnitDef {
_id: "month",
symbol: "mo",
aliases: &["mo", "month", "months"],
category: Category::Time,
conversion: Conversion::Factor(2_592_000.0),
},
UnitDef {
_id: "year",
symbol: "yr",
aliases: &["yr", "year", "years"],
category: Category::Time,
conversion: Conversion::Factor(31_536_000.0),
},
// Pressure (base: Pa)
UnitDef {
_id: "pascal",
symbol: "Pa",
aliases: &["pa", "pascal", "pascals"],
category: Category::Pressure,
conversion: Conversion::Factor(1.0),
},
UnitDef {
_id: "hectopascal",
symbol: "hPa",
aliases: &["hpa", "hectopascal"],
category: Category::Pressure,
conversion: Conversion::Factor(100.0),
},
UnitDef {
_id: "kilopascal",
symbol: "kPa",
aliases: &["kpa", "kilopascal"],
category: Category::Pressure,
conversion: Conversion::Factor(1000.0),
},
UnitDef {
_id: "bar",
symbol: "bar",
aliases: &["bar", "bars"],
category: Category::Pressure,
conversion: Conversion::Factor(100_000.0),
},
UnitDef {
_id: "millibar",
symbol: "mbar",
aliases: &["mbar", "millibar"],
category: Category::Pressure,
conversion: Conversion::Factor(100.0),
},
UnitDef {
_id: "psi",
symbol: "psi",
aliases: &["psi", "pounds_per_square_inch"],
category: Category::Pressure,
conversion: Conversion::Factor(6894.76),
},
UnitDef {
_id: "atmosphere",
symbol: "atm",
aliases: &["atm", "atmosphere", "atmospheres"],
category: Category::Pressure,
conversion: Conversion::Factor(101_325.0),
},
UnitDef {
_id: "mmhg",
symbol: "mmHg",
aliases: &["mmhg", "torr"],
category: Category::Pressure,
conversion: Conversion::Factor(133.322),
},
// Energy (base: Joule)
UnitDef {
_id: "joule",
symbol: "J",
aliases: &["j", "joule", "joules"],
category: Category::Energy,
conversion: Conversion::Factor(1.0),
},
UnitDef {
_id: "kilojoule",
symbol: "kJ",
aliases: &["kj", "kilojoule", "kilojoules"],
category: Category::Energy,
conversion: Conversion::Factor(1000.0),
},
UnitDef {
_id: "calorie",
symbol: "cal",
aliases: &["cal", "calorie", "calories"],
category: Category::Energy,
conversion: Conversion::Factor(4.184),
},
UnitDef {
_id: "kilocalorie",
symbol: "kcal",
aliases: &["kcal", "kilocalorie", "kilocalories"],
category: Category::Energy,
conversion: Conversion::Factor(4184.0),
},
UnitDef {
_id: "watt_hour",
symbol: "Wh",
aliases: &["wh", "watt_hour"],
category: Category::Energy,
conversion: Conversion::Factor(3600.0),
},
UnitDef {
_id: "kilowatt_hour",
symbol: "kWh",
aliases: &["kwh", "kilowatt_hour"],
category: Category::Energy,
conversion: Conversion::Factor(3_600_000.0),
},
UnitDef {
_id: "btu",
symbol: "BTU",
aliases: &["btu", "british_thermal_unit"],
category: Category::Energy,
conversion: Conversion::Factor(1055.06),
},
]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_celsius_to_fahrenheit() {
let r = convert_to(&100.0, "c", "f").unwrap();
assert!((r.value - 212.0).abs() < 0.01);
}
#[test]
fn test_fahrenheit_to_celsius() {
let r = convert_to(&32.0, "f", "c").unwrap();
assert!((r.value - 0.0).abs() < 0.01);
}
#[test]
fn test_body_temp_f_to_c() {
let r = convert_to(&98.6, "f", "c").unwrap();
assert!((r.value - 37.0).abs() < 0.01);
}
#[test]
fn test_km_to_miles() {
let r = convert_to(&100.0, "km", "mi").unwrap();
assert!((r.value - 62.1371).abs() < 0.01);
}
#[test]
fn test_miles_to_km() {
let r = convert_to(&1.0, "mi", "km").unwrap();
assert!((r.value - 1.60934).abs() < 0.01);
}
#[test]
fn test_kg_to_lb() {
let r = convert_to(&1.0, "kg", "lb").unwrap();
assert!((r.value - 2.20462).abs() < 0.01);
}
#[test]
fn test_lb_to_kg() {
let r = convert_to(&100.0, "lbs", "kg").unwrap();
assert!((r.value - 45.3592).abs() < 0.01);
}
#[test]
fn test_liters_to_gallons() {
let r = convert_to(&3.78541, "l", "gal").unwrap();
assert!((r.value - 1.0).abs() < 0.01);
}
#[test]
fn test_kmh_to_mph() {
let r = convert_to(&100.0, "kmh", "mph").unwrap();
assert!((r.value - 62.1371).abs() < 0.01);
}
#[test]
fn test_gb_to_mb() {
let r = convert_to(&1.0, "gb", "mb").unwrap();
assert!((r.value - 1000.0).abs() < 0.01);
}
#[test]
fn test_gib_to_mib() {
let r = convert_to(&1.0, "gib", "mib").unwrap();
assert!((r.value - 1024.0).abs() < 0.01);
}
#[test]
fn test_hours_to_minutes() {
let r = convert_to(&2.5, "h", "min").unwrap();
assert!((r.value - 150.0).abs() < 0.01);
}
#[test]
fn test_bar_to_psi() {
let r = convert_to(&1.0, "bar", "psi").unwrap();
assert!((r.value - 14.5038).abs() < 0.01);
}
#[test]
fn test_kcal_to_kj() {
let r = convert_to(&1.0, "kcal", "kj").unwrap();
assert!((r.value - 4.184).abs() < 0.01);
}
#[test]
fn test_sqm_to_sqft() {
let r = convert_to(&1.0, "m2", "ft2").unwrap();
assert!((r.value - 10.7639).abs() < 0.01);
}
#[test]
fn test_unknown_unit_returns_none() {
assert!(convert_to(&100.0, "xyz", "abc").is_none());
}
#[test]
fn test_cross_category_returns_none() {
assert!(convert_to(&100.0, "km", "kg").is_none());
}
#[test]
fn test_convert_common_returns_results() {
let results = convert_common(&100.0, "km");
assert!(!results.is_empty());
assert!(results.len() <= 5);
}
#[test]
fn test_convert_common_excludes_source() {
let results = convert_common(&100.0, "km");
for r in &results {
assert_ne!(r.target_symbol, "km");
}
}
#[test]
fn test_alias_case_insensitive() {
let r1 = convert_to(&100.0, "KM", "MI").unwrap();
let r2 = convert_to(&100.0, "km", "mi").unwrap();
assert!((r1.value - r2.value).abs() < 0.001);
}
#[test]
fn test_full_name_alias() {
let r = convert_to(&100.0, "kilometers", "miles").unwrap();
assert!((r.value - 62.1371).abs() < 0.01);
}
#[test]
fn test_format_currency_two_decimals() {
let r = convert_to(&1.0, "km", "mi").unwrap();
// display_value should have reasonable formatting
assert!(!r.display_value.is_empty());
}
#[test]
fn test_currency_alias_convert_to() {
// "dollar" and "euro" are aliases, not in the UNITS table
let r = convert_to(&20.0, "dollar", "euro");
// May return None if ECB rates unavailable (network), but should not panic
// In a network-available environment, this should return Some
if let Some(r) = r {
assert!(r.value > 0.0);
assert_eq!(r.target_symbol, "EUR");
}
}
#[test]
fn test_currency_alias_convert_common() {
let results = convert_common(&20.0, "dollar");
// May be empty if ECB rates unavailable, but should not panic
for r in &results {
assert!(r.value > 0.0);
}
}
#[test]
fn test_display_value_no_double_unit() {
let r = convert_to(&100.0, "km", "mi").unwrap();
// display_value should contain the symbol exactly once
let count = r.display_value.matches(&r.target_symbol).count();
assert_eq!(count, 1, "display_value '{}' should contain '{}' exactly once", r.display_value, r.target_symbol);
}
}

View File

@@ -1,6 +1,9 @@
// Core providers (no plugin equivalents)
mod application;
mod command;
pub(crate) mod calculator;
pub(crate) mod converter;
pub(crate) mod system;
// Native plugin bridge
pub mod native_provider;
@@ -104,10 +107,24 @@ pub trait Provider: Send + Sync {
fn items(&self) -> &[LaunchItem];
}
/// Trait for built-in providers that produce results per-keystroke.
/// Unlike static `Provider`s which cache items via `refresh()`/`items()`,
/// dynamic providers generate results on every query.
pub(crate) trait DynamicProvider: Send + Sync {
#[allow(dead_code)]
fn name(&self) -> &str;
fn provider_type(&self) -> ProviderType;
fn query(&self, query: &str) -> Vec<LaunchItem>;
fn priority(&self) -> u32;
}
/// Manages all providers and handles searching
pub struct ProviderManager {
/// Core static providers (apps, commands, dmenu)
providers: Vec<Box<dyn Provider>>,
/// Built-in dynamic providers (calculator, converter)
/// These are queried per-keystroke, like native dynamic plugins
builtin_dynamic: Vec<Box<dyn DynamicProvider>>,
/// Static native plugin providers (need query() for submenu support)
static_native_providers: Vec<NativeProvider>,
/// Dynamic providers from native plugins (calculator, websearch, filesearch)
@@ -136,6 +153,7 @@ impl ProviderManager {
) -> Self {
let mut manager = Self {
providers: core_providers,
builtin_dynamic: Vec::new(),
static_native_providers: Vec::new(),
dynamic_providers: Vec::new(),
widget_providers: Vec::new(),
@@ -178,6 +196,25 @@ impl ProviderManager {
manager
}
/// Get type IDs of built-in providers (for conflict detection with native plugins)
fn builtin_type_ids(&self) -> std::collections::HashSet<String> {
let mut ids: std::collections::HashSet<String> = self
.builtin_dynamic
.iter()
.filter_map(|p| match p.provider_type() {
ProviderType::Plugin(id) => Some(id),
_ => None,
})
.collect();
// Also include built-in static providers that use Plugin type
for p in &self.providers {
if let ProviderType::Plugin(id) = p.provider_type() {
ids.insert(id);
}
}
ids
}
/// Create a self-contained ProviderManager from config.
///
/// Loads native plugins, creates core providers (Application + Command),
@@ -277,7 +314,63 @@ impl ProviderManager {
core_providers.push(provider);
}
// Built-in dynamic providers
let mut builtin_dynamic: Vec<Box<dyn DynamicProvider>> = Vec::new();
if config.providers.calculator {
builtin_dynamic.push(Box::new(calculator::CalculatorProvider));
info!("Registered built-in calculator provider");
}
if config.providers.converter {
builtin_dynamic.push(Box::new(converter::ConverterProvider::new()));
info!("Registered built-in converter provider");
}
// Built-in static providers
if config.providers.system {
core_providers.push(Box::new(system::SystemProvider::new()));
info!("Registered built-in system provider");
}
// Compute built-in type IDs to detect conflicts with native plugins.
// A native plugin whose type_id matches a built-in provider would
// produce duplicate results, so we skip it.
let builtin_ids: std::collections::HashSet<String> = {
let mut ids = std::collections::HashSet::new();
// Dynamic built-ins (calculator, converter)
for p in &builtin_dynamic {
if let ProviderType::Plugin(id) = p.provider_type() {
ids.insert(id);
}
}
// Static built-ins added to core_providers (e.g. system)
for p in &core_providers {
if let ProviderType::Plugin(id) = p.provider_type() {
ids.insert(id);
}
}
ids
};
let native_providers: Vec<NativeProvider> = native_providers
.into_iter()
.filter(|provider| {
let type_id = provider.type_id();
if builtin_ids.contains(type_id) {
info!(
"Skipping native plugin '{}' — built-in provider exists",
type_id
);
false
} else {
true
}
})
.collect();
let mut manager = Self::new(core_providers, native_providers);
manager.builtin_dynamic = builtin_dynamic;
manager.runtimes = runtimes;
manager.runtime_type_ids = runtime_type_ids;
manager
@@ -602,8 +695,42 @@ impl ProviderManager {
let dynamic_results = provider.query(query);
// Priority comes from plugin-declared priority field
let base_score = provider.priority() as i64;
// Auto-detect plugins (calc, conv) get a grouping bonus so
// all their results stay together above generic search results
let grouping_bonus: i64 = match provider.provider_type() {
ProviderType::Plugin(ref id)
if matches!(id.as_str(), "calc" | "conv") =>
{
10_000
}
_ => 0,
};
for (idx, item) in dynamic_results.into_iter().enumerate() {
results.push((item, base_score - idx as i64));
results.push((item, base_score + grouping_bonus - idx as i64));
}
}
// Built-in dynamic providers (calculator, converter)
for provider in &self.builtin_dynamic {
if !filter.is_active(provider.provider_type()) {
continue;
}
let dynamic_results = provider.query(query);
let base_score = provider.priority() as i64;
let grouping_bonus: i64 = match provider.provider_type() {
ProviderType::Plugin(ref id)
if matches!(id.as_str(), "calc" | "conv") =>
{
10_000
}
_ => 0,
};
for (idx, item) in dynamic_results.into_iter().enumerate() {
results.push((item, base_score + grouping_bonus - idx as i64));
}
}
}
@@ -686,7 +813,18 @@ impl ProviderManager {
base_score.map(|s| {
let frecency_score = frecency.get_score_at(&item.id, now);
let frecency_boost = (frecency_score * frecency_weight * 10.0) as i64;
(item.clone(), s + frecency_boost)
// Exact name match bonus — apps get a higher boost
let exact_match_boost = if item.name.eq_ignore_ascii_case(query) {
match &item.provider {
ProviderType::Application => 50_000,
_ => 30_000,
}
} else {
0
};
(item.clone(), s + frecency_boost + exact_match_boost)
})
};
@@ -1078,4 +1216,24 @@ mod tests {
assert_eq!(results.len(), 1);
assert_eq!(results[0].0.name, "Firefox");
}
#[test]
fn test_builtin_type_ids_includes_dynamic_and_static() {
use super::calculator::CalculatorProvider;
use super::converter::ConverterProvider;
use super::system::SystemProvider;
let mut pm = ProviderManager::new(
vec![Box::new(SystemProvider::new())],
vec![],
);
pm.builtin_dynamic = vec![
Box::new(CalculatorProvider),
Box::new(ConverterProvider::new()),
];
let ids = pm.builtin_type_ids();
assert!(ids.contains("calc"));
assert!(ids.contains("conv"));
assert!(ids.contains("sys"));
}
}

View File

@@ -0,0 +1,148 @@
use super::{LaunchItem, Provider, ProviderType};
/// Built-in system provider. Returns a fixed set of power and session management actions.
///
/// This is a static provider — items are populated in `new()` and `refresh()` is a no-op.
pub(crate) struct SystemProvider {
items: Vec<LaunchItem>,
}
impl SystemProvider {
pub fn new() -> Self {
let commands: &[(&str, &str, &str, &str, &str)] = &[
(
"shutdown",
"Shutdown",
"Power off the system",
"system-shutdown",
"systemctl poweroff",
),
(
"reboot",
"Reboot",
"Restart the system",
"system-reboot",
"systemctl reboot",
),
(
"reboot-bios",
"Reboot to BIOS",
"Restart into UEFI/BIOS setup",
"system-reboot",
"systemctl reboot --firmware-setup",
),
(
"suspend",
"Suspend",
"Suspend to RAM",
"system-suspend",
"systemctl suspend",
),
(
"hibernate",
"Hibernate",
"Suspend to disk",
"system-suspend-hibernate",
"systemctl hibernate",
),
(
"lock",
"Lock Screen",
"Lock the session",
"system-lock-screen",
"loginctl lock-session",
),
(
"logout",
"Log Out",
"End the current session",
"system-log-out",
"loginctl terminate-session self",
),
];
let items = commands
.iter()
.map(|(action_id, name, description, icon, command)| LaunchItem {
id: format!("sys:{}", action_id),
name: name.to_string(),
description: Some(description.to_string()),
icon: Some(icon.to_string()),
provider: ProviderType::Plugin("sys".into()),
command: command.to_string(),
terminal: false,
tags: vec!["system".into()],
})
.collect();
Self { items }
}
}
impl Provider for SystemProvider {
fn name(&self) -> &str {
"System"
}
fn provider_type(&self) -> ProviderType {
ProviderType::Plugin("sys".into())
}
fn refresh(&mut self) {
// Static provider — no-op
}
fn items(&self) -> &[LaunchItem] {
&self.items
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn has_seven_actions() {
let provider = SystemProvider::new();
assert_eq!(provider.items().len(), 7);
}
#[test]
fn contains_expected_action_names() {
let provider = SystemProvider::new();
let names: Vec<&str> = provider.items().iter().map(|i| i.name.as_str()).collect();
assert!(names.contains(&"Shutdown"));
assert!(names.contains(&"Reboot"));
assert!(names.contains(&"Lock Screen"));
assert!(names.contains(&"Log Out"));
}
#[test]
fn provider_type_is_sys_plugin() {
let provider = SystemProvider::new();
assert_eq!(provider.provider_type(), ProviderType::Plugin("sys".into()));
}
#[test]
fn shutdown_command_is_correct() {
let provider = SystemProvider::new();
let shutdown = provider
.items()
.iter()
.find(|i| i.name == "Shutdown")
.expect("Shutdown item must exist");
assert_eq!(shutdown.command, "systemctl poweroff");
}
#[test]
fn all_items_have_system_tag() {
let provider = SystemProvider::new();
for item in provider.items() {
assert!(
item.tags.contains(&"system".to_string()),
"item '{}' is missing 'system' tag",
item.name
);
}
}
}

View File

@@ -1,6 +1,6 @@
[package]
name = "owlry"
version = "1.0.2"
version = "1.0.5"
edition = "2024"
rust-version = "1.90"
description = "A lightweight, owl-themed application launcher for Wayland"

View File

@@ -185,16 +185,25 @@ impl OwlryApp {
}
fn setup_icon_theme() {
// Ensure we have icon fallbacks for weather/media icons
// These may not exist in all icon themes
if let Some(display) = gtk4::gdk::Display::default() {
let icon_theme = gtk4::IconTheme::for_display(&display);
// Add Adwaita as fallback search path (has weather and media icons)
icon_theme.add_search_path("/usr/share/icons/Adwaita");
icon_theme.add_search_path("/usr/share/icons/breeze");
// If the system icon theme doesn't exist on disk (e.g., set in
// gsettings but not installed), GTK falls back to hicolor which
// has almost no icons. Detect this and use Adwaita instead.
let theme_name = icon_theme.theme_name();
let theme_exists = icon_theme
.search_path()
.iter()
.any(|p| p.join(theme_name.as_str()).is_dir());
debug!("Icon theme search paths configured with Adwaita/breeze fallbacks");
if !theme_exists && theme_name != "hicolor" && theme_name != "Adwaita" {
info!(
"Icon theme '{}' not found on disk, falling back to Adwaita",
theme_name
);
icon_theme.set_theme_name(Some("Adwaita"));
}
}
}

View File

@@ -14,6 +14,7 @@
background-color: var(--owlry-bg, @theme_bg_color);
border-radius: var(--owlry-border-radius, 12px);
border: 1px solid var(--owlry-border, @borders);
box-shadow: var(--owlry-shadow, none);
padding: 12px;
}
@@ -56,6 +57,16 @@
color: var(--owlry-accent-bright, @theme_selected_fg_color);
}
/* Highlighted result row (exact match or auto-detected plugin result) */
.owlry-result-highlight {
background-color: alpha(var(--owlry-accent, @theme_selected_bg_color), 0.08);
border-left: 3px solid var(--owlry-accent, @theme_selected_bg_color);
}
.owlry-result-highlight:selected {
border-left: 3px solid var(--owlry-accent-bright, @theme_selected_fg_color);
}
/* Result icon */
.owlry-result-icon {
color: var(--owlry-text, @theme_fg_color);

View File

@@ -31,8 +31,6 @@
.owlry-main {
background-color: rgba(26, 27, 38, 0.95);
border: 1px solid rgba(65, 72, 104, 0.6);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(224, 175, 104, 0.1);
}
/* Search entry */

View File

@@ -42,6 +42,8 @@ struct LazyLoadState {
all_results: Vec<LaunchItem>,
/// Number of items currently displayed
displayed_count: usize,
/// The query that produced these results (for highlighting in lazy-loaded batches)
query: String,
}
/// Number of items to display initially and per batch
@@ -239,36 +241,18 @@ impl MainWindow {
search_entry_for_refresh.emit_by_name::<()>("changed", &[]);
});
// Set up periodic widget auto-refresh (every 5 seconds) — local backend only
// In daemon mode, the daemon handles widget refresh and results come via IPC
if main_window.is_dmenu_mode {
// dmenu typically has no widgets, but this is harmless
// Periodic widget refresh — local backend only.
// In daemon mode, the daemon handles widget refresh internally;
// the UI gets updated data on the next user-initiated search.
// We do NOT re-query in daemon mode because it resets the user's
// scroll position and selection.
if !matches!(&*main_window.backend.borrow(), SearchBackend::Daemon(_)) {
let backend_for_auto = main_window.backend.clone();
gtk4::glib::timeout_add_local(std::time::Duration::from_secs(5), move || {
backend_for_auto.borrow_mut().refresh_widgets();
gtk4::glib::ControlFlow::Continue
});
}
let backend_for_auto = main_window.backend.clone();
let current_results_for_auto = main_window.current_results.clone();
let submenu_state_for_auto = main_window.submenu_state.clone();
let search_entry_for_auto = main_window.search_entry.clone();
gtk4::glib::timeout_add_local(std::time::Duration::from_secs(5), move || {
let in_submenu = submenu_state_for_auto.borrow().active;
// For local backend: refresh widgets (daemon handles this itself)
backend_for_auto.borrow_mut().refresh_widgets();
// For daemon backend: re-query to get updated widget data
if !in_submenu {
if let SearchBackend::Daemon(_) = &*backend_for_auto.borrow() {
// Trigger a re-search to pick up updated widget items from daemon
search_entry_for_auto.emit_by_name::<()>("changed", &[]);
} else {
// Local backend: update widget items in-place (legacy behavior)
// This path is only hit in dmenu mode which doesn't have widgets,
// but keep it for completeness.
let _results = current_results_for_auto.borrow();
// No-op for local mode without widget access
}
}
gtk4::glib::ControlFlow::Continue
});
main_window
}
@@ -528,7 +512,7 @@ impl MainWindow {
}
for item in &actions {
let row = ResultRow::new(item);
let row = ResultRow::new(item, "");
results_list.append(&row);
}
@@ -610,7 +594,7 @@ impl MainWindow {
}
for item in &filtered {
let row = ResultRow::new(item);
let row = ResultRow::new(item, "");
results_list.append(&row);
}
@@ -709,6 +693,7 @@ impl MainWindow {
let results_list_cb = results_list.clone();
let current_results_cb = current_results.clone();
let lazy_state_cb = lazy_state.clone();
let query_for_highlight = query_str.clone();
gtk4::glib::spawn_future_local(async move {
if let Ok(result) = rx.await {
@@ -726,7 +711,7 @@ impl MainWindow {
INITIAL_RESULTS.min(items.len());
for item in items.iter().take(initial_count) {
let row = ResultRow::new(item);
let row = ResultRow::new(item, &query_for_highlight);
results_list_cb.append(&row);
}
@@ -741,6 +726,7 @@ impl MainWindow {
let mut lazy = lazy_state_cb.borrow_mut();
lazy.all_results = items;
lazy.displayed_count = initial_count;
lazy.query = query_for_highlight;
}
});
} else {
@@ -760,7 +746,7 @@ impl MainWindow {
let initial_count = INITIAL_RESULTS.min(results.len());
for item in results.iter().take(initial_count) {
let row = ResultRow::new(item);
let row = ResultRow::new(item, &query_str);
results_list.append(&row);
}
@@ -772,6 +758,7 @@ impl MainWindow {
results[..initial_count].to_vec();
let mut lazy = lazy_state.borrow_mut();
lazy.all_results = results;
lazy.query = query_str;
lazy.displayed_count = initial_count;
}
},
@@ -1267,7 +1254,7 @@ impl MainWindow {
let initial_count = INITIAL_RESULTS.min(results.len());
for item in results.iter().take(initial_count) {
let row = ResultRow::new(item);
let row = ResultRow::new(item, "");
results_list.append(&row);
}
@@ -1282,42 +1269,6 @@ impl MainWindow {
});
}
fn update_results(&self, query: &str) {
let cfg = self.config.borrow();
let max_results = cfg.general.max_results;
drop(cfg);
let results = self.backend.borrow_mut().search(
query,
max_results,
&self.filter.borrow(),
&self.config.borrow(),
);
// Clear existing results
while let Some(child) = self.results_list.first_child() {
self.results_list.remove(&child);
}
// Display initial batch only
let initial_count = INITIAL_RESULTS.min(results.len());
for item in results.iter().take(initial_count) {
let row = ResultRow::new(item);
self.results_list.append(&row);
}
if let Some(first_row) = self.results_list.row_at_index(0) {
self.results_list.select_row(Some(&first_row));
}
// current_results holds what's currently displayed; store full vec for lazy loading
*self.current_results.borrow_mut() = results[..initial_count].to_vec();
let mut lazy = self.lazy_state.borrow_mut();
lazy.all_results = results;
lazy.displayed_count = initial_count;
}
/// Set up lazy loading scroll detection
fn setup_lazy_loading(&self) {
let vadj = self.scrolled.vadjustment();
@@ -1372,8 +1323,9 @@ impl MainWindow {
if displayed < all_count {
// Load next batch
let new_end = (displayed + LOAD_MORE_BATCH).min(all_count);
let query = lazy.query.clone();
for item in lazy.all_results[displayed..new_end].iter() {
let row = ResultRow::new(item);
let row = ResultRow::new(item, &query);
results_list.append(&row);
}
lazy.displayed_count = new_end;

View File

@@ -1,6 +1,6 @@
use gtk4::prelude::*;
use gtk4::{Box as GtkBox, Image, Label, ListBoxRow, Orientation, Widget};
use owlry_core::providers::LaunchItem;
use owlry_core::providers::{LaunchItem, ProviderType};
#[allow(dead_code)]
pub struct ResultRow {
@@ -18,9 +18,31 @@ fn is_emoji_icon(s: &str) -> bool {
!first_char.is_ascii() && s.chars().count() <= 8
}
/// Check if this item should be highlighted based on the query.
/// Highlighted when:
/// - Item is from an auto-detecting plugin (calculator, converter) that parsed
/// the query into a result — these produce direct answers, not search results
/// - Item name exactly matches the query (case-insensitive)
fn should_highlight(item: &LaunchItem, query: &str) -> bool {
if query.is_empty() {
return false;
}
// Exact name match (case-insensitive)
if item.name.eq_ignore_ascii_case(query) {
return true;
}
// Auto-detect plugins that produce direct answers (not search tools)
matches!(
&item.provider,
ProviderType::Plugin(id) if matches!(id.as_str(), "calc" | "conv")
)
}
impl ResultRow {
#[allow(clippy::new_ret_no_self)]
pub fn new(item: &LaunchItem) -> ListBoxRow {
pub fn new(item: &LaunchItem, query: &str) -> ListBoxRow {
let row = ListBoxRow::builder()
.selectable(true)
.activatable(true)
@@ -28,6 +50,10 @@ impl ResultRow {
row.add_css_class("owlry-result-row");
if should_highlight(item, query) {
row.add_css_class("owlry-result-highlight");
}
let hbox = GtkBox::builder()
.orientation(Orientation::Horizontal)
.spacing(12)

View File

@@ -77,8 +77,6 @@
.owlry-main {
background-color: rgba(5, 5, 5, 0.98);
border: 1px solid rgba(38, 38, 38, 0.8);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.8),
0 0 0 1px rgba(255, 0, 68, 0.1);
}
.owlry-search {

View File

@@ -24,8 +24,6 @@
.owlry-main {
background-color: rgba(30, 30, 46, 0.95);
border: 1px solid rgba(69, 71, 90, 0.6);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(203, 166, 247, 0.1);
}
.owlry-search {

View File

@@ -24,8 +24,6 @@
.owlry-main {
background-color: rgba(40, 42, 54, 0.95);
border: 1px solid rgba(98, 114, 164, 0.6);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(189, 147, 249, 0.1);
}
.owlry-search {

View File

@@ -24,8 +24,6 @@
.owlry-main {
background-color: rgba(40, 40, 40, 0.95);
border: 1px solid rgba(80, 73, 69, 0.6);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(254, 128, 25, 0.1);
}
.owlry-search {

View File

@@ -24,8 +24,6 @@
.owlry-main {
background-color: rgba(46, 52, 64, 0.95);
border: 1px solid rgba(76, 86, 106, 0.6);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4),
0 0 0 1px rgba(136, 192, 208, 0.1);
}
.owlry-search {

View File

@@ -24,8 +24,6 @@
.owlry-main {
background-color: rgba(40, 44, 52, 0.95);
border: 1px solid rgba(24, 26, 31, 0.6);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(97, 175, 239, 0.1);
}
.owlry-search {

View File

@@ -33,8 +33,6 @@
.owlry-main {
background-color: rgba(26, 27, 38, 0.95);
border: 1px solid rgba(65, 72, 104, 0.6);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(224, 175, 104, 0.1);
}
.owlry-search {

View File

@@ -24,8 +24,6 @@
.owlry-main {
background-color: rgba(25, 23, 36, 0.95);
border: 1px solid rgba(38, 35, 58, 0.6);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(196, 167, 231, 0.1);
}
.owlry-search {

View File

@@ -24,8 +24,6 @@
.owlry-main {
background-color: rgba(0, 43, 54, 0.95);
border: 1px solid rgba(88, 110, 117, 0.6);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(38, 139, 210, 0.1);
}
.owlry-search {

View File

@@ -24,8 +24,6 @@
.owlry-main {
background-color: rgba(26, 27, 38, 0.95);
border: 1px solid rgba(65, 72, 104, 0.6);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(122, 162, 247, 0.1);
}
.owlry-search {

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,116 @@
# Built-in Providers Migration — Design Spec
## Goal
Move calculator, converter, and system from external `.so` plugins (owlry-plugins repo) to native providers compiled into `owlry-core`. Remove 3 plugin AUR packages (transitional), 4 meta AUR packages (already deleted). Update READMEs for both repos.
## Architecture
The 3 plugins currently use the FFI plugin API (`PluginVTable`, `PluginItem`, etc.) and are loaded as `.so` files by `NativePluginLoader`. As built-in providers, they become native Rust modules inside `owlry-core/src/providers/` implementing the existing `Provider` trait — same as `ApplicationProvider` and `CommandProvider`.
No changes to the plugin system itself. External plugins continue to work via `.so` loading.
## Components
### New modules in owlry-core
- `providers/calculator.rs` — port of owlry-plugin-calculator (231 lines, depends on `meval`)
- `providers/converter/mod.rs` — port of owlry-plugin-converter entry point
- `providers/converter/parser.rs` — query parsing (235 lines, no new deps)
- `providers/converter/units.rs` — unit definitions + conversion (944 lines, no new deps)
- `providers/converter/currency.rs` — ECB rate fetching (313 lines, depends on `reqwest` blocking + `dirs` + `serde`)
- `providers/system.rs` — port of owlry-plugin-system (257 lines, no new deps)
### New owlry-core dependencies
- `meval` — math expression evaluation (currently optional behind `lua` feature, make required)
- `reqwest` with `blocking` feature — ECB currency rate fetching (currently optional behind `lua`, make required)
- `dirs` — already a dependency
- `serde`/`serde_json` — already dependencies
### Modified files
- `owlry-core/src/providers/mod.rs` — register the 3 new providers in `ProviderManager`, honor config toggles, classify calculator+converter as dynamic providers
- `owlry-core/Cargo.toml` — move `meval` and `reqwest` from optional to required
- `owlry-core/src/config/mod.rs` — add `converter` config toggle (calculator and system already exist)
### Provider classification
- Calculator → dynamic (queried per-keystroke via `query()`)
- Converter → dynamic (queried per-keystroke via `query()`)
- System → static (populated at `refresh()`, returns fixed list of actions)
## Provider Type IDs
Built-in providers use `ProviderType::Plugin(String)` with fixed IDs to maintain backward compatibility with the UI highlighting and filter system:
- Calculator: `ProviderType::Plugin("calc".into())`
- Converter: `ProviderType::Plugin("conv".into())`
- System: `ProviderType::Plugin("sys".into())`
This ensures the UI's highlighting logic (`matches!(id.as_str(), "calc" | "conv")`) and CSS badge classes (`.owlry-badge-calc`, `.owlry-badge-sys`) continue to work without changes.
## Config
Existing toggles in `[providers]`:
```toml
[providers]
calculator = true # already exists
system = true # already exists
converter = true # new — add with default true
```
When a toggle is false, the provider is not registered in `ProviderManager` at startup.
## Currency Conversion
The converter's currency feature uses `reqwest` (blocking) to fetch ECB exchange rates with a 24-hour file cache at `~/.cache/owlry/ecb_rates.json`. If the HTTP fetch fails (no network, timeout), currency conversion silently returns no results — unit conversion still works. This matches current plugin behavior.
## AUR Changes
### Main repo (owlry)
- `aur/owlry-core/PKGBUILD` — bump version
- Remove `aur/owlry-meta-*` directories (4 dirs, already deleted from AUR)
### Plugins repo (owlry-plugins)
- Remove crates: `owlry-plugin-calculator`, `owlry-plugin-converter`, `owlry-plugin-system`
- Remove AUR dirs: `aur/owlry-plugin-calculator`, `aur/owlry-plugin-converter`, `aur/owlry-plugin-system` from tracked files
- Push transitional PKGBUILDs to the 3 AUR repos:
```bash
pkgname=owlry-plugin-calculator # (and converter, system)
pkgver=<last_version>
pkgrel=99
pkgdesc="Transitional package — calculator is now built into owlry-core"
arch=('any')
depends=('owlry-core>=<new_version>')
replaces=('owlry-plugin-calculator')
# No source, no build, no package body
```
### Conflict prevention
When owlry-core gains built-in calculator/converter/system, users who have the old `.so` plugins installed will have both the built-in provider AND the `.so` plugin active — duplicate results. The daemon should detect this: if a built-in provider ID matches a loaded native plugin ID, skip the native plugin. Add this check in `ProviderManager` when registering native plugins.
## README Updates
### Main repo README
- Package table: remove separate plugin entries for calculator, converter, system — note them as built-in to owlry-core
- Remove meta package section entirely
- Update install examples (no need to install calculator/converter/system separately)
### Plugins repo README
- Remove calculator, converter, system from plugin listing
- Add note that these 3 are built into owlry-core
## Testing
- Port existing plugin tests directly — they test provider logic, not FFI wrappers
- `cargo test -p owlry-core --lib` covers all 3 new providers
- Add conflict detection test (built-in provider ID vs native plugin ID)
- Manual verification: `= 5+3` (calc), `20F` (conv), `20 euro to dollar` (currency), system actions

566
justfile
View File

@@ -1,65 +1,57 @@
# Owlry build and release automation
# Default recipe
default:
@just --list
# Build debug (all workspace members)
# === Build ===
build:
cargo build --workspace
# Build UI binary only
build-ui:
cargo build -p owlry
# Build core daemon only
build-daemon:
cargo build -p owlry-core
# Build core daemon release
release-daemon:
cargo build -p owlry-core --release
# Run core daemon
run-daemon *ARGS:
cargo run -p owlry-core -- {{ARGS}}
# Build release
release:
cargo build --workspace --release
# Run in debug mode
release-daemon:
cargo build -p owlry-core --release
# === Run ===
run *ARGS:
cargo run -p owlry -- {{ARGS}}
# Run tests
run-daemon *ARGS:
cargo run -p owlry-core -- {{ARGS}}
# === Quality ===
test:
cargo test --workspace
# Check code
check:
cargo check --workspace
cargo clippy --workspace
# Format code
fmt:
cargo fmt --all
# Clean build artifacts
clean:
cargo clean
# Install locally (core + runtimes)
# === Install ===
install-local:
#!/usr/bin/env bash
set -euo pipefail
echo "Building release..."
# Build UI without embedded Lua (smaller binary)
cargo build -p owlry --release --no-default-features
# Build core daemon
cargo build -p owlry-core --release
# Build runtimes
cargo build -p owlry-lua -p owlry-rune --release
echo "Creating directories..."
@@ -71,55 +63,21 @@ install-local:
sudo install -Dm755 target/release/owlry-core /usr/bin/owlry-core
echo "Installing runtimes..."
if [ -f "target/release/libowlry_lua.so" ]; then
sudo install -Dm755 target/release/libowlry_lua.so /usr/lib/owlry/runtimes/liblua.so
echo " → liblua.so"
fi
if [ -f "target/release/libowlry_rune.so" ]; then
sudo install -Dm755 target/release/libowlry_rune.so /usr/lib/owlry/runtimes/librune.so
echo " → librune.so"
fi
[ -f target/release/libowlry_lua.so ] && sudo install -Dm755 target/release/libowlry_lua.so /usr/lib/owlry/runtimes/liblua.so
[ -f target/release/libowlry_rune.so ] && sudo install -Dm755 target/release/libowlry_rune.so /usr/lib/owlry/runtimes/librune.so
echo "Installing systemd service files..."
if [ -f "systemd/owlry-core.service" ]; then
sudo install -Dm644 systemd/owlry-core.service /usr/lib/systemd/user/owlry-core.service
echo " → owlry-core.service"
fi
if [ -f "systemd/owlry-core.socket" ]; then
sudo install -Dm644 systemd/owlry-core.socket /usr/lib/systemd/user/owlry-core.socket
echo " → owlry-core.socket"
fi
[ -f systemd/owlry-core.service ] && sudo install -Dm644 systemd/owlry-core.service /usr/lib/systemd/user/owlry-core.service
[ -f systemd/owlry-core.socket ] && sudo install -Dm644 systemd/owlry-core.socket /usr/lib/systemd/user/owlry-core.socket
echo ""
echo "Installation complete!"
echo " - /usr/bin/owlry (UI)"
echo " - /usr/bin/owlry-core (daemon)"
echo " - $(ls /usr/lib/owlry/runtimes/*.so 2>/dev/null | wc -l) runtimes"
echo " - systemd: owlry-core.service, owlry-core.socket"
echo ""
echo "To start the daemon:"
echo " systemctl --user enable --now owlry-core.service"
echo " OR add 'exec-once = owlry-core' to your compositor config"
echo ""
echo "Note: Install plugins separately from the owlry-plugins repo."
echo "Done. Start daemon: systemctl --user enable --now owlry-core.service"
# === Release Management ===
# === Version Management ===
# AUR package directories (relative to project root)
aur_core_dir := "aur/owlry"
# Get current version from core crate
version := `grep '^version' crates/owlry/Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/'`
# Show current version
show-version:
@echo "Current version: {{version}}"
# Show all crate versions
show-versions:
#!/usr/bin/env bash
echo "=== Crate Versions ==="
for toml in Cargo.toml crates/*/Cargo.toml; do
for toml in crates/*/Cargo.toml; do
name=$(grep '^name' "$toml" | head -1 | sed 's/.*"\(.*\)"/\1/')
ver=$(grep '^version' "$toml" | head -1 | sed 's/.*"\(.*\)"/\1/')
printf " %-30s %s\n" "$name" "$ver"
@@ -129,20 +87,16 @@ show-versions:
crate-version crate:
@grep '^version' crates/{{crate}}/Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/'
# Bump a specific crate version (usage: just bump-crate owlry-core 0.2.0)
# Bump a single crate version, update Cargo.lock, commit
bump-crate crate new_version:
#!/usr/bin/env bash
set -euo pipefail
toml="crates/{{crate}}/Cargo.toml"
if [ ! -f "$toml" ]; then
echo "Error: $toml not found"
exit 1
fi
[ -f "$toml" ] || { echo "Error: $toml not found"; exit 1; }
old=$(grep '^version' "$toml" | head -1 | sed 's/.*"\(.*\)"/\1/')
if [ "$old" = "{{new_version}}" ]; then
echo "{{crate}} is already at {{new_version}}, skipping"
exit 0
fi
[ "$old" = "{{new_version}}" ] && { echo "{{crate}} already at {{new_version}}"; exit 0; }
echo "Bumping {{crate}} from $old to {{new_version}}"
sed -i 's/^version = ".*"/version = "{{new_version}}"/' "$toml"
cargo check -p {{crate}}
@@ -150,7 +104,214 @@ bump-crate crate new_version:
git commit -m "chore({{crate}}): bump version to {{new_version}}"
echo "{{crate}} bumped to {{new_version}}"
# Bump meta-packages (no crate, just AUR version)
# Bump all crates to same version
bump-all new_version:
#!/usr/bin/env bash
set -euo pipefail
for toml in crates/*/Cargo.toml; do
crate=$(basename $(dirname "$toml"))
old=$(grep '^version' "$toml" | head -1 | sed 's/.*"\(.*\)"/\1/')
[ "$old" = "{{new_version}}" ] && continue
echo "Bumping $crate from $old to {{new_version}}"
sed -i 's/^version = ".*"/version = "{{new_version}}"/' "$toml"
done
cargo check --workspace
git add crates/*/Cargo.toml Cargo.lock
git commit -m "chore: bump all crates to {{new_version}}"
echo "All crates bumped to {{new_version}}"
# Bump core UI only
bump new_version:
just bump-crate owlry {{new_version}}
# === Tagging ===
# Tag a specific crate (format: {crate}-v{version})
tag-crate crate:
#!/usr/bin/env bash
set -euo pipefail
ver=$(grep '^version' "crates/{{crate}}/Cargo.toml" | head -1 | sed 's/.*"\(.*\)"/\1/')
tag="{{crate}}-v$ver"
if git rev-parse "$tag" >/dev/null 2>&1; then
echo "Tag $tag already exists"
exit 0
fi
git tag -a "$tag" -m "{{crate}} v$ver"
echo "Created tag $tag"
# Push all local tags
push-tags:
git push --tags
# === AUR Package Management ===
# Stage AUR files into the main repo git index.
# AUR subdirs have their own .git (for aur.archlinux.org), which makes
# git treat them as embedded repos. Temporarily hide .git to stage files.
aur-stage pkg:
#!/usr/bin/env bash
set -euo pipefail
dir="aur/{{pkg}}"
[ -d "$dir" ] || { echo "Error: $dir not found"; exit 1; }
# Build list of files to stage
files=("$dir/PKGBUILD" "$dir/.SRCINFO")
for f in "$dir"/*.install; do
[ -f "$f" ] && files+=("$f")
done
if [ -d "$dir/.git" ]; then
mv "$dir/.git" "$dir/.git.bak"
git add "${files[@]}"
mv "$dir/.git.bak" "$dir/.git"
else
git add "${files[@]}"
fi
# Update a specific AUR package PKGBUILD with correct version + checksum
aur-update-pkg pkg:
#!/usr/bin/env bash
set -euo pipefail
aur_dir="aur/{{pkg}}"
[ -d "$aur_dir" ] || { echo "Error: $aur_dir not found"; exit 1; }
# Determine version
case "{{pkg}}" in
owlry-meta-*)
ver=$(grep '^pkgver=' "$aur_dir/PKGBUILD" | sed 's/pkgver=//')
echo "Meta-package {{pkg}} at $ver (bump pkgrel manually if needed)"
(cd "$aur_dir" && makepkg --printsrcinfo > .SRCINFO)
exit 0
;;
*)
crate_dir="crates/{{pkg}}"
[ -d "$crate_dir" ] || { echo "Error: $crate_dir not found"; exit 1; }
ver=$(grep '^version' "$crate_dir/Cargo.toml" | head -1 | sed 's/.*"\(.*\)"/\1/')
;;
esac
tag="{{pkg}}-v$ver"
url="https://somegit.dev/Owlibou/owlry/archive/$tag.tar.gz"
echo "Updating {{pkg}} to $ver (tag: $tag)"
sed -i "s/^pkgver=.*/pkgver=$ver/" "$aur_dir/PKGBUILD"
sed -i 's/^pkgrel=.*/pkgrel=1/' "$aur_dir/PKGBUILD"
# Update checksum from the tagged tarball
if grep -q "^source=" "$aur_dir/PKGBUILD"; then
echo "Downloading tarball and computing checksum..."
hash=$(curl -sL "$url" | b2sum | cut -d' ' -f1)
if [ -z "$hash" ] || [ ${#hash} -lt 64 ]; then
echo "Error: failed to download or hash $url"
exit 1
fi
sed -i "s|^b2sums=.*|b2sums=('$hash')|" "$aur_dir/PKGBUILD"
fi
(cd "$aur_dir" && makepkg --printsrcinfo > .SRCINFO)
echo "{{pkg}} PKGBUILD updated to $ver"
# Shortcut: update core UI AUR package
aur-update:
just aur-update-pkg owlry
# Publish a specific AUR package to aur.archlinux.org
aur-publish-pkg pkg:
#!/usr/bin/env bash
set -euo pipefail
aur_dir="aur/{{pkg}}"
[ -d "$aur_dir/.git" ] || { echo "Error: $aur_dir has no AUR git repo"; exit 1; }
cd "$aur_dir"
ver=$(grep '^pkgver=' PKGBUILD | sed 's/pkgver=//')
git add -A
git commit -m "Update to v$ver" || { echo "Nothing to commit"; exit 0; }
git push origin master
echo "{{pkg}} v$ver published to AUR!"
# Shortcut: publish core UI to AUR
aur-publish:
just aur-publish-pkg owlry
# Update and publish ALL AUR packages
aur-update-all:
#!/usr/bin/env bash
set -euo pipefail
for dir in aur/*/; do
pkg=$(basename "$dir")
[ -f "$dir/PKGBUILD" ] || continue
echo "=== $pkg ==="
just aur-update-pkg "$pkg"
echo ""
done
echo "All updated. Run 'just aur-publish-all' to publish."
aur-publish-all:
#!/usr/bin/env bash
set -euo pipefail
for dir in aur/*/; do
pkg=$(basename "$dir")
[ -d "$dir/.git" ] || continue
echo "=== $pkg ==="
just aur-publish-pkg "$pkg"
echo ""
done
echo "All published!"
# Show AUR package status
aur-status:
#!/usr/bin/env bash
echo "=== AUR Package Status ==="
for dir in aur/*/; do
pkg=$(basename "$dir")
[ -f "$dir/PKGBUILD" ] || continue
ver=$(grep '^pkgver=' "$dir/PKGBUILD" | sed 's/pkgver=//')
if [ -d "$dir/.git" ]; then
printf " ✓ %-30s %s\n" "$pkg" "$ver"
else
printf " ✗ %-30s %s (no AUR repo)\n" "$pkg" "$ver"
fi
done
# Commit AUR file changes to the main repo (handles embedded .git dirs)
aur-commit msg="chore(aur): update PKGBUILDs":
#!/usr/bin/env bash
set -euo pipefail
for dir in aur/*/; do
pkg=$(basename "$dir")
[ -f "$dir/PKGBUILD" ] || continue
just aur-stage "$pkg"
done
git diff --cached --quiet && { echo "No AUR changes to commit"; exit 0; }
git commit -m "{{msg}}"
# === Release Workflows ===
# Release a single crate: bump → push → tag → update AUR → publish AUR
release-crate crate new_version:
#!/usr/bin/env bash
set -euo pipefail
just bump-crate {{crate}} {{new_version}}
git push
just tag-crate {{crate}}
just push-tags
echo "Waiting for tag to propagate..."
sleep 3
just aur-update-pkg {{crate}}
just aur-commit "chore(aur): update {{crate}} to {{new_version}}"
git push
just aur-publish-pkg {{crate}}
echo ""
echo "{{crate}} v{{new_version}} released and published to AUR!"
# === Meta Package Management ===
# Bump meta-package versions
bump-meta new_version:
#!/usr/bin/env bash
set -euo pipefail
@@ -165,271 +326,14 @@ bump-meta new_version:
done
echo "Meta-packages bumped to {{new_version}}"
# Bump all crates (core UI + daemon + plugin-api + runtimes) to same version
bump-all new_version:
#!/usr/bin/env bash
set -euo pipefail
for toml in crates/*/Cargo.toml; do
crate=$(basename $(dirname "$toml"))
old=$(grep '^version' "$toml" | head -1 | sed 's/.*"\(.*\)"/\1/')
if [ "$old" != "{{new_version}}" ]; then
echo "Bumping $crate from $old to {{new_version}}"
sed -i 's/^version = ".*"/version = "{{new_version}}"/' "$toml"
fi
done
cargo check --workspace
git add crates/*/Cargo.toml Cargo.lock
git commit -m "chore: bump all crates to {{new_version}}"
echo "All crates bumped to {{new_version}}"
# Bump core version (usage: just bump 0.2.0)
bump new_version:
#!/usr/bin/env bash
set -euo pipefail
if [ "{{version}}" = "{{new_version}}" ]; then
echo "Version is already {{new_version}}, skipping bump"
exit 0
fi
echo "Bumping core version from {{version}} to {{new_version}}"
sed -i 's/^version = ".*"/version = "{{new_version}}"/' crates/owlry/Cargo.toml
cargo check -p owlry
git add crates/owlry/Cargo.toml Cargo.lock
git commit -m "chore: bump version to {{new_version}}"
echo "Version bumped to {{new_version}}"
# Create and push a release tag
tag:
#!/usr/bin/env bash
set -euo pipefail
if git rev-parse "v{{version}}" >/dev/null 2>&1; then
echo "Tag v{{version}} already exists, skipping"
exit 0
fi
echo "Creating tag v{{version}}"
git tag -a "v{{version}}" -m "Release v{{version}}"
git push origin "v{{version}}"
echo "Tag v{{version}} pushed"
# Update AUR package (core UI)
aur-update:
#!/usr/bin/env bash
set -euo pipefail
cd "{{aur_core_dir}}"
url="https://somegit.dev/Owlibou/owlry"
echo "Updating PKGBUILD to version {{version}}"
sed -i 's/^pkgver=.*/pkgver={{version}}/' PKGBUILD
sed -i 's/^pkgrel=.*/pkgrel=1/' PKGBUILD
# Update checksums (b2sums)
echo "Updating checksums..."
b2sum=$(curl -sL "$url/archive/v{{version}}.tar.gz" | b2sum | cut -d' ' -f1)
sed -i "s/^b2sums=.*/b2sums=('$b2sum')/" PKGBUILD
# Generate .SRCINFO
echo "Generating .SRCINFO..."
makepkg --printsrcinfo > .SRCINFO
# Show diff
git diff
echo ""
echo "AUR package updated. Review changes above."
echo "Run 'just aur-publish' to commit and push."
# Publish AUR package (core UI)
aur-publish:
#!/usr/bin/env bash
set -euo pipefail
cd "{{aur_core_dir}}"
git add PKGBUILD .SRCINFO
git commit -m "Update to v{{version}}"
git push
echo "AUR package v{{version}} published!"
# Test AUR package build locally (core UI)
aur-test:
#!/usr/bin/env bash
set -euo pipefail
cd "{{aur_core_dir}}"
echo "Testing PKGBUILD..."
makepkg -sf
echo ""
echo "Package built successfully!"
ls -lh *.pkg.tar.zst
# === AUR Package Management (individual packages) ===
# Update a specific AUR package (usage: just aur-update-pkg owlry-core)
aur-update-pkg pkg:
#!/usr/bin/env bash
set -euo pipefail
aur_dir="aur/{{pkg}}"
if [ ! -d "$aur_dir" ]; then
echo "Error: $aur_dir not found"
exit 1
fi
url="https://somegit.dev/Owlibou/owlry"
# Determine crate version
case "{{pkg}}" in
owlry-meta-essentials|owlry-meta-tools|owlry-meta-widgets|owlry-meta-full)
# Meta-packages use static versioning (1.0.0), only bump pkgrel for dep changes
crate_ver=$(grep '^pkgver=' "$aur_dir/PKGBUILD" | sed 's/pkgver=//')
;;
*)
# Get version from crate
crate_dir="crates/{{pkg}}"
if [ ! -d "$crate_dir" ]; then
echo "Error: $crate_dir not found"
exit 1
fi
crate_ver=$(grep '^version' "$crate_dir/Cargo.toml" | head -1 | sed 's/.*"\(.*\)"/\1/')
;;
esac
cd "$aur_dir"
echo "Updating {{pkg}} PKGBUILD:"
echo " pkgver=$crate_ver"
sed -i "s/^pkgver=.*/pkgver=$crate_ver/" PKGBUILD
sed -i 's/^pkgrel=.*/pkgrel=1/' PKGBUILD
# Update checksums
if grep -q "^source=" PKGBUILD; then
echo "Updating checksums..."
b2sum=$(curl -sL "$url/archive/v$crate_ver.tar.gz" | b2sum | cut -d' ' -f1)
sed -i "s/^b2sums=.*/b2sums=('$b2sum')/" PKGBUILD
fi
# Generate .SRCINFO
echo "Generating .SRCINFO..."
makepkg --printsrcinfo > .SRCINFO
git diff --stat
echo ""
echo "{{pkg}} updated. Run 'just aur-publish-pkg {{pkg}}' to publish."
# Publish a specific AUR package
aur-publish-pkg pkg:
#!/usr/bin/env bash
set -euo pipefail
aur_dir="aur/{{pkg}}"
if [ ! -d "$aur_dir" ]; then
echo "Error: $aur_dir not found"
exit 1
fi
cd "$aur_dir"
ver=$(grep '^pkgver=' PKGBUILD | sed 's/pkgver=//')
git add PKGBUILD .SRCINFO
git commit -m "Update to v$ver"
git push origin master
echo "{{pkg}} v$ver published!"
# === Testing ===
# Test a specific AUR package build locally
aur-test-pkg pkg:
#!/usr/bin/env bash
set -euo pipefail
cd "aur/{{pkg}}"
echo "Testing {{pkg}} PKGBUILD..."
makepkg -sf
echo ""
echo "Package built successfully!"
ls -lh *.pkg.tar.zst
# List all AUR packages with their versions
aur-status:
#!/usr/bin/env bash
echo "=== AUR Package Status ==="
for dir in aur/*/; do
pkg=$(basename "$dir")
if [ -f "$dir/PKGBUILD" ]; then
ver=$(grep '^pkgver=' "$dir/PKGBUILD" | sed 's/pkgver=//')
if [ -d "$dir/.git" ]; then
status="✓"
else
status="✗ (not initialized)"
fi
printf " %s %-30s %s\n" "$status" "$pkg" "$ver"
fi
done
# Update ALL AUR packages (core + daemon + runtimes + meta)
aur-update-all:
#!/usr/bin/env bash
set -euo pipefail
echo "=== Updating core UI ==="
just aur-update
echo ""
echo "=== Updating core daemon ==="
just aur-update-pkg owlry-core
echo ""
echo "=== Updating runtimes ==="
just aur-update-pkg owlry-lua
just aur-update-pkg owlry-rune
echo ""
echo "=== Updating meta-packages ==="
for pkg in owlry-meta-essentials owlry-meta-tools owlry-meta-widgets owlry-meta-full; do
echo "--- $pkg ---"
(cd "aur/$pkg" && makepkg --printsrcinfo > .SRCINFO)
done
echo ""
echo "All AUR packages updated. Run 'just aur-publish-all' to publish."
# Publish ALL AUR packages
aur-publish-all:
#!/usr/bin/env bash
set -euo pipefail
echo "=== Publishing core UI ==="
just aur-publish
echo ""
echo "=== Publishing core daemon ==="
just aur-publish-pkg owlry-core
echo ""
echo "=== Publishing runtimes ==="
just aur-publish-pkg owlry-lua
just aur-publish-pkg owlry-rune
echo ""
echo "=== Publishing meta-packages ==="
for pkg in owlry-meta-essentials owlry-meta-tools owlry-meta-widgets owlry-meta-full; do
echo "--- $pkg ---"
just aur-publish-pkg "$pkg"
done
echo ""
echo "All AUR packages published!"
# Full release workflow for core only (bump + tag + aur)
release-core new_version: (bump new_version)
#!/usr/bin/env bash
set -euo pipefail
# Push version bump
git push
# Create and push tag
just tag
# Wait for tag to be available
echo "Waiting for tag to propagate..."
sleep 2
# Update AUR
just aur-update
echo ""
echo "Core release v{{new_version}} prepared!"
echo "Review AUR changes, then run 'just aur-publish'"