From c3f96b59e7ba7189756eae58fa396f198553fdcc Mon Sep 17 00:00:00 2001 From: CSY2569 <79797451+CSY2569@users.noreply.github.com> Date: Fri, 31 Jul 2026 20:17:12 +0800 Subject: [PATCH] add icefish/phone-connect v0.1.0 (#155) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add icefish/phone-connect v0.1.0 * update phone-connect README with screenshot * fix phone-connect: add thumbnail.webp and missing en.json keys * fix phone-connect: remove unused assets and screenshot.png * fix phone-connect: CI compliance fixes * fix phone-connect: command seq and MPRIS performance * fix phone-connect: MPRIS sequential + media action delay * fix phone-connect: seek format + live position polling * fix phone-connect: use Set position for media seek * fix phone-connect: shell-quote seek variant * fix phone-connect: seek position tracking * fix phone-connect: final compliance — remove LICENSE, update thumbnail, fix tags --- phone-connect/README.md | 75 +++ phone-connect/panel.luau | 378 ++++++++++++++ phone-connect/plugin.toml | 175 +++++++ phone-connect/service.luau | 760 +++++++++++++++++++++++++++++ phone-connect/thumbnail.webp | Bin 0 -> 58674 bytes phone-connect/tile.luau | 89 ++++ phone-connect/translations/en.json | 118 +++++ phone-connect/widget.luau | 133 +++++ 8 files changed, 1728 insertions(+) create mode 100644 phone-connect/README.md create mode 100644 phone-connect/panel.luau create mode 100644 phone-connect/plugin.toml create mode 100644 phone-connect/service.luau create mode 100644 phone-connect/thumbnail.webp create mode 100644 phone-connect/tile.luau create mode 100644 phone-connect/translations/en.json create mode 100644 phone-connect/widget.luau diff --git a/phone-connect/README.md b/phone-connect/README.md new file mode 100644 index 0000000..048bfe9 --- /dev/null +++ b/phone-connect/README.md @@ -0,0 +1,75 @@ +# Phone Connect + +Control your KDE Connect-paired phone from the Noctalia bar. + +## Plugin + +| Entry | Type | Id | +|-------|------|----| +| `icefish/phone-connect` | plugin | — | +| `bar` | widget | `icefish/phone-connect:bar` | +| `tile` | shortcut | `icefish/phone-connect:tile` | +| `details` | panel | `icefish/phone-connect:details` | +| `details_floating` | panel | `icefish/phone-connect:details_floating` | +| `details_widget` | panel | `icefish/phone-connect:details_widget` | +| `kde` | service | `icefish/phone-connect:kde` | + +## Usage + +Add the bar widget and control-center tile from Settings, then click either to open the details panel. + +Open panels from the shell: + +``` +noctalia msg panel-toggle icefish/phone-connect:details +noctalia msg panel-toggle icefish/phone-connect:details_floating +noctalia msg panel-toggle icefish/phone-connect:details_widget +``` + +Send IPC events to the service: + +``` +noctalia msg plugin icefish/phone-connect:kde all refresh +noctalia msg plugin icefish/phone-connect:kde all cmd '{"op":"ring","device":""}' +``` + +## Features + +- **Device status** — battery level, charging state, network type (5G/LTE), pairing +- **Quick actions** — ring, ping, send clipboard, share text/files/URLs, SMS, SFTP browser +- **Media control** — play/pause/skip/seek, album art, now-playing (MPRIS) +- **Customization** — per-device image and alias, three panel placements +- **Language** — English / Simplified Chinese + +## Requirements + +- `kdeconnect` — KDE Connect daemon and CLI +- `gdbus` — ships with glib2 +- `sshfs` — optional, for phone file browsing + +## Settings + +| Setting | Default | Description | +|---------|---------|-------------| +| State Update Interval | 30 | Seconds between device refreshes | +| Show Charging Fill | true | Highlight bar widget when charging | +| Show Clipboard Action | true | Show clipboard button in panel | +| Device Image | — | Custom image for selected device | +| Device Alias | Your-Phone | Custom display name | +| Language | en | English / 简体中文 | +| Panel Placement | attached | Attached / Floating center / Below widget | + +## IPC + +| Event | Payload | Description | +|-------|---------|-------------| +| `refresh` | — | Full device refresh | +| `cmd` | JSON `{"op":"…","device":"…"}` | Execute an operation | + +Supported ops: `ring`, `ping`, `clipboard`, `share_text`, `share_url`, `share_file`, `pair`, `accept`, `reject`, `unpair`, `browse`, `sms_send`, `launch_sms_app`, `media`, `media_seek`, `select`, `refresh`, `set_image`. + +## Notes + +- Communicates with KDE Connect via DBus (`gdbus call`) and CLI (`kdeconnect-cli`) +- Persists user preferences to `pluginDataDir()/state.json` +- No external network requests; all logic runs locally diff --git a/phone-connect/panel.luau b/phone-connect/panel.luau new file mode 100644 index 0000000..bf42beb --- /dev/null +++ b/phone-connect/panel.luau @@ -0,0 +1,378 @@ +-- panel.luau +-- Phone Connect - details panel (layered info-style UI, 720x600). +-- +-- Layout: header + device hero + info grid span the top (full width); below +-- them a two-column row: left (flex, scrollable) holds media control + SMS +-- composer; right (fixed width) holds action buttons stacked vertically. +-- Reads state from the service entry; sends commands via "pc.cmd". + + +-- Local translation: reads from pc.trTable (published by service) so users +-- can switch language at runtime, unlike noctalia.tr() which follows system locale. +local function t(key) + local table = noctalia.state.get("pc.trTable") or {} + return table[key] or key +end + +local PLUGIN_ID = "icefish/phone-connect" + +-- ── Command channel ───────────────────────────────────────────────────────── +local function sendCmd(cmd) + -- time-based seq prevents silent drops after hot-reload + cmd.seq = math.floor(os.clock() * 1000000) + noctalia.state.set("pc.cmd", cmd) +end + +-- ── SMS composer local state (ui.input is uncontrolled) ───────────────────── +local smsDestination = "" +local smsMessage = "" + +-- ── Helpers ───────────────────────────────────────────────────────────────── +local function glyphForType(t) + if t == "tablet" then return "device-tablet" end + if t == "laptop" then return "device-laptop" end + if t == "desktop" or t == "computer" then return "device-desktop" end + if t == "tv" then return "device-tv" end + return "device-mobile" +end + +local function batteryGlyph(charge, charging) + if charging then return "battery-charging" end + if type(charge) ~= "number" then return "battery" end + if charge >= 90 then return "battery" end + if charge >= 60 then return "battery-3" end + if charge >= 30 then return "battery-2" end + if charge > 0 then return "battery-1" end + return "battery-off" +end + +local function infoCell(label, value, glyph) + return ui.column({ gap = 2, padding = 6, radius = 6, fill = "surface/0.5", flexGrow = 1 }, { + ui.row({ gap = 6, align = "center" }, { + ui.glyph({ name = glyph, size = 13, color = "on_surface_variant" }), + ui.label({ text = label, fontSize = 10, color = "on_surface_variant" }), + }), + ui.label({ text = value, fontSize = 13, fontWeight = "medium", color = "on_surface" }), + }) +end + +-- ── Device hero card (top, full width) ────────────────────────────────────── +local function heroCard(d) + local reachable = d.isReachable == true + local paired = d.isPaired == true + local aliasMap = noctalia.state.get("pc.aliasMap") or {} + local name = aliasMap[d.id] or "Your-Phone" + local glyph = glyphForType(d.type) + local imgMap = noctalia.state.get("pc.imageMap") or {} + local imgPath = imgMap[d.id] + -- verify custom image exists + if imgPath and imgPath ~= "" and not noctalia.fileExists(imgPath) then + imgPath = nil + end + local statusText, statusColor + if not reachable then + statusText = paired and t("status.offline") or t("status.not_paired") + statusColor = "on_surface_variant" + else + statusText = t("status.connected") + statusColor = "primary" + end + local charge = d.batteryCharge + local chargeText = (type(charge) == "number" and charge >= 0) + and (tostring(charge) .. "%") or "--" + local chargeColor = "on_surface" + if type(charge) == "number" and charge <= 20 then chargeColor = "error" end + + return ui.column({ gap = 8, padding = 14, radius = 10, fill = "surface/0.7", + border = "primary/0.2", borderWidth = 1 }, { + ui.row({ gap = 14, align = "center" }, { + imgPath and ui.image({ path = imgPath, width = 52, height = 52, radius = 26, fit = "cover" }) + or ui.box({ width = 52, height = 52, radius = 26, fill = "primary/0.15" }, { + ui.glyph({ name = glyph, size = 28, color = "primary" }), + }), + ui.column({ gap = 4, flexGrow = 1 }, { + ui.label({ text = name, fontSize = 17, fontWeight = "bold", color = "on_surface" }), + ui.box({ radius = 10, fill = statusColor .. "/0.15", padding = 4 }, { + ui.label({ text = statusText, fontSize = 11, color = statusColor }), + }), + }), + ui.column({ gap = 2, align = "center" }, { + ui.glyph({ name = batteryGlyph(charge, d.batteryCharging), size = 22, + color = chargeColor }), + ui.label({ text = chargeText, fontSize = 13, fontWeight = "bold", color = chargeColor }), + }), + }), + }) +end + +-- ── Info grid (top, full width) ────────────────────────────────────────────── +local function infoGrid(d) + local charge = d.batteryCharge + local chargeVal = (type(charge) == "number" and charge >= 0) + and (tostring(charge) .. "%" .. (d.batteryCharging and " ⚡" or "")) or "--" + local netVal = (d.networkType and d.networkType ~= "") + and (d.networkType .. (type(d.networkStrength) == "number" + and d.networkStrength >= 0 and (" • " .. tostring(d.networkStrength) .. "/4") or "")) + or "--" + local pairVal = d.isPaired and t("panel.paired") or t("panel.unpaired") + local typeVal = d.type or t("panel.unknown") + return ui.row({ gap = 8 }, { + infoCell(t("panel.battery"), chargeVal, "battery"), + infoCell(t("panel.network"), netVal, "antenna"), + infoCell(t("panel.pairing"), pairVal, "link"), + infoCell(t("panel.type"), typeVal, "device-mobile"), + }) +end + +-- ── Right column: action buttons stacked vertically ───────────────────────── +local function vActionBtn(text, glyph, onClick, variant) + return ui.button({ text = text, glyph = glyph, variant = variant or "outline", + controlSize = "md", onClick = onClick }) +end + +local function rightColumn(d) + local reachable = d.isReachable == true + local paired = d.isPaired == true + local btns = {} + if reachable and paired then + table.insert(btns, vActionBtn(t("action.ring"), "bell-ringing", + function() sendCmd({ op = "ring", device = d.id }) end, "primary")) + table.insert(btns, vActionBtn(t("action.ping"), "message", + function() sendCmd({ op = "ping", device = d.id }) end)) + table.insert(btns, vActionBtn(t("action.browse"), "folder", + function() sendCmd({ op = "browse", device = d.id }) end)) + table.insert(btns, vActionBtn(t("action.sms"), "message-2", + function() sendCmd({ op = "launch_sms_app", device = d.id }) end)) + if noctalia.getConfig("enable_clipboard_action") then + table.insert(btns, vActionBtn(t("action.clipboard"), "clipboard", + function() sendCmd({ op = "clipboard", device = d.id }) end)) + end + table.insert(btns, vActionBtn(t("action.share"), "share", + function() + local clip = noctalia.clipboardText() + if clip then sendCmd({ op = "share_text", device = d.id, text = clip }) end + end)) + table.insert(btns, vActionBtn(t("action.unpair"), "link-off", + function() sendCmd({ op = "unpair", device = d.id }) end, "ghost")) + elseif not paired then + table.insert(btns, vActionBtn(t("action.pair"), "link", + function() sendCmd({ op = "pair", device = d.id }) end, "primary")) + else + table.insert(btns, vActionBtn(t("action.unpair"), "link-off", + function() sendCmd({ op = "unpair", device = d.id }) end, "ghost")) + end + return ui.column({ width = 110, gap = 8, padding = 8, align = "stretch" }, btns) +end + +-- ── Left column: media + SMS (scrollable) ────────────────────────────────── +local function fmtTime(ms) + if type(ms) ~= "number" or ms <= 0 then return "0:00" end + local s = math.floor(ms / 1000) + local m = math.floor(s / 60) + s = s % 60 + return string.format("%d:%02d", m, s) +end + +local function mediaSection(d) + local title = d.mediatitle + if not title or title == "" then return nil end + local artist = d.mediaartist or "" + local album = d.mediaalbum or "" + local playing = d.mediaisPlaying == true + local volume = tonumber(d.mediavolume) or 0 + local length = tonumber(d.medialength) or 0 + local position = tonumber(d.mediaposition) or 0 + local canSeek = d.mediacanSeek == true + local artUrl = d.mediaArtPath + + -- only use art if the file exists (phone path via SFTP mount) + if artUrl and artUrl ~= "" and not noctalia.fileExists(artUrl) then + artUrl = nil + end + + local header = ui.row({ gap = 6, align = "center" }, { + ui.glyph({ name = "music", size = 13, color = "primary" }), + ui.label({ text = t("panel.now_playing"), fontSize = 10, + color = "primary", fontWeight = "bold", flexGrow = 1 }), + }) + + local titleBlock + if artUrl then + titleBlock = ui.row({ gap = 8, align = "center" }, { + ui.image({ path = artUrl, width = 32, height = 32, radius = 4, fit = "cover" }), + ui.column({ gap = 1, flexGrow = 1 }, { + ui.label({ text = title, fontSize = 12, fontWeight = "medium", color = "on_surface" }), + ui.label({ text = artist, fontSize = 10, color = "on_surface_variant" }), + ui.label({ text = album, fontSize = 9, color = "on_surface_variant" }), + }), + }) + else + titleBlock = ui.column({ gap = 1 }, { + ui.label({ text = title, fontSize = 12, fontWeight = "medium", color = "on_surface" }), + ui.label({ text = artist, fontSize = 10, color = "on_surface_variant" }), + ui.label({ text = album, fontSize = 9, color = "on_surface_variant" }), + }) + end + + local transport = ui.row({ gap = 4, align = "center" }, { + ui.button({ glyph = "player-skip-back", variant = "ghost", controlSize = "sm", + onClick = function() sendCmd({ op = "media", device = d.id, action = "Previous" }) end }), + ui.button({ glyph = playing and "player-pause" or "player-play", + variant = "primary", controlSize = "sm", + onClick = function() sendCmd({ op = "media", device = d.id, action = "PlayPause" }) end }), + ui.button({ glyph = "player-skip-forward", variant = "ghost", controlSize = "sm", + onClick = function() sendCmd({ op = "media", device = d.id, action = "Next" }) end }), + ui.button({ glyph = "player-stop", variant = "ghost", controlSize = "sm", + onClick = function() sendCmd({ op = "media", device = d.id, action = "Stop" }) end }), + }) + + local progress + if canSeek and length > 0 then + local seekPos = position + progress = ui.column({ gap = 2 }, { + ui.slider({ min = 0, max = length, step = 1000, value = position, + controlSize = "sm", + onChange = function(v) + seekPos = tonumber(v) or position + end, + onDragEnd = function() + sendCmd({ op = "media_seek", device = d.id, offset = tostring(seekPos) }) + end }), + ui.row({ justify = "space_between" }, { + ui.label({ text = fmtTime(position), fontSize = 9, color = "on_surface_variant" }), + ui.label({ text = fmtTime(length), fontSize = 9, color = "on_surface_variant" }), + }), + }) + end + + local children = { header, titleBlock, transport } + if progress then table.insert(children, progress) end + return ui.column({ gap = 6, padding = 8, radius = 8, fill = "primary/0.08" }, children) +end + +local function smsComposer(d) + return ui.column({ gap = 8, padding = 12, radius = 8, fill = "surface/0.5", + border = "on_surface/0.1", borderWidth = 1 }, { + ui.label({ text = t("panel.sms"), fontSize = 12, fontWeight = "bold", + color = "on_surface" }), + ui.input({ key = "sms-dest", placeholder = t("panel.sms_dest"), + value = smsDestination, controlSize = "sm", + onChange = function(v) smsDestination = v end }), + ui.input({ key = "sms-msg", placeholder = t("panel.sms_msg"), + value = smsMessage, controlSize = "sm", + onChange = function(v) smsMessage = v end }), + ui.button({ text = t("panel.sms_send"), glyph = "send", + variant = "primary", controlSize = "sm", + onClick = function() + if smsDestination ~= "" and smsMessage ~= "" then + sendCmd({ op = "sms_send", device = d.id, + destination = smsDestination, text = smsMessage }) + smsMessage = "" + render() + end + end }), + }) +end + +local function leftColumn(d) + local items = {} + local media = mediaSection(d) + if media then table.insert(items, media) end + if d.isReachable and d.isPaired then + table.insert(items, smsComposer(d)) + end + -- if nothing to show, a placeholder so the column isn't empty + if #items == 0 then + table.insert(items, ui.column({ gap = 8, padding = 20, align = "center" }, { + ui.glyph({ name = "music-off", size = 28, color = "on_surface_variant" }), + ui.label({ text = t("panel.no_media"), color = "on_surface_variant" }), + })) + end + return ui.column({ width = 304, flexGrow = 1 }, { + ui.scroll({ flexGrow = 1, gap = 12, padding = 0 }, items), + }) +end + +-- ── Device switcher (bottom, full width, only if >1 device) ───────────────── +local function switcher(devices, order, sel) + if #order <= 1 then return nil end + local items = {} + for _, id in ipairs(order) do + local d = devices[id] + if d then + local isSel = id == sel + table.insert(items, ui.row({ + key = "sw-" .. id, gap = 8, align = "center", padding = 8, radius = 6, + fill = isSel and "primary/0.12" or "surface/0.4", + onClick = function() sendCmd({ op = "select", device = id }) end, + }, { + ui.glyph({ name = glyphForType(d.type), size = 14, + color = d.isReachable and "primary" or "on_surface_variant" }), + ui.label({ text = aliasMap[id] or "Your-Phone", fontSize = 12, flexGrow = 1, color = "on_surface" }), + ui.label({ text = (type(d.batteryCharge) == "number" and d.batteryCharge >= 0) + and (tostring(d.batteryCharge) .. "%") or "", fontSize = 10, + color = "on_surface_variant" }), + })) + end + end + return ui.column({ gap = 4, padding = 8, radius = 8, fill = "surface/0.3" }, items) +end + +-- ── Main render ───────────────────────────────────────────────────────────── +function render() + local backend = noctalia.state.get("pc.backend") or { available = false } + local devices = noctalia.state.get("pc.devices") or {} + local order = noctalia.state.get("pc.order") or {} + local sel = noctalia.state.get("pc.selected") + if not sel or not devices[sel] then sel = order[1] end + + local body = {} + -- header (full width top) + table.insert(body, ui.row({ gap = 8, align = "center" }, { + ui.glyph({ name = "device-mobile", size = 18, color = "primary" }), + ui.label({ text = t("panel.title"), fontSize = 16, fontWeight = "bold", + color = "on_surface", flexGrow = 1 }), + ui.button({ glyph = "refresh", variant = "ghost", controlSize = "sm", + onClick = function() sendCmd({ op = "refresh" }) end }), + ui.button({ glyph = "close", variant = "ghost", controlSize = "sm", + onClick = function() panel.close() end }), + })) + + if not backend.available then + table.insert(body, ui.column({ gap = 8, padding = 40, align = "center", flexGrow = 1 }, { + ui.glyph({ name = "phone-off", size = 40, color = "on_surface_variant" }), + ui.label({ text = t("status.no_backend"), color = "on_surface_variant" }), + })) + elseif not sel or not devices[sel] then + table.insert(body, ui.column({ gap = 8, padding = 40, align = "center", flexGrow = 1 }, { + ui.glyph({ name = "phone-off", size = 40, color = "on_surface_variant" }), + ui.label({ text = t("panel.empty"), color = "on_surface_variant" }), + })) + else + local d = devices[sel] + -- top: hero + info (full width) + table.insert(body, heroCard(d)) + table.insert(body, infoGrid(d)) + -- middle: two-column row (left content flex, right actions fixed) + table.insert(body, ui.row({ gap = 14, flexGrow = 1, align = "stretch", justify = "center" }, { + leftColumn(d), + rightColumn(d), + })) + -- bottom: device switcher (only if multiple devices) + local sw = switcher(devices, order, sel) + if sw then table.insert(body, sw) end + end + + panel.render(ui.column({ gap = 10, padding = 12 }, body)) +end + +function onOpen(_context) render() end +function onClose() end + +noctalia.state.watch("pc.devices", render) +noctalia.state.watch("pc.order", render) +noctalia.state.watch("pc.selected", render) +noctalia.state.watch("pc.backend", render) +noctalia.state.watch("pc.imageMap", render) +noctalia.state.watch("pc.trTable", render) +noctalia.state.watch("pc.aliasMap", render) diff --git a/phone-connect/plugin.toml b/phone-connect/plugin.toml new file mode 100644 index 0000000..c48cd34 --- /dev/null +++ b/phone-connect/plugin.toml @@ -0,0 +1,175 @@ +# Noctalia v5 plugin manifest for Phone Connect (KDE Connect backend only) +# Plugin system is beta; APIs may change before v5 stable. +# +# plugin_api = 16: targets the last released level of v5.0.0. We avoid 17/18 +# (Unreleased) so the plugin loads on stable v5.0.0. None of our code relies on +# 17+ features: onExit is unused, onConfigChanged is a baseline service hook. +# +# Status: KDE Connect only. Valent backend is intentionally out of scope for +# now, but the service entry (service.luau) is written as a swappable backend +# owner that publishes state to UI entries, so Valent can replace its internals +# later without UI changes. + +id = "icefish/phone-connect" +name = "Phone Connect" +version = "0.1.0" +plugin_api = 16 +author = "icefish" +license = "MIT" +deprecated = false +icon = "devices" +description = "Control paired phones via KDE Connect — battery, ring, ping, share, clipboard, pairing." +tags = ["bar", "panel", "service", "media"] +dependencies = ["kdeconnect", "sshfs"] + +# ── Plugin-level settings (shared across all entries) ─────────────────────── +# Mirrors the persisted keys of the original DankKDEConnect plugin where they +# make sense as typed settings. Per-device maps (image/type/recent-images-path) +# stay in pluginDataDir() JSON because they are dynamic, not declarative. + +[[setting]] +key = "state_update_interval" +type = "int" +label_key = "settings.state_update_interval.label" +description_key = "settings.state_update_interval.description" +default = 30 +min = 0 +max = 300 + +[[setting]] +key = "enable_charging_animation" +type = "bool" +label_key = "settings.enable_charging_animation.label" +description_key = "settings.enable_charging_animation.description" +default = true + +[[setting]] +key = "custom_image" +type = "file" +label_key = "settings.custom_image.label" +description_key = "settings.custom_image.description" +default = "" +extensions = [".png", ".jpg", ".jpeg", ".webp", ".svg"] + +[[setting]] +key = "device_alias" +type = "string" +label_key = "settings.device_alias.label" +description_key = "settings.device_alias.description" +default = "Your-Phone" + +[[setting]] +key = "language" +type = "select" +label_key = "settings.language.label" +description_key = "settings.language.description" +default = "en" +options = [ + { value = "en", label_key = "settings.language.en" }, + { value = "zh_hans", label_key = "settings.language.zh_hans" }, +] + +[[setting]] +key = "enable_clipboard_action" +type = "bool" +label_key = "settings.enable_clipboard_action.label" +description_key = "settings.enable_clipboard_action.description" +default = true + +[[setting]] +key = "show_ongoing_media" +type = "bool" +label_key = "settings.show_ongoing_media.label" +description_key = "settings.show_ongoing_media.description" +default = true + +[[setting]] +key = "show_device_placeholder" +type = "bool" +label_key = "settings.show_device_placeholder.label" +description_key = "settings.show_device_placeholder.description" +default = true + +[[setting]] +key = "scan_subdirectories" +type = "bool" +label_key = "settings.scan_subdirectories.label" +description_key = "settings.scan_subdirectories.description" +default = false + +[[setting]] +key = "max_recent_images" +type = "int" +label_key = "settings.max_recent_images.label" +description_key = "settings.max_recent_images.description" +default = 4 +min = 1 +max = 12 + +[[setting]] +key = "panel_placement" +type = "select" +label_key = "settings.panel_placement.label" +description_key = "settings.panel_placement.description" +default = "attached" +options = [ + { value = "attached", label_key = "settings.panel_placement.attached" }, + { value = "floating", label_key = "settings.panel_placement.floating" }, + { value = "widget", label_key = "settings.panel_placement.widget" }, +] + +# ── Entries ───────────────────────────────────────────────────────────────── +# Four entries: a background service that talks DBus, a bar widget, a control +# center tile, and a panel for the detailed device view. + +[[service]] +id = "kde" +entry = "service.luau" + +[[widget]] +id = "bar" +entry = "widget.luau" + +# Right-click opens the details panel via onRightClick in the script +# (noctalia.togglePanel). We deliberately do not bind [widget.actions] here: +# the panel-toggle action grammar for opening a *plugin* panel by id is not +# documented, so we drive it from the script instead. + +[[shortcut]] +id = "tile" +entry = "tile.luau" + +[[panel]] +id = "details" +entry = "panel.luau" +width = 500 +height = 600 +placement = "attached" +position = "auto" +open_near_click = true +keyboard_focus = "on_demand" + +# Floating variant: same script, opens detached (screen center). The widget/tile +# pick which panel id to toggle based on the "panel_placement" setting, so users +# choose attached vs floating without a runtime setPlacement API. +[[panel]] +id = "details_floating" +entry = "panel.luau" +width = 500 +height = 600 +placement = "floating" +position = "center" +keyboard_focus = "on_demand" + +# Floating-near-widget variant: floating surface that opens near the toggling +# bar widget (open_near_click), i.e. suspended just below the widget rather than +# anchored to the bar edge (attached) or screen-centered (floating). +[[panel]] +id = "details_widget" +entry = "panel.luau" +width = 500 +height = 600 +placement = "floating" +position = "auto" +open_near_click = true +keyboard_focus = "on_demand" diff --git a/phone-connect/service.luau b/phone-connect/service.luau new file mode 100644 index 0000000..0d45a42 --- /dev/null +++ b/phone-connect/service.luau @@ -0,0 +1,760 @@ +-- entries/service.luau +-- Phone Connect - KDE Connect backend service (the single owner of DBus logic). +-- +-- Architecture: Noctalia plugin scripts run in an isolated sandbox with NO +-- `require`/`dofile`/`loadfile`. So all KDE Connect interaction lives here and +-- is published to UI entries (widget/tile/panel) as plain data via +-- `noctalia.state`. A future Valent backend would replace this file's internals +-- only; the state contract below stays unchanged so UI entries need no edits. +-- + +-- Local translation: reads from pc.trTable (published by service) so users +-- can switch language at runtime, unlike noctalia.tr() which follows system locale. +local function t(key) + local table = noctalia.state.get("pc.trTable") or {} + return table[key] or key +end + +-- ── State contract (read by UI entries) ────────────────────────────────────── +-- noctalia.state "pc.backend" : { available=bool, name="KDE Connect"|"None", +-- announcedName="", selfId="" } +-- noctalia.state "pc.devices" : { [id] = { id, name, type, isReachable, +-- isPaired, pairState, verificationKey, +-- supportedPlugins={}, batteryCharge, +-- batteryCharging, networkType, +-- networkStrength } } +-- noctalia.state "pc.order" : { id, ... } device id display order +-- noctalia.state "pc.selected" : string currently selected device id +-- noctalia.state "pc.cmd" : { op=string, device=string, [args...] } +-- written by UI entries; service executes +-- and clears. ops: ring|ping|clipboard| +-- share_text|share_url|share_file|pair| +-- accept|reject|unpair|browse|select|refresh +-- noctalia.state "pc.event" : { type=string, ... } transient UI events +-- (pairing request, file received) the panel +-- may surface; service sets, panel consumes. +-- +-- ── KDE Connect DBus surface (verified via gdbus introspect) ───────────────── +-- service : org.kde.kdeconnect +-- daemon : /modules/kdeconnect iface org.kde.kdeconnect.daemon +-- methods: devices(b,b)->as, selfId()->s, announcedName()->s +-- signals: deviceAdded(s), deviceRemoved(s), +-- deviceVisibilityChanged(s,b), deviceListChanged(), +-- pairingRequestsChanged() +-- device : /modules/kdeconnect/devices/ iface org.kde.kdeconnect.device +-- props: type,name,isReachable,isPaired,pairState,verificationKey, +-- supportedPlugins,statusIconName +-- methods: requestPairing,acceptPairing,cancelPairing,unpair +-- signals: reachableChanged(b), pairStateChanged(i), +-- nameChanged(s), typeChanged(s), statusIconNameChanged +-- battery : /battery props: charge(i),isCharging(b); sig: refreshed(b,i) +-- conn : /connectivity_report props: cellularNetworkType(s), +-- cellularNetworkStrength(i); sig: refreshed(s,i) +-- share : /share methods: shareUrl(s),shareText(s); sig: shareReceived(s) +-- ping : /ping methods: sendPing(), sendPing(s) +-- sftp : /sftp methods: startBrowsing()->b, mount(), mountAndWait()->b, +-- mountPoint()->s, isMounted()->b + +-- ── Shell helpers (duplicated per the sandbox constraint; keep tiny) ───────── +local function shellQuote(s) + return "'" .. tostring(s):gsub("'", "'\\''") .. "'" +end + +local SVC = "org.kde.kdeconnect" +local DAEMON_PATH = "/modules/kdeconnect" +local DAEMON_IFACE = "org.kde.kdeconnect.daemon" +local DEV_IFACE = "org.kde.kdeconnect.device" +local PROPS_IFACE = "org.freedesktop.DBus.Properties" +local BATTERY_IFACE = "org.kde.kdeconnect.device.battery" +local CONN_IFACE = "org.kde.kdeconnect.device.connectivity_report" +local MPRIS_IFACE = "org.kde.kdeconnect.device.mprisremote" + +-- Build a gdbus call that returns one property as a variant. +local function gdbusGetProp(devPath, iface, prop) + return table.concat({ + "gdbus call --session", + "--dest", shellQuote(SVC), + "--object-path", shellQuote(devPath), + "--method", shellQuote(PROPS_IFACE .. ".Get"), + shellQuote(iface), shellQuote(prop), + }, " ") +end + +-- Parse a single gdbus return value. gdbus prints results as a tuple: +-- variant string : (<'24122RKC7C'>,) +-- variant bool : (,) +-- variant int : (<-1>,) +-- bare bool : (true,) (non-variant, e.g. NameHasOwner) +-- Validated against real kdeconnect gdbus output. +local function parseGdbusValue(raw) + if raw == nil then return nil end + raw = raw:gsub("^%s+", ""):gsub("%s+$", "") + -- variant form: (,) + local inner = raw:match("^%(<(.+)>%,?%)?$") + if inner then + if inner == "true" then return true end + if inner == "false" then return false end + local n = tonumber(inner) + if n then return n end + local s = inner:match("^'(.*)'$") or inner:match('^"(.*)"$') + if s then return s end + return inner + end + -- bare form: (true,) (false,) (-1,) -> strip tuple parens and trailing comma + local bare = raw:match("^%((.-)%,?%)$") + if bare == nil then bare = raw end + bare = bare:gsub("%s+$", ""):gsub(",%s*$", "") + if bare == "true" then return true end + if bare == "false" then return false end + local n = tonumber(bare) + if n then return n end + local s = bare:match("^'(.*)'$") or bare:match('^"(.*)"$') + if s then return s end + return bare +end + +-- Parse gdbus array-of-strings tuple like: (['id1', 'id2'],) +local function parseGdbusStringArray(raw) + if raw == nil then return {} end + local arr = raw:match("%[(.-)%]") + if arr == nil then return {} end + local out = {} + for item in arr:gmatch("'([^']*)'") do + table.insert(out, item) + end + return out +end + +-- ── Device data model ──────────────────────────────────────────────────────── +-- In-memory device table keyed by id (mirrors original PhoneConnectService.devices). +local devices= {} +local deviceOrder= {} + +local function snapshotDevices() + local snap = {} + for _, id in ipairs(deviceOrder) do + local d = devices[id] + if d then snap[id] = d end + end + return snap +end + +local function publishDevices() + noctalia.state.set("pc.devices", snapshotDevices()) + noctalia.state.set("pc.order", deviceOrder) +end + +-- ── Persistent state (selected device) ────────────────────────────────────── +-- noctalia.state is in-memory only and cleared when the plugin stops, so the +-- last selected device is persisted to pluginDataDir()/state.json. Per-device +-- image/type/recent-images maps will live here too once their UI lands. +local DATA_FILE -- resolved lazily; pluginDataDir() may be nil very early + +local function dataPath() + if DATA_FILE then return DATA_FILE end + local dir = noctalia.pluginDataDir() + if not dir then return nil end + DATA_FILE = dir .. "/state.json" + return DATA_FILE +end + +local function loadData() + local p = dataPath() + if not p then return {} end + local raw = noctalia.readFile(p) + if not raw then return {} end + local ok, data = pcall(noctalia.json.decode, raw) + if ok and type(data) == "table" then return data end + return {} +end + +local function saveData(data) + local p = dataPath() + if not p then return end + local ok, encoded = pcall(noctalia.json.encode, data) + if ok and encoded then noctalia.writeFile(p, encoded) end +end + + local function persistSelected(dev) + local data = loadData() + data.selected = dev + saveData(data) + end + + local function loadImageMap() + return loadData().imageMap or {} + end + + local function persistImageMap(map) + local data = loadData() + data.imageMap = map + saveData(data) + end + + local function loadAliasMap() + return loadData().aliasMap or {} + end + + local function persistAliasMap(map) + local data = loadData() + data.aliasMap = map + saveData(data) + end + + -- restore last selection and image map at load + do + local saved = loadData() + if saved.selected and type(saved.selected) == "string" and saved.selected ~= "" then + noctalia.state.set("pc.selected", saved.selected) + end + noctalia.state.set("pc.imageMap", saved.imageMap or {}) + noctalia.state.set("pc.aliasMap", saved.aliasMap or {}) + end + + -- ── Translation loader ────────────────────────────────────────────────── + -- noctalia.tr() follows system locale and can't be switched at runtime, + -- so we load both translations ourselves and publish the active one + -- to state. Each UI entry reads pc.trTable for a local t(key) wrapper. + local function flattenTable(t, prefix) + local out = {} + for k, v in pairs(t) do + local full = prefix and (prefix .. "." .. k) or k + if type(v) == "table" then + local sub = flattenTable(v, full) + for sk, sv in pairs(sub) do out[sk] = sv end + else + out[full] = v + end + end + return out + end + + local function loadTranslations(lang) + local dir = noctalia.pluginDir() + if not dir then return {} end + local raw = noctalia.readFile(dir .. "/translations/" .. lang .. ".json") + if not raw then return {} end + local ok, data = pcall(noctalia.json.decode, raw) + if ok and type(data) == "table" then return flattenTable(data) end + return {} + end + + local function publishTranslations(lang) + local table = loadTranslations(lang or "en") + noctalia.state.set("pc.trTable", table) + noctalia.state.set("pc.lang", lang) + end + + -- initial load + publishTranslations(noctalia.getConfig("language") or "en") + +-- ── Backend availability ───────────────────────────────────────────────────── +local function detectBackend() + local hasGdbus = noctalia.commandExists("gdbus") + local hasCli = noctalia.commandExists("kdeconnect-cli") + local available = noctalia.commandExists("kdeconnectd") or hasGdbus or hasCli + -- A more precise check: is the DBus name actually owned? Use gdbus if present. + if hasGdbus then + noctalia.runAsync( + "gdbus call --session --dest org.freedesktop.DBus " + .. "--object-path /org/freedesktop/DBus " + .. "--method org.freedesktop.DBus.NameHasOwner " + .. shellQuote(SVC), + function(res) + local owned = false + if res and not res.error then + owned = parseGdbusValue(res.stdout or "") == true + end + noctalia.state.set("pc.backend", { + available = owned, + name = owned and "KDE Connect" or "None", + announcedName = "", + selfId = "", + hasGdbus = hasGdbus, + hasCli = hasCli, + }) + end + ) + else + noctalia.state.set("pc.backend", { + available = available, + name = available and "KDE Connect" or "None", + announcedName = "", + selfId = "", + hasGdbus = false, + hasCli = hasCli, + }) + end +end + +-- ── Fetch device properties via gdbus ──────────────────────────────────────── +-- Fetches the device interface properties and merges into the in-memory record. +local function fetchDeviceProps(id, cb) + local devPath = DAEMON_PATH .. "/devices/" .. id + -- GetAll would be ideal but gdbus call needs the method signature; use + -- individual Get calls for the fields we need (simple and robust). + local fields = { + { "name" }, { "type" }, { "isReachable" }, { "isPaired" }, + { "pairState" }, { "verificationKey" }, + } + -- We issue them sequentially via a small recursive helper to keep ordering + -- simple. (Concurrent runAsync would race on the shared `devices` table.) + local i = 1 + local function next() + if i > #fields then + publishDevices() + if cb then cb() end + return + end + local prop = fields[i][1] + i = i + 1 + noctalia.runAsync(gdbusGetProp(devPath, DEV_IFACE, prop), function(res) + local d = devices[id] or { id = id } + if res and not res.error then + d[prop] = parseGdbusValue(res.stdout or "") + end + devices[id] = d + next() + end) + end + next() +end + +-- Fetch battery + connectivity sub-interface properties (only if paired+reachable). +local function fetchDeviceExtras(id) + local d = devices[id] + if not d then return end + if not (d.isPaired and d.isReachable) then return end + local devPath = DAEMON_PATH .. "/devices/" .. id + -- battery.charge / battery.isCharging + noctalia.runAsync(gdbusGetProp(devPath .. "/battery", + BATTERY_IFACE, "charge"), function(res) + if res and not res.error then + local d2 = devices[id] + if d2 then + d2.batteryCharge = parseGdbusValue(res.stdout or "") + devices[id] = d2 + publishDevices() + end + end + end) + noctalia.runAsync(gdbusGetProp(devPath .. "/battery", + BATTERY_IFACE, "isCharging"), function(res) + if res and not res.error then + local d2 = devices[id] + if d2 then + d2.batteryCharging = parseGdbusValue(res.stdout or "") + devices[id] = d2 + publishDevices() + end + end + end) + -- connectivity + noctalia.runAsync(gdbusGetProp(devPath .. "/connectivity_report", + CONN_IFACE, "cellularNetworkType"), + function(res) + if res and not res.error then + local d2 = devices[id] + if d2 then + d2.networkType = parseGdbusValue(res.stdout or "") + devices[id] = d2 + publishDevices() + end + end + end) + noctalia.runAsync(gdbusGetProp(devPath .. "/connectivity_report", + CONN_IFACE, "cellularNetworkStrength"), + function(res) + if res and not res.error then + local d2 = devices[id] + if d2 then + d2.networkStrength = parseGdbusValue(res.stdout or "") + devices[id] = d2 + publishDevices() + -- if network still default, nudge the phone to report + -- (but not more than once per 120s) + if (type(d2.networkType) ~= "string" or d2.networkType == "") + and d2.networkStrength == -1 then + local now = os.time() + local last = d2._netRefreshAt or 0 + if now - last > 120 then + d2._netRefreshAt = now + devices[id] = d2 + runCli("--refresh -d " .. shellQuote(id)) + end + end + end + end + end) + -- mprisremote: media fields fetched sequentially (parallel had callback races). + -- We publish once at the end to avoid flicker. + local mprisFields = { "isPlaying", "title", "artist", "album", "volume", + "localAlbumArtUrl", "length", "position", "canSeek" } + local mi = 1 + local function fetchMprisNext() + if mi > #mprisFields then + local d2 = devices[id] + if d2 and d2.medialocalAlbumArtUrl and d2.medialocalAlbumArtUrl ~= "" then + local art = d2.medialocalAlbumArtUrl + if art:sub(1, 7) == "file://" then art = art:sub(8) end + d2.mediaArtPath = art + devices[id] = d2 + end + publishDevices() + return + end + local prop = mprisFields[mi] + mi = mi + 1 + noctalia.runAsync(gdbusGetProp(devPath .. "/mprisremote", MPRIS_IFACE, prop), + function(res) + if res and not res.error then + local d2 = devices[id] + if d2 then + d2["media" .. prop] = parseGdbusValue(res.stdout or "") + devices[id] = d2 + end + end + fetchMprisNext() + end) + end + fetchMprisNext() +end + +-- ── Full device list refresh ───────────────────────────────────────────────── +local refreshing = false +local function refreshDevices() + if refreshing then return end + refreshing = true + noctalia.runAsync( + "gdbus call --session --dest " .. SVC + .. " --object-path " .. DAEMON_PATH + .. " --method " .. DAEMON_IFACE .. ".devices false false", + function(res) + refreshing = false + if not res or res.error then + noctalia.log("[phone-connect] devices() failed: " + .. (res and res.error or "nil")) + return + end + local ids = parseGdbusStringArray(res.stdout or "") + -- remove gone devices + local seen = {} + for _, id in ipairs(ids) do seen[id] = true end + for i = #deviceOrder, 1, -1 do + if not seen[deviceOrder[i]] then + devices[deviceOrder[i]] = nil + table.remove(deviceOrder, i) + end + end + -- add new + for _, id in ipairs(ids) do + if not devices[id] then + table.insert(deviceOrder, id) + devices[id] = { id = id } + end + end + -- fetch props for each (sequential via a chain) + local idx = 1 + local function fetchNext() + if idx > #deviceOrder then + publishDevices() + -- then extras for reachable paired devices + for _, id2 in ipairs(deviceOrder) do + fetchDeviceExtras(id2) + end + return + end + local id = deviceOrder[idx] + idx = idx + 1 + fetchDeviceProps(id, fetchNext) + end + fetchNext() + end) +end + +-- ── Command execution (from UI entries via pc.cmd) ────────────────────────── +-- We use polling for state (verified reliable) instead of DBus signal +-- monitoring: empirically, dbus-monitor captured ZERO kdeconnect-path signals +-- even when triggering refresh/ping/pairing cycles, because kdeconnectd emits +-- most state changes from the phone side on device-specific paths and they are +-- sparse on a stable paired device. So: periodic update() refreshes state, and +-- state-mutating commands trigger an immediate refreshDevices() on completion +-- for responsive UI without waiting for the next poll. + +local function runCli(args, cb) + noctalia.runAsync("kdeconnect-cli " .. args, function(res) + if res and res.error then + noctalia.notifyError(t("error.no_backend"), res.error) + end + if cb then cb(res) end + end) +end + +local function gdbusDeviceMethod(id, method, cb) + local path = DAEMON_PATH .. "/devices/" .. id + noctalia.runAsync( + "gdbus call --session --dest " .. SVC + .. " --object-path " .. path + .. " --method " .. DEV_IFACE .. "." .. method, + function(res) + if res and res.error then + noctalia.log("[phone-connect] " .. method .. " failed: " .. res.error) + end + if cb then cb(res) end + end) +end + +-- Call a method on a device sub-interface (e.g. sftp, mprisremote, sms). +-- sub is the path suffix + interface suffix, e.g. "sftp" -> path /devices//sftp, +-- iface org.kde.kdeconnect.device.sftp. extraArgs is a shell-quoted arg string. +local function gdbusSubMethod(id, sub, method, extraArgs, cb) + local path = DAEMON_PATH .. "/devices/" .. id .. "/" .. sub + local iface = "org.kde.kdeconnect.device." .. sub + local cmd = "gdbus call --session --dest " .. SVC + .. " --object-path " .. shellQuote(path) + .. " --method " .. shellQuote(iface .. "." .. method) + if extraArgs and extraArgs ~= "" then cmd = cmd .. " " .. extraArgs end + noctalia.runAsync(cmd, function(res) + if res and res.error then + noctalia.log("[phone-connect] " .. sub .. "." .. method .. " failed: " .. res.error) + end + if cb then cb(res) end + end) +end + +-- Call a sub-interface method that returns a single value; parse the result. +local function gdbusSubMethodValue(id, sub, method, cb) + gdbusSubMethod(id, sub, method, "", function(res) + if not res or res.error then cb(nil) return end + cb(parseGdbusValue(res.stdout or "")) + end) +end + +local function handleCommand(cmd) + if type(cmd) ~= "table" then return end + local op = cmd.op + local dev = cmd.device or "" + -- helper: run a mutating op, then refresh so the UI updates immediately + local function mutating(fn) + fn(function() refreshDevices() end) + end + if op == "refresh" then + refreshDevices() + elseif op == "select" then + noctalia.state.set("pc.selected", dev) + persistSelected(dev) + elseif op == "ring" then + runCli("--ring -d " .. shellQuote(dev)) + elseif op == "ping" then + local msg = cmd.message or "" + if msg ~= "" then + runCli("--ping-msg " .. shellQuote(msg) .. " -d " .. shellQuote(dev)) + else + runCli("--ping -d " .. shellQuote(dev)) + end + elseif op == "clipboard" then + runCli("--send-clipboard -d " .. shellQuote(dev)) + elseif op == "share_text" then + runCli("--share-text " .. shellQuote(cmd.text or "") .. " -d " .. shellQuote(dev)) + elseif op == "share_url" then + runCli("--share " .. shellQuote(cmd.url or "") .. " -d " .. shellQuote(dev)) + elseif op == "share_file" then + runCli("--share " .. shellQuote(cmd.path or "") .. " -d " .. shellQuote(dev)) + elseif op == "set_image" then + local map = noctalia.state.get("pc.imageMap") or {} + local path = cmd.path or "" + if path == "" then map[dev] = nil else map[dev] = path end + noctalia.state.set("pc.imageMap", map) + persistImageMap(map) + elseif op == "pair" then + mutating(function(cb) gdbusDeviceMethod(dev, "requestPairing", cb) end) + elseif op == "accept" then + mutating(function(cb) gdbusDeviceMethod(dev, "acceptPairing", cb) end) + elseif op == "reject" then + mutating(function(cb) gdbusDeviceMethod(dev, "cancelPairing", cb) end) + elseif op == "unpair" then + mutating(function(cb) gdbusDeviceMethod(dev, "unpair", cb) end) + elseif op == "browse" then + -- SFTP: ensure mounted, then open the phone's storage directory. + local function tryOpen() + gdbusSubMethodValue(dev, "sftp", "mountPoint", function(mp) + if not mp or mp == "" then + noctalia.notifyError(t("error.browse_failed"), "no mount point") + return + end + -- getDirectories returns a{sv}, we need to parse it differently. + -- Use a raw gdbus call and extract the first directory path. + local path = DAEMON_PATH .. "/devices/" .. dev .. "/sftp" + local cmd = "gdbus call --session --dest " .. SVC + .. " --object-path " .. shellQuote(path) + .. " --method org.kde.kdeconnect.device.sftp.getDirectories" + noctalia.runAsync(cmd, function(res) + local dirPath = mp + if res and not res.error then + -- output like: ({'/run/.../storage/emulated/0': <'Internal'>,},) + -- extract the first path in single quotes + local first = (res.stdout or ""):match("'([^']+)'") + if first then dirPath = first end + end + noctalia.runInTerminal("xdg-open " .. shellQuote(dirPath)) + noctalia.notify(t("notify.opening_browser"), dirPath) + end) + end) + end + gdbusSubMethodValue(dev, "sftp", "isMounted", function(mounted) + if mounted then + tryOpen() + else + gdbusSubMethodValue(dev, "sftp", "mountAndWait", function(ok) + if ok then + tryOpen() + else + gdbusSubMethodValue(dev, "sftp", "getMountError", function(errMsg) + noctalia.notifyError(t("error.browse_failed"), + errMsg or "mount failed (sshfs installed?)") + end) + end + end) + end + end) + elseif op == "sms_send" then + -- Send an SMS via kdeconnect-cli (handles address/message/attachment). + local args = "--send-sms " .. shellQuote(cmd.text or "") + .. " --destination " .. shellQuote(cmd.destination or "") + .. " -d " .. shellQuote(dev) + if cmd.attachment and cmd.attachment ~= "" then + args = args .. " --attachment " .. shellQuote(cmd.attachment) + end + runCli(args, function(res) + if res and not res.error then + noctalia.notify(t("notify.sms_sent"), cmd.destination or "") + end + end) + elseif op == "launch_sms_app" then + gdbusSubMethod(dev, "sms", "launchApp", "", nil) + elseif op == "media" then + -- MPRIS control. action: Play|Pause|PlayPause|Next|Previous|Stop + local action = cmd.action or "PlayPause" + gdbusSubMethod(dev, "mprisremote", "sendAction", + shellQuote(action), function() + -- delay 600ms for phone to update MPRIS state + noctalia.runAsync("sleep 0.6", function() + refreshDevices() + end) + end) + elseif op == "media_seek" then + -- set position property directly (seek method is unreliable) + local offset = tonumber(cmd.offset) or 0 + local path = DAEMON_PATH .. "/devices/" .. dev .. "/mprisremote" + noctalia.runAsync( + "gdbus call --session --dest " .. SVC + .. " --object-path " .. shellQuote(path) + .. " --method " .. shellQuote(PROPS_IFACE .. ".Set") + .. " " .. MPRIS_IFACE .. " position " + .. shellQuote(""), + function() + noctalia.runAsync("sleep 0.3", function() refreshDevices() end) + end) + elseif op == "media_set_volume" then + -- volume is a readwrite property; use gdbus Set. + local vol = tonumber(cmd.volume) or 0 + local path = DAEMON_PATH .. "/devices/" .. dev .. "/mprisremote" + noctalia.runAsync( + "gdbus call --session --dest " .. SVC + .. " --object-path " .. shellQuote(path) + .. " --method " .. shellQuote(PROPS_IFACE .. ".Set") + .. " " .. shellQuote(MPRIS_IFACE) .. " volume " + .. "", + function() refreshDevices() end) + else + noctalia.log("[phone-connect] unknown cmd op: " .. tostring(op)) + end +end + +-- ── Signal monitoring ──────────────────────────────────────────────────────── +-- Deliberately NOT implemented: see note above the command section. Polling +-- (update()) + immediate refresh after mutating commands covers state updates +-- reliably without the fragility of parsing dbus-monitor's multi-line variant +-- output. Revisit only if a concrete need for push notifications (e.g. incoming +-- shareReceived) arises; even then, prefer `gdbus monitor` filtered to one +-- member over a generic parser. + +-- ── Lifecycle ──────────────────────────────────────────────────────────────── +local function intervalMs() + local secs = tonumber(noctalia.getConfig("state_update_interval")) or 30 + if secs <= 0 then return 60000 end + -- if media is playing, poll faster for live position updates + for _, d in pairs(devices) do + if d.mediaisPlaying == true then return 1000 end + end + return secs * 1000 +end + +function update() + noctalia.setUpdateInterval(intervalMs()) + detectBackend() + refreshDevices() +end + +function onConfigChanged() + noctalia.setUpdateInterval(intervalMs()) + -- apply custom_image to selected device + local img = noctalia.getConfig("custom_image") + if img ~= nil then + local sel = noctalia.state.get("pc.selected") + if sel and sel ~= "" then + local map = noctalia.state.get("pc.imageMap") or {} + if img == "" then map[sel] = nil else map[sel] = img end + noctalia.state.set("pc.imageMap", map) + persistImageMap(map) + end + end + -- apply device_alias to selected device + local alias = noctalia.getConfig("device_alias") + if alias ~= nil then + local sel = noctalia.state.get("pc.selected") + if sel and sel ~= "" then + local amap = noctalia.state.get("pc.aliasMap") or {} + if alias == "Your-Phone" then amap[sel] = nil else amap[sel] = alias end + noctalia.state.set("pc.aliasMap", amap) + persistAliasMap(amap) + end + end + -- reload translations when language changes + local lang = noctalia.getConfig("language") or "en" + if lang ~= noctalia.state.get("pc.lang") then + publishTranslations(lang) + end +end + +-- IPC hook for external driving / testing: `noctalia msg plugin [payload]` +-- event "refresh" -> full device refresh +-- event "cmd" payload=json -> execute a command table ({"op":"ring","device":"id"}) +function onIpc(event, payload) + if event == "refresh" then + refreshDevices() + elseif event == "cmd" and payload then + local ok, cmd = pcall(noctalia.json.decode, payload) + if ok and type(cmd) == "table" then + handleCommand(cmd) + else + noctalia.log("[phone-connect] onIpc cmd: invalid json payload") + end + end +end + +-- Top-level init runs once at load. +detectBackend() +refreshDevices() +-- Command channel: UI entries write { op, device, seq, ... } to "pc.cmd". +-- seq is a high-resolution timestamp so it never resets on hot-reload (unlike +-- a counter which would cause commands to be silently dropped after UI reload). +local lastCmdSeq = 0 +noctalia.state.watch("pc.cmd", function(cmd) + if type(cmd) ~= "table" then return end + local seq = tonumber(cmd.seq) or 0 + if seq <= lastCmdSeq then return end + lastCmdSeq = seq + cmd.seq = nil + handleCommand(cmd) +end) diff --git a/phone-connect/thumbnail.webp b/phone-connect/thumbnail.webp new file mode 100644 index 0000000000000000000000000000000000000000..e13c57cf2b34324ea0f65465e0dd55879850649e GIT binary patch literal 58674 zcmWIYbaT^s%D@or>J$(bU=hK^z`&ruz`(GdnL(O~!PD6}-~=NB0|Nu&2@uI*z`&53 zS5g$@?xYYA8KuDffPs+#EYHA@m|R={QiB6CGBA9*22p!i7l#^r!kj6o#mNi|3?CR6 z7*vWPBBK}>7)2Nu7(~(`Yz+`Q3BtAkvCB&eN*EXz13>JekRWFU2F4Ty1_q6EBz6)K zJGr0;q`rrNfx#v>rxaut$UPw6@ucR31~V`)a4;}1$S@Q$1TnZXIDz~RQoz8Fzleds z;wu9K^8|!g(o6=1?X3(961NayDhn7G_*)nlww*_aA;P2}u_zI29t#5l15;WW1H-4~ z3=F)H3=F~-7#O&s!Ey`?3^?2diZmGpcZPh1e1>#}9EMZ|1qM%sJceWjJq85^BL)Ko zLk6?301E}1#|#Vx_n77~Y8_yfVY1_7YIJCHU|Xs$%=?$D4DVn2+F#3% zE8#=X=DxhI#2mbAJ8!Y<)8P5_8|u??3b3*C~{LIlW)*U;R`0 zcj>?HFX#*YDF5)^sroJdS^uy9$N$j&>#kS7^MA*c?z#H!{Ga-F{y*yf#fR;GS%2lf z=fBDS^3TqHJ^#`F$Mr3LKmKC=>+|3K@BKgbf9rp>|7%zIzhEEd|FZwne=>d${yF{I z{O|vt>@T*jwR=^6i>TKcmL;q$M-L-FZtj3bLa2> z-x&W#|Np;%{r~&`ez*VX|8M^7{_y6D>*Kz$^|GfHr4c|Z8ua{ri-?cyaKjGi)|Hgmi@9XcWKeqqG{F-`;-^c$f`FHe_ z;E(o?`OoLy`X6Dx?*EDZ*5BW%|F!+U^%eK4@Duf${#X82{CofR^ndYR=hxl${b&C1 z_4oGY=U>)u{lD%%=dZ25FaBWu6aVA>*Z4Q}5AAo?7ydu_e(%5cC(B>%e{27&e#f7N z-%S7H{~Z3E|9$@#`A7CU|G)V?@jvt5`k(q=?!U4>`2Xim^%aVeYJc_rivOJd zzyIC;LjR-xQ~R6$xBTz(8+Pv(*DRQrt{g@XTYb2IUadfvz>8M@=sM zPtz5fK57W1zCY?_vZJcAFy`BV%6+zreZul~D|(&Qvll+O>4BEwk#CW6Tz|fJEc|ii zf&8m#)mx2qhg*#7RnkRAFtdCdQ<7J}16hxgdB%(&*TRPaH~ewY+h& zH!(cF{LsSwce>&E4&L+87X^>cWI@wxt$*K-b&poKmK&_KZ&s;8N=%pb7c%6sZp^py`J_0PV|I(O!49KoJg%}n zdK*+&^E(e0b^n*MKb6{U@Lj(CKgagE%8UY+tq~loJUN|>5sz%#Tkf0H*EC8iY^nYe zUiHWHgU1ft+b0F{-%sgTJpb`?A(f5S-cLU}>&6lr<`$E>U+#arU+mcOQrO_a<#w5i z3=0muoXZ&Ed3wjqvfWk8@)=r!7o9UUr*g%7=vj5^$k!(mcKyBb|I6+EV_7{*BFg&D zy>K^FGT8cQy8W{Gyg5Y|*xw#o?GbS~wNAY;|BCw)hrpP>I(t7$1?)f5`!)OQ^xskZ zEsH*@F&ITP{qSFUw5DZ!P+8l9-(s1jt9+xLt0nWB*(|J zWM%!>ZB}Eckff(WzG`!rl19SR?y$SanN+vB|4@~FY4}xNW^#qh-IQf-m#O9l z=B`VAweRMm&EG2;9c?^4`8J3gGC%Bhqo%yx;?+2zGCMxO2 z7N~9S+svtNvDj4Bjt@g>aea{MVV=krt|F*5X_R(*>Y-@)f z76}#wJ`EF`HCQ}tLk-^aUf9N4&h0ka;AB%{h21NrTopC_x$RObj6a)9dh+Ff+nc3* zw_DC_uk5+%xKZriZ>N%mU-k}?KGPgaOr@-k)HzLOT(vX#|D_GnEbe@^kyk!+BO++R z3ezn~5>r;V%sCad=1%&#|FzjHLM?mVs^T%Iz6|tXMKWEmTAAKwB z^#9&YDLuC7^3(ax_kG<}<9NbdZ?`gwtOb|xUH1nT&P-RNYEyGgzN>r7eqQyt=`<-Z zxl{XDpFHl^{&iAR)uGkDHRTyXFWJZMHQsFG^Yv)@sed<{SQRWI9w_HtTou@>U&)v3aF;Lnctzm;uSLJL%P%pPs$A5J+9a9er{aF=MQp;iQxDWx7|yX3 zaa`a!$JeWSWUXxCp{~4llrshYSk~`d(^x&D?+W|Y;##kA z6J?1VGxD_Ca(K;m^*Kks;#v2cJwYH{P@Y*f`{r3S!AQpE=2zzDvAsO#J2^D>?9bi$ z6_3{553N*<+m`h4WP-y z!cX4nJNz@>*B{$#6q@`d(yssL7v~=$l66*GH>bHIwr+4PGoNO6^(>#HaNYZL^RKMw zKD+FSf~WWg?;i}087`z9KlnsDqf~ig&^hU3-?p-o)6UKO=6t|!`d{uH=c)`2{dxKK zJ9l?z1XJO=qh&KEPkR4i)|)GIlCA2O}qc0v~br{srAPu>{n~z-FrIkK8w~H zwdJWM`#!6zYjjxrBdOf&p#A(a&#c5l=Z3xKWx90bordp~-%}YLzADj)X=G2F>Hbxx z;>5M8@*`^>{**FT%jHhAKNR-yo{Q3Rqy2w3mo!xNZ;4}nSE{GGVDaU*-UlB$vz33D z(G&G_=|PUX9#_Fqy}%Mv(O#RaeskoX{7-S%_+DJuCFDoUH5Q+^E8%yAcsDp^aK1Cx zwSB=GEv=n478)WecDgNHYEV-W<$dl0bGF&vZeuIiU8<=$ZI@QMg$NzF)_hwjW6@L% zY2OzYq9)AQZuPsizs5EBNkdRd&Br$8?MLVTc2joLS#N53(9O3;!_MDY#WDS7<=V}2 z+77rSxlC2cWZk8&(|B#&)+)2rg4ta&*1Z#{IC$g!*|PW1_HnYmSG8Bf|EW8gyZl{L zp^Lc2+?h-Tu}4GaCN-8FNZxsQMP*0q^W_tZ?KujU|4`A5TwiUzs^CA@g5FK6iI$yj zR%}RGSGYJ{O6qd*Mcus>uhgoO4TL_*eQjp0S;6pgJIkSC6Fk<QO+CMpdmDz$JB?aFnc-QXpuo2Rm@MM`+Zo+@V ziu~Qn4jG;|5-eyq(XG6xAoFFi!IZ7JH)ifqTq3aIpW`h5y{4aMth@M=rE6NbskG)+ zAIaCL^KSBg^8L$mYF2}`_{3vzFAMb_*XDnA|6o1+O3E2#Bj&oeV~RRcj-Orosr2u( zKOWg1&XpzpIkh0-Ub)nxbqmgY))IO@^Fit(F`rEzQ&fdpnP+ZJjaPnl`gBx)*@|DW zQa`;v{5M)(I-l9V{J^n)uUGNL*e<#9WMk&%jWO%uQvbNTmTGxD-%n$0>V3VI0FOS$ z1ItxS>d)0(7J2))ca_M`oxe_s^C@WV^}OVBH@LA%_}K~mWq}EY1KR6kcg?Ds+PTax zZ{s?Rf(a9Ed~!`Yns?~#{rKWmpX`Hr`_|@V^i0Zl=QY9 zBy=XMGCVT0jKyKyc3)30nSE^{4%w8tw%k_TB)=z&uUU|Dr z^ym=J5!vy$*Q0h>(_5Dtf3H}|Uaa$)T2%gF*EELRYec4XN&lJnrM)se(LYPg^D_I{ z9Zw8$)RQFF9kV{jDOo;|;fLk9fZM-J-u`kg(D=4w{@N$b=PuN~Y4W}(GO=@q+{Tqp zX2o*vc41LJmsg|sb^3G7B;8O6gPS_9ze{?3Y`Hjd9-|n0>fLmssqH+ql8Ve5tB*Y{ zy0W;RX@gas;x>ci_Ge33m8#ZkJH_zTYVOO4r}Vt8m7kI+m}jVRV5dFXh3{<6?;V&H zC%q`YZCYBnW5G>LTXt)GXjOS5Mky_Vm`m z@*?GXA}JN!&R65lCb{qb8!E}E(m6_ z-j#_bRx~VslD7J9a;37*={IXXy;&w|=sM+Gwt+!K+jHy4$xew!qbIypo3!M5*`y~| zS0&i|Te&f-@}6YBZ0EN{~15-kY3jvBk#E=6!qpVN-$Y9 z?FZi%(Qn2_9i}_9Zeq)5W~x(|*K%X=mM7~NbMD>J)s~yz$9!G&#LnJaKlwi=%q+Gz zEah9)a7IJvoXhMzEQLlhj-Je$VDb5W_VTz(DSZarA=!$CKGPqqjILt%@Y#!#L-e7M zN!qUdUr@>l?XUG0M?n@Rk8Be-AU7RbuweHMbmFg@W#mF`L z`u?BWmKZ1crOajCm5A`4v$M8wKYPu{zg0~9#+dsqs>z$y$cWzF^g8UkfYQ1T z1~!v>o-bR(+4728p)SwuYpnH6A7{1h>ru|FoPL|08h$_ciAhb&a)Y@s-Ja+RC!{(b|T^B0WJmy$yRd4vg)84X2!zA;Xdc;=yrR?gxMQ)5BobMExKJ}^J%tPvU7dU_LuwK-T6LgFQf3H$fIF= z8c!dudN;w+`qkb7)_88a>ZZSY&c3?Lu)yAM%@muP{Ri&|KauAt{%F?tesxffQJ2t#NWV0y$rsuNhzovx#pCZS~|4DmycPz3IZ?^B>(#h|~l|{qBAub8MA-YI3gFg;%+; zEN>^L&E9q3|3B%A&KmQ(T2D)jd9C)&X!M9Qp>o@>z=wKJBqF3+vn1S zn{P;Iez!8SwrxKxxs7L$z|5w~#?71JpHKd`IpSo*jgAQ$`x90^TD5Ao?MpeqNAqN@ z)@jI#HJzKg@RRM9jAH!@f4O|P&vicgUd=z1!6$3I*I;Je72cPZFZ=j( z`m*LFLi}%hgZnPZ+*9<`(G+c*?&{!l;6m|_OK-3L{}?4uo3}e?#icXVmI@!fR!1Lv zI=fZSWW2t9TDc4&!h($6GrSHkx%*Ylk_ ziGTl*^b?DY3hrNZ;*QhKnBO5^Zv--*+u z?#))lYx!*6#a?MRm*&0M@n%tl^F;2%@MSwABNDC3TX#0-*6?`R@5}x6e_4>KJFDd6 zAOD!Bvi5j!xC#H! zRGpB?X6#JwX0+W4W9T#w^W~9kskz*3+9_@ldtI=1kL&IS^NsZw!UR~?2nMaqnEp^j z`t8KC(Q>mFrrQ6mwPKgnOt|>DOX+OUgFCmwqf-<@lKS6F{TtV$bZ%l%qtai_hx;OH z``+4}f2_i7?#~xKxk#e;#os@C*~gh*X@{>&Sh_c`*Ogbiv~m|q*~>I5kC;^xjpr#{ z;y=XIp(k>bec|bwzo*8(jJJ4G^4jFJ`PPF)8?JuN)S2!jryjmXKF<05$E|La4fVUH za{E3~nb7)1_&~38`Yp54wz`|m_V=|sz3apF zX}(_Y|Ks5c2j9l)?cW#VKEJZ(o$0QfuTJN2J^!=7TwQG13Dx!O9J@r%3NH~8JYOza z+?`p#Z&Z}7u}`u5&E<7s+a0!cPBh=XzIDmB=BbK(8O)`zE4)5>)+Dcr5S}(kt!I%W zYe~ALoyhHz(^&Tlmb|>>zUuU}f|sqESbv#$$DQAB1WJjUq7ECJ+nK@a1H1015X>C3*>C>W-@-Xxb2u>v1rn})6-r4TxtDav5HOe z&Ee&;tG4&Gn(KV1pSb<%_7czAHIKR`@7-M7bo%(MhD#47s4{yV342tMVz%3`O1Iu= z#a>G$@x8WULV;HgoU&lFvHz^^FnhJk`m(6&)>HDI%}$xPaLEHvkHyn&rmjhqT=nXg z@T^BC*jS=}K5||ZA-^=|`pJJ~y;JO7G_AjDyTEnzy_GX2X&n3ERz7jsM$6x;-p-Vi z3Odlf??L9#Y`%`w^2!A>FL@`kO=5VpDJoB8!z}s7TXS!$G;W(c<#oo=nx7R+A3J;M z0xqSz)ePHyRHLzG+b-ec^_H@e8APgHcprG^IDN-tDn9zFNwWst9f34gvQZ{{_l$_`@5G%`wJC*e4vut(S4P@@Oebn2e)|@3m*vP ze(cQND7xf{i0qB;i5s5^Hsw^FOgHkp%->unyKj5>JrkovAmn^dc0 zr{YzbXxwWvhf`!HMIOFua;uH6&0tl#)n+dbIJHu31f3)9L^Y`v*E+sNL&{(}$K zi-g7n({285?sJmZ8k*R~yIbyK`NzdSw)}5)J8tr6`_Ao~EBk&HF>=p(TKm6dN5qy! zHP59w<}1bb=!Lp<%=FmeqT0JK?R~4I*96CVOf_oj?sKFDw=rs#yH2g1B3#vY^h1B5 z#}&PU-39O{iW>MSP9*4 zOuJuq%1xT$a_P0BoKa%W)~yFL7<(KtH|aISNH;$+lyP1+{lu#Tr|jjDFA|{7vQ4?rH5Vm~NT)qfApc-oKf@_Wi9``z|N7iQC^Y{tr%BoL2Z( z?f31ZdG)*7t502xX-xVWv+!@+GA`z{Qm%TFgJbO*57C>RdCd@?{f5{ z^7;BbH|P9Z_pfbTMaf!O^B;4UzW;bX&Q~GL?2-QQopawE&Js$xqCDwBuS9}-=((p^ z7f(bLP3l{5=KdY?o2{~57a2oLA1uCmlqoN7w!u!VsGuj*H{*~SfBUvN^>TI!^J*-a(LxlnsFSvaQou6lBA%7_;ky-kFTmLH_Cip z@6W*Zye*{XVz9DWQJ6+f>wd}Z32rf$pLE!yrBAw=oEYf0WPjiF)!P(5ip%;IZvFY? z_1mu}bol=-6}I#H+F#dX!_uqlw4%Y7@t>|s`mKVN^z=UA(5pN5CivZXo_1}6m+pD*18;63ZeouZnW9BUJ zS?@~9P5qn_YkeNQ{8FUCZ(bicKX0Phwr_4-53>Gz`f~JVjqCsA&;NO!;X1G7v({v# z*uLB4&Q-i`o%ZP8)2{sUNvDXvMNdtvyXy7)z|#7ew^&d3bj~gJtlqix&Sajf3s2`9 znIgDgsh;>RHXV*xx$*O>o)txzvFN4M$oKwbn4hE7czWSeVdXvDCJxT;A`M>ue5~Qn zXU&jX_w34d75ASHx?i$Q{cYs@BS>Ggxb|HA=RjBYv}^w?YPZRnZND%>=A2aNh5vGs zkLk+R-U{Jl-g4JvKfBE3DhXGgkhGh{kA9fU@m#oW`xnn;DG{@qF2yP^?+8pO(Vo6N zHtXMv)fZy7{C{;LtaAZ>d+5dw{(e^rmaIv+`2E!CCjnkBep@kWDyu)clEv-b%M|OJ zG*6^f*&?WOa>L(pjjn9NGa@O=g)<&jT#Ji5)1YynjG<-^AA=_I)?ntJKUVMd4*&U% zxx-SYtKVkB!SGpOkr&09z&+_~nCaRF0puAP~(OVw3mzVpe*=SBZpJL&* zP-uzi1oMOi8rwY;OWBRrEEh~`S}PoK(d)qXsfEv^E8;IqWtGsGG()mE_IxzIX`zVo z7qye~cKUCjLyU&X4D6T#Fpes%@RNAZW@JAa({R_qGj;*wIe|JvOHcpAd zDoy3u%cK-Oh2nV%u0@j-o~6k}Kh-)~b5xyU@{$m{k^23mC zlO=s?WM-T<^9J2%arQ<28dv-sV{w7%cR80NgbD7wY*qoLflO>bW% zq(ttU{Uk*rG(hdjnacvxBL(&f$6q-Yc0u<<@J6k8tBE1&W+`?(ET4X3eag{WmvdG( zJI3#rwbXm+`S-a$5BKfgQ6SGFYIKqJe9DVczG<&MZoy#hp;$b=(!%?0nbdt_Kz>)xLKAHu@+`SykbaSy+MW~L1+H!D=s(88d~;&kq?eBc z?&h!W{O)*K+M};NYSw+W->S)rUAZpZWv?`_`n$kccFEq0oRh6$^O^4Rezy?h%JzNm zq@eNRXNLt5&3^5^{1+m)swVlb{aFP#P83)Q?0Z@GTBo<;E~L^0*$@i%KHd43I2x?3Ii)idUj z+)Htd7mp^f?O$qqQ0mSR=XoxrR;w@0Syj=NBC|p4LPk)0>8fwy>$h&4b(+O<@#~3P z4HXqLiyqhPxn-UzGexo3ch5Bo#w}YK^msh39G?8n_levdi_wk_uGpb zpHC@Y5^is~H~x>buU}4NwVjm5h36iXckZVy&vT7rthy@}?bg-CwR%Bi`umr&Jtb$( zou09F@yWTYGZq?LndYS-#Jt9T&;C1~j~<=pf6wQ%>D%3fbN+|($A3^Tbz59rzuorV zUW3Zxzi$;CZTEVTE@SK#^xW&B5QnKGXK5&hM3~t9id(a$tiJHpAbw?FTcy0^jYE=5 zGI#3o_s3MT1zz9$)9Cy{-zAwYwh~^STPO9IP4;;kX2deTO~KoaAHE#?xPC?W=f5eFvSoH;a&KSST=``0$@32??sZ?~=wsh7dAjT3DHDGB zylgJIwsAhojS2h3p8pFj5?b-HxMP9i{QqwjzGx{w&trMJVCLHW&%*q-9X&OF$I4*k zC-U32oqv3KnaPu%GiL5}760c@IMYF*Kl-KLgPF&J_HTaoIpKX|#)pf$*I0+vZ#vN& zd1Jp}=e0Jy-!Er&e*JeqI_U;)>|Ulhaq797&Mt{{`earnX5GC)?fZ$1O8RGoYCoj6 zUXSb8RsZjJ_B>0`XQ$3EUca{?;aJSgNh&dJUtdm+UB^+9(;ncyY`gE)SKl`!KiCtt zNAmQ&Nz-2Ig$n7X{W4EFS$^K-wDQ}`EsZN)>fVwp;;DGW5nOvVZ^hZ=LbZhk>k{;h4@G6H5_uYQ*itSv>A@s{ zC`X>(SJc<3wCZ*w3f}l|`eM3q{iy|$4HTD&E?8&Xx7YorrqS)0{j>kK2G#D#s9NtE z=WFr)>z9H@_GN}PQ}kz;|Ggy>*{$89GwHdsZ-vOQ?=E86$+YHEN{~l> z#3rTK_cyJ3`}dqkWLEmwF5%7Cb}ai=c-5J-oy@8yS7xu=I`3in)I`@8)=c}&R^L=^ zz7Vx*fBgSVCm!?X6}z%dn`HE_{eYCx&E8aTw#m8WJK}TioR6ImzPL-pViwc0_^|M@ z){iogb55^WlgB6)J@fg?!^{&rug%yOah^ZPfz?jQOW{e}p{He0{}uJ4%feJ6QljoJ z|NpQ0F7wV(oe!C}-t4-gIqUGtU0V}Fjh3EaOA>tfvcms%A=mq_$4dOe&pNGsRjW4L zD)8**4vPiF8@9$3^76{3OnU0#9&v7gMp(er8xw@8mH(?8*vanUvaeq$!0FRf33-Nh zOq(a(DBn_>eydp1^26rRsTs{4Qudxao667q-(EN|X6+eu_gAsZ@3*|Vll)<|(`^~X zXOm`U8k-nz`S7t_B4W+loFoR`9o@PAc4k?|SWTIm9x7X1WOH#V&zk;&`#$79Z_fMM zET)7}?@!SE&``4`Pa%}!o{`e|!*j|&(?ek%&O&_;3 z7qsnLUvTMcHCu*;k%gPF?~&to7}OR;T@`(Pc>CY>?|WXaRW&Sq(onN1B|qqt)w=z6 zH1x|JC|pzd$v>+!(K|ZMI-XtCB2c?EW7zSXBbb7Qyu5Y0CuDWfgyr*%-|gT&kAGZ*KI8derW zKjn+iQq(w{-ZXWYSM}6cW)tHMZuc`@W92?wW8wGH?Fr|8)L;61eh(8L$E0YkB;~bj z({&1#teeIiQ5d}I`F<6%ki6;w)3)Eb^2sl!^zGwMPcYLkTs=#M@wZ61M%#V6#r88- zZ4vk;p!lZq_immS;>>=|FPzS9Q|7+7f6@alsl82xJ;HIP`;N13*k-@-%bL?w3KKG9 zmiA97w9Q&>=+zv0BUMO6p3BtBzaWi;Jqa!-e<x{1ZBn1~t-MN9`7(chj zBDKE4l@SMaes_y=T$@&HK5yxP7OQCgzt7)w`%kw2UUYnH9OqWOqCX)AHTdKfj2pMq^_jv+cs>TNw?<{-$_)SE@9K1i$yM>P@&geL_`R`2@hcoB5O#N-x6DoAbzNmToo0yh;nhpQLUf!P_^yt*&sSMA$ z91i{4z3ye+N#E$Fd*z@)dchyxr-wPM>4=%f7F6v%%Wxm}V zju?^6*XFkTteaqXDSUCR&No}@V;UJ=Yh*ucbnsIS*W*{Y%zh)m=9T{uqimL~HW!%# ziW~GxpLER%+%72ih*fxrq-nF$m4Z3xKQ6D|v-W@v?IW6&u@2OLjcb2aW+WY;1 zK+*p0ud5B~79~AO%Izw8{56&@zpJ1;=5F)0E6VTGiYCW4=4OO>{P)QDJ~yJ}S(ZBA z-QeJ6-X%+V{&ma|x4*GbV4IJk!}psSV(Y#1yrlQ|8MWw~^t7q8e*MRzf6JjO-> zo!aDh+1t->Qk3iD>UD#{J>!)==1;x1qi4qL4fk0TGj+nICNpHFN!q%& zo;7cnFIAXVqw4WwL-U0Vk^g=Mvqxta7fRZiHXbuBZen*(|MmRLt&4TZGv_Gfr+?nO zxsR{?_u<+Arr`emmv>Xx9-f%0GNY+ROCw*8d9|CK@z%+4@7_!gHU0GEeDzFyN99?z zTi3n+sdXXnXOZX!^_#cz{)Bw#voEl8H`{VnbIy~0D|fE$F`nPtc=5vWPc_j@k?hP0YT;;#-f6#sDx$PQxtS#x~hhdCCNwsYoMc_$a!ZJl^#`n9%n z#Ye^Q4p)_xHdWY92$j8I9~H3WX29vw-n;gGZDxzIYdN_n*-3k~PiR`+BDU0DDUF-n zO7BzuxG_&>-#+ovTis@!UbjNWw=?f2Cu7kuYaJh z&*1T?6A^C~{4kB|S@2k?Dd^_iPg5`S*`H9JCFeaSYjHMH+v-zaxqaupy8fY{S>#0S zHbW2V!^y1uh8}M>2+fbqJpA*N+}CZ1mt||tbzcUP>n`TKB71^kDSZL^=tDQ8*kVFtDhj8_y0_NO7F~DE!RSF}RJQ6iV|>8V?>>y4YQAMagLc-f+y`PIyySvwg|wuG&yk2F%e zrrGQx{Uyxv*T~kR~rLQEOf9~k~cC% zDZiR-S=AiIn9%Y2(Z7$zi`=Bsp6{|MblqIY_~3Hwi>Q-6ac4UxsPpj7doFIwzHY7T ztDEx7Gyg{)=Kjy=KgD75xtSWZN}nwDPW3!`AnAjh!r#cvUKi%@&e$K9wCW>IbSuN; zDXwaBm0iNmIS1Y|RkSJE^Qgs`_4mQ*^h~9T%XOz+ZQL2&#Im&ZGbew9+5aQq2mZ4& zST&bhtl<@D_LcK$l$4OR_cVMH_O?II=EDnyB`2zKboPE+Z?W;xjUtD9j&Sz*N6n`# z_f5Yab?WzKrA=$^725u9b29w*KKjG;?tko)GVe!U_kQdt>da-jM^ahlsmhm0M_tn= zvo)S@?K;voGw0T(-6y3ExVlZ&+VGfz*H%rYev{gwo@Gm1V^(?I$W~a)(!PdIcXj?- zvAMGy1sIbun+&(qvFx35qEqI4ocp@ueh4l%h&%m zw_DdOXPWl*to?&F!PhT7cr7_MzoF%&`QfbMX9*vt=&ACh$<&rSk=PTiZt_-brQ=nR zq=l~Qq&}%Ff4cSZ@tC$Yg=f~e$0Pdw>7FZCoT@BVv|lf_NjSbQrja)`rr*6G_I}mj z0TISx_opp*Qwe}zH>ASRJc8KJ0EhXc9kJRF)yiQk$YuNoi zCVsKm<^JZfgA0GZ><_Qz`)+&LH10x9x=K&mpI1v#m-}h+rGza!U0@-%zD56uN4oD( z^}BZqJ$)*qq`T)Ved8##|MfKXmkXX=`93v3uItXEp!#Pl4;$Mg+mGb!yv(NPv_&Lh zhkJc?;pCYXvi*!aipMNwrX1+dy}Of1u9!Qz@=><&=^)4FR|+pRi2Z6RZM(Jf$-N}c zorzN1mn5I4OkMJ3`aPw??|#aEws^`WcjQ}Wy71T;e@tLWg6;-J}`Mq zGdDS~TZms=Z}Zb>v&;Rm71w#+*5CBTGu-CZ&fAZA4e#z)Bs6Ur$KJDfoZs_`t$76t zTXxTP4ewZdV~@hmb|YKPmZJt+U$5Hs#e2=`Cp-W3UG_BCoo%&2C1=vJiT_)ka9r?s z9%Xv8&0I0!SlU}(6_wXsJKZ)Joym>&zui)Dj5$EIDKKi`{so6`O1!f?cWT27%NY^R z+`Vse$lgfil&WL>w|vG<(>pUWBG}n|43hk2t&p7}%q7Zn)%!xqi#Dev*LAMGEU-#l zbvGePCH{%gM^#`;NrVDY5Z&$^Rm{?AukX zYPVz8-W@DDQ5{h?;d+S?lf?AbTrWzx1F!Hb)!I8#pJ!6x#FGs(f2?%>J%7!@m-V+d zA9{A}k&yVSZ~u3u2AH`>Xt>7cZ`U$Dcs=~#b2(Y@^?56}ek}J{#q#j^@`m#U8?6=8 zUdU7=$N#hClGI$uD%!BzzvY>Fo1H?>hg(xMKDI^jG-!k;=~^xMwrl&5qvf9)eHz!9 zM_Xjxzp~)SoYU6-{szv`+2UINyr}u1@fE$SGp<`E99)^oV!H8m)0|2-MvHo$CIexv z9&P1ZK`CFwM=Qd(HdI6{;Qgg|Yiat@ZuRr-_rfD*hl*T2di?tP7c+Gf>=b@WuNFMA zE0JgKYn9uZqeSAqm#ms>zrQGe^+)gJ#~&X34ri8YHTkWe^K4Q0ksg;&kFD#LU9w!a zVv&N~`#g1%M}}*TuUh?7=}GmP$pKQckLl`)^gF+Mm9)z3gUef8@xT?QSz{NjnAZMh z*1V62{yZDfH*-I-{kd$VF-wO2MP_5O^|xKN7}vWm6mk7DHLUgL+|!GNlJ}fD%jwva z@>5#og^;-l!=#v>j$w|!UaeRZpb_xtiR{yY#dmJzC!Rg`gXsYGPKL<`zlNF_{(Bd? zu)MOw;R9=+;n%1?MZygg4tmy~D^jA)Ek7k%_mNYj)cudgj%VpC**3ZTnxz>>zHL9L z;wSsbYCgO4i7Pu-=4_SuV;p9%{PDd>3X@eInpFmU`!IoZs$~L8*E7aj*W41n&b2aX zc1rHnJ~XHPV_Z~<&|0rs3%-WCHtd*t?ekr=1+k}@`q!UwQ+so;;P0C$>osS8a((>t zoxsL8QO@155eFRPmn-?+6c6>U-m*|_+dZM|eV$I77jD+v7jaj<|MXeXV!tBhAC2JFws%E-WI=5_WU)`SY zDaHI?=`FiG@217<=Foe!)NAccizc`<))ciLQ#&huOjYUKgUn-7h{~ z^s#F7_f>s+8b8Hvo+DM*kRJMPMfjCb_Az;u9@)ONt+M$eLCD;DqC{z~}8^3)y59wnR22+?lS&5F{dha7o88*00xX z6@TxP+1+!=Mk1fG;ajo3 zR^pB+cN`PgnL>-+q^|!s^-AueX^Y&>=j?DdH?PGGL z(PD`$IhMY)oLc7^iaWTy5-*1zs4!yk-PkFT{Kq?G=~rq0>yaOfzF2K}>b2oqcjcU~ z!m38QxK|j=yWqU4H|qT4>~pWaMmWviF59EaXek)YCtxJwUCYHk&$*HB(z6FCl}`&+ z96#C}*I#^w?X|Gyorcr=ykW~uHRvx=b6`vi>`s^3=lSndi&D^`%LnfMI+)Lsf5^7y z>fiYs`$N4iyeyvbk=c0p`hQoy095^A-zE|M_N3k-B2`Q?f3U#;(w^`)y!m$j zbmdI#8no}74}HVGUX|fKzt*gYX?7mHE4D3F-bi?EajZ3^GYrZM&J^af&eTw?r`%7gW z?kwHz{yw64iP5$-NggbL*6qD)5g!?aR>;(-%-Vn0FI|p3N1oM6^t9rknp;Ln8Kp;; zFM7KEui4CF0aN$9eYNvckiYbuu7l?*za0}zDau*@vzzxqY;=+V^Z!}SJM1oPzR1yG zUf|gEBz2>gx5hp8{rXeR=iCv0xmom3zz55iPIU*h^6<`yn!8Wd7{u-T_%~+jveWAq zFt!}Ack~PSIj!$l!=4a}0lnYU70ZV~sioRT8{X87IhIA*n}mz#}ZC~J%yl>lu)feShnX~U-;ZXA5$X4;W?)ts+J+uF_9ekCyO5|7XvAdR5 zsK z>y>}c_Ia}8FjtYduJ(&|(=YS&n`6H7sn}~D`rz5bbjhuH?dsGkKVx$CzR91#D)FF1 z?^BZb9Ldg(ttzKfoYoy%@wnAo>GFs0r^{A3PVA{F`@r$8UTIlgX;10TUiV)|*Xm!o z|Kdp#DpF9y|Q&EuJJc#B`e$E`V=RMhYN`RNy|r`B=Xc;?J& zk&ICvUY~oqgI&q_;(~pt+oxWg;v$x;Y$RxB?7aHet6hnE*{lEMU0%KB>%3)22C2yv zWgZ8&Y^&uvX|m%y`=9j{-UO7r<&4nQ^3Xt4Z5b zp3fZCIbuG{X=1Ba>IBr=`Sxu)RIZb`eNEA7VaJXabDF-E`v|yylArco?zWp>SAW0eh7&b^9w=$m8%~>Y$>G=M z&i`KP^;~!!I_)`r+Fv=`W=sE=I`W3GkkMDW0J@%i%YQA9d-C4|!j>oHRk2s;5bZDEd zd-a#CKYY^XdP*$lFAhpF*U_8xC!_WflVa~Bl?g?rQrqvVBwzfSy0q-$L35o=jEWr} zN^pca@TxWaq7geM>+xg){hs?Sr&h0_lzIg^rzdrljBWP zu81m|qEq-O`my#s$?|OzwsIZZYrFUL6gBBD%BSYYl^m9McCS>D>wM2Wx4ziJ&u(<> zovEz0@Ah25%&sTz&3GABdf9zzd6`i2_>RKmVDq^D4^pmd%y?ulHQ<-uoQ1s?TZ7$e zK5i*eDc_{9%e5|(POI;Ex-k6Qe(m;}-R}=>o#8S$ol|y| ze)C4HEyY*3&uE*nxpu0h?_T=0(l0^5%BJlb9}`cVfL`96wI8PLmY*hgwR_gbyu=y* z7VK@1-*F_eL)qoZDc_C1e>JH29hg7gCH}-|O(yFNFYA|z9;w|UZ?Q1$^y#;z`itb4 zc7#4FJ!d^>57U%_d0JL)z6fRIHF+Gk&TZ54%_oyn*e&aMfvoI4v*n&1FJ7wsV+`5( zirHu3iGH=4mu!#nyGjL0S;^)tkUPW6lP;vmCaIh_|6R+bh6MX!>7C0y?@K(NdQZ%3 z#}B(>9S1fg&e-Jh_5_E=-e(53VK*mxd=ImJ@b>#Jjm(AH8x5zWY})fGrpswt;6*FV zO;?LU|6faZu)llJ*9jXQy$?;u{{6Dq?iZ)5?dH(qOy05yCI;`aKNakJR(H~T!rirO zyBY-I(FiEcrbIiv)pRm1sk5P=oFIODU+NQb25+X_06`g<}o50#eQ#=OO9UZ zWY8k}LosRc&OZ}R?3XD}H*NpqWTDeIosGr2C-Fq5(8JXH^;&H)JC5`kY~wvEk=&kW z{-cX$xt#y{1vXjX@@3c4e>|Pan$feuY8ls@A1wRkYHu`(-Tdxq$KF|W4&8@1GXm3_ zehGBlTc&it$xG;sV3w)hW`^CTzNjjncrL%{@{$a(9ohO*EtRKSJ=}FoFLjgsg_7HH zClnJol^PdRs(qLv$vj(o4qsF2#Metd=my%HwwC`Z#Z~8Bc0c(|XU}{uMe(vNg+GlYS%uxM>ZKK3`tEQyb9!#Q%Fb0=E|%_{ zzck@=mF%`RnTJk2S`p7ZNhDli(!}2fgEy~_R4kNtc$gHl;d!KxmNk2M48zCi3xY*+ zU&^-M)4Y3H=GFcmtILmvrAsa3iBu1;Zs(mSXl8kxjWuHOft)k*r){6nQoj1o+QSF9 zW*>OAYGKNz`_WHdoeuxPe9rL3htf7?3k|7Nb3J%3ySOT{@NM|TbE)mnEt?Ix3Ewq; z?Wr(#ct0VeVwrhR15b@IbAiWG&)yq*Yj5;jKcb%(Yj~3@NB!H5`Po2N<9;vZoMRYZ$i~8 z{o=k{yJz>Et)}}Z9-qjtPYith1I+i`Cc)=Kr0wf=A`< zl>Yz9$J=$oH6-WOuiRf-SlBsNHtm}}-vjOg=QVfd-8jCG;e2iE(&&h|tQzrh1>27Q z)scKl?c^N=F1QEY)Ow<(X&$e8S$Hmgd~)}!dO7P$mnLLj?Qh>kf_y5W`1hh)wFu@)-?aZYQBaY zX?LDXS$E==p5g-$8(HpY_wx5%?RWTlKYXe2u2uP&|K7~{>yjq7roAawa!KyZ-9Oad zRmMgIiu~$a>-1!~#=8B{GVAuPx0P}6_#@!mmAOP^(Ie3df?u10>@zZ+`#jm^Gc(jI0(CW+p1*p}cJYJM%$F)=Co+~zRok~vYlVtl$(M*0hBC#g4lnH- z@6H$5dG(4>kU6WhRYqJqe_8F1<%f66ebs1Mry8QPUWv(c_Wu_e+@%M^^*FCQ+RWqH z^_A_*_Wx&oOx~ybj`~~YIzep-=S!WAeIKRj z7?<)VYQ4L-BlchM)~ge9%@#HtS$3Q=Nu~LqH_MB2J(<^^J-R*daPHO1kK&fOEn+bH zz@?wmp?Mw(aOsh|1lb$tR$Z%DaF$i04&v(-`K3~R~z_N)!fK7y_hr;kp% z=jbryTJxT-bCcdJKCOT0==5_RzU92$9M8fWa8=~bqwLdx$)*={nrC@9zB{+%YVh>@ zRqR{Xui6W&Hq7eb)O)h8*W%&UeKXq?P6fW3w&k{&$voAv8ots~0;-okuT{1&3Xv$9 zQ^@DO*6m?h>Q2U1wsln;OBxtJfG@F|f|Eb<)@d^NP1dQ_k({^4qm@pXs#K6D4lk&1cB-$V#gh__X7` z-96T@Esr-B^urEi&9_r7#4~gs`SNFXolS?tk!6l%bGAs= zdPxOKmhJM|?3dq=eyQb1Wv|^={U#Q+L&dj#d%M;?OrL-DE|YbTkZ<~=qYIV3PF}S1 zedv7Vqt;)0E#B-rE|U6mvCGw~%eK9mz#r6NV0&cGf7>M?&MQiK3uk8A=E|9?A5+WF ze^M>=#y#WDG^2uv{dR5^=|xw&ugti0nfdjlUh8ibQQfxF)PJ6f-7`Vy`+_Hv>`Y&J z$SkZ2{U-QFA;~&$saZy6*OE#rj|`hXkr(wg`8eCGH>`P?6wbf$C-?u9eHWMf`jKmH z=6U(pH;Fwi77E-)Tj%P!KAOH-l=tbqhADdlT{g{0jGcLOyMRvh3zoX03RlX_W^-yC zjX#)w{Ze&EQ~y5S3!T?(zZbq-ulOwHyVH}Nvpvt3ns~P}ST2fSI=lL+g~D>Jsq3A~ zzwX&mwrXO}yJxExdd~XobJ?XR>X6=v4KM#!HOBs1?C`y3qNbnrmeQQV6}>G!@6WYn znYbC|7D*}mO!zPP=bq5&W%=v! z>!9Z^SIu9u>QUUDtRt)M{QuBdtCk}Cn`KkE(}ZQk8OwgG@9u3bJ(%Yz`XqfZm;Lu! z+Y@;&bne+Acw`HY0y`(G`&j|U$@T_se`IgC!TPEHlbG+i7gtX*h!rKwE^N>{R}pur zX1V!Ae)GQH3yV7n8b2<1{q#oYm7tYB9P@c++%CS=rJQ@jQloWu__p?YEx-A#<^Se= zkmKh1IRDbOHSa#~_Z5^c>$hNxJa*DMhPhSJMq!GjiLizKeD|3L?Ps@L;!vH?C^1)g z&EplkcFRK*9scwv71n<`l99E1%jv>zLEj7)u9D5q=lECVuuS)A#><<<>IT`0(V*CI1cW=h8w}XCEy&;dW9lczZWf`@Pg*(%2Flz2&`7r`_ID!vAVb+nbmb_aG?xKBusR z>fA4Gu4gZ7-f>SMeo0Zd;;Gh8)v~I`x@^Dhn6uw`?WtQ%29d2E%zj+br~Vz*6n*1d zn7T1mwc@jN{gJlc+6(SB8O}-UUTIuA!TEH}O`b3OjD~)5Cc1Ab(A|FTbI$8}-q}AS zqWmU`&RtdfbLDmgE;;tp_8E^Be|i7qv$uUOXXDaHE4fn@d@TyTo?5G&L*Lf$?O8uh z^1`P$&bRezZ*lTG7YX~(VDUsY+jA@@>t2nXdALJl$p6tH!eTiFb#41(Z8A@` ztd;20oMOFR+-c#w1HF3$XFi-;S<+X(IcrluSl8p~N~!Dq5mQZ;N`6mWF3x{1Em=BS zY(?9%JIdczw!J*Ov}~Cj*SYYmr&Hcc-o>izyT5Qr=@Vh!BaGH|D*_%b+piM;IQsdI zD4Qu$I+I%E?o^=b@QKYyEK_)XQ(a9o#H;u!xBHZravw| z{fjf(bBj;cg2ES>U!ta0>3kQHzQ5_($xLtQva{-L&c_2E&*?lSnRH+O&-|H{p5h1I zoZm5Vb%@XX*>*o(^{e$4CM=0suxhQ?!4P&)=?(XcWal05(LKB;BxZ>$PjI8WMoJCS zBS(4r-*015ekmASuUM+SJ=f4_B9HD~^)H4w3s)a6vtXaUOYV8SgWGe?ZspT^(%SF%Y_bpRP+!3AowAWP3`h=918vC@Td(NKzGGoWn+>L*mSbnJmJWlVn z7jrAUwpK@>WzVg$wZARj8^5WEy~AsKLSWa9e-}?AHTXCS%GX_dRL<+C{fakE^wQ<9 zMakP;lPw)i&58WHaNUo%b5oUUY>fXawXrYcOxnV zO)u}N)LXVOb(Z!1oXo#Mvs|ovgMV#Ye3HkEYyPdz3OR8(7c@6m6mOGF3jbT2_;KAH zXUTRi=FMB{H7{+w<(B#O;hYKYLKa>OpQE(s<-V0u9^}5hyzY~VzWgU0rD>mzG(6Zi zrM}LAQQdEzerXHa&oFtvfSSVJ*EJ=-Xl2Gb-cr098<}(K=d8DR`|}OU51cq^py8sM zH$y1Dg7w_L+f1iF_$Zj2J(DWcpnvP4@WCo;4GsV0&vbq7T)4;lCTV$&^bTdFS=n1U zEIxR@W$}9>{Xj!rSZC&RZrjrB`V0k0?X{=gJk_7+#x7VR;JHpJ)qBUA=*4Ucg4s3a zn0%a}yMftfqek)@^-3{MdJBLLS>~tqg?;O8=kkpB&c1c3_Us zqC;vChp(t)EO!+8zt8OXRwdo19P1v+M=L$e{MC1TV!|Y`=TR=*YRofs_l6YRe0}le ztN&Y<{5{MlU3tdgO!>9TxA|wD%3pus$kSynneV6G^mNL5v0dEZwdSEMhnpWew3rxP z(fl^eS~~LL)cF}}AK%!YXqh#mYtJ*~nUg=J?_adp+UOsL=MrUA{_yn~od4o8=Le)( zG@m^<$x?b=jg*w1X@X^PrTKxIk-N+!^;TCH-uocO_VFwK;`Us-14nvKSg&^+p)7M|8?}%sv3PY;`F;Kv+BmYXRkM==M=44Yx7w8rYOxr$DaeLZaZe4cxBZ2=Y#3-vcDQyGe3)Z z35xjsJQC-^AbED8%2K~VqbI!^)I%>m58g3hnZmlSYzIG0-q)*o%VrzHgr$AcnSSqI z-M!+DQ1ZnI{4opl<|#6$-}m}ro%lX9rZY9ms!ML+Tv@jLEz9;=Jen1CwO8TX&1X-! zwakB>wq<8OBJ=#)qf<_rk-MTBLt{T}4=^-eHp{}R$yD7%;7?@jTSfKuv|CGmR=($Z zvu(~iOW)mY8~=*huAXRqKeT(@obqE6PIui52>QrZ`^rMPvgFRHfCcOGu5qZH_4|16 z;$>T*i(%HS>oz!CKhU|ZZ^en=BcH6lzL{kA*yZ>138hRKpKE6wI;d4^yX-&Pks{t} z?`71jrkoc@XltwU-@~gZpR|v2@~pIy$Kpr5A|=cX-Hi1uU(fiW#5LvRi#_K;(jG0& zF5eL_=Qa1~^+szJ#m`f{bvW6f?m$?cX56O2D-~JYYRodLcW>9bBI+l(a+1S@mk!6& ztQnUd&1@|^$+z_sf7sK{Pvw|I9cTO%*feW;%2ieSY;RM?NfOSv_EXNU*nca|>wvSw zXI{foooDYKiLY8~c0+Gs;k<_ZYh4c2HvU{@CO@zJ^%u7};Bfv7j__9j8jYVnJzb~z7tR~kEl|A)X zFSNM2Q#7@I>5f*L=g*GL{J{A6v-}cAh9z-q;Y2*~bHneLq?3%rt#JL(}q|`twO2PlqQ- zO0PZ|baY1j|0lV#mwA4<)Tp`D?SuAjwl#V(jVd>I+ZFyhzGCZN@1;5UdZpg?n&u14 z%a;lJf1EOXhVcwJlP_#mNiwQ;S!b)o=-Z0d2di!u-*Np~M&W@Zmfp5w`*klC?|pt< z*dy?M&Wj^UmUwcO)CnJs2wqn?_qLRHVsD_&iMC&DUFFi}tLDISe;9Q5}izv!gl`npo zQvT<3;K!o^0pTr{+pk^7S$Ap828Qsa%u92g9Y0+7Dn?%as#R&jg10Yz`K?KvT>IZm zJh^?l!nT!Hyq?{6i}cz2GND4m&}GFEP5HwAS+iR|K9tu#`e5pV&Nepb=tW(O<WrA@0^PoRlV`|T?ot!0 zh(2h0&im$@wuHGG!xWz7O|Pj->0Iw1+FZE6{zQ(``J^+6oQ9h55286PFJWD|v+U`D zqf4S5`OVt28mDi~kdGw*X0fTj3){3R}-y@v1z!s>NA5oWJn&%xM)%`rSZRYK-DhZOeIgl9f!;(1$Iw0WBqFtR6Ha3)2XGawYHZU?Y|tj_de5xDL=0s z-7UmDJ(oGb<*)Fg-Ew(s2i!ZhF8AHhcHY=sBtPb%ozdL2sg`MxlILobQzqQGy;H>H za_-t~>{nD1ugCnlcJ{(dyW<<*)%lE$_~O)O&JQiyZ%E8r5ACmXN(zxBE^_ z!sgnnqvlf`XJsC2czkKW`ZrfE-V*lYw`MYP&GFnM{a|A+p3!xCO2HUnrB=J+4JuP=fY^q)Uxjr7|uNpVE=Jm|HiHf za#Q^{H=pg~-uY+ibM2Pcea9~S5mgfVcvxgo=PRjQi4Sxm?&@?-i!v6k7fLy=tlRO| zA?4IdPP;qPewBz8{!~pXozK(id-t=r>&G?kKYpLXbLErZ+3j($s*esW+;HcN`P>_f z=e$m4zqMVsgKPhhRWH^uezX5m`a+7wZ?eA0{8m-b1Zmxtj)O^arEeMpWqV3i%f39B zQDpk~xX!M>23c1uA6Bp&UwP5ckY(2V9af%9w`aynvgIjF^ylJQc}Lhl*5y5;SF^;t zr3uT|EtS4=yZ^&$y{N3E#{fna`BH& zjF%p$P5QO7$|P~4lvhOfD%V3rYeL>SO?vog`reuwsr*^~uI3k_bC+-AiMiYG#P7b; z$3Gfx_RZZsCB?RGjTP6`X{T1?`~6!4JEd|C3SyHsspzl4V`ZNO#9Mp86(x7d3jc~K3gKo z?Ce##{GYkL+kdrvmDTamt~lMLb7Ytft!Mk0KX>xm8T(&<@jvYl8>e@};@kQq(sxRE zAIIt$Xg{5HRV-`OktqAFr9Wp>q*Q(PQN8=Ahh-{T$z`d>jBC68`bLIbHV|0tcVB>c zx5$hy)>WwuS8jd39eAWFY1Kp}xlQxsU1M+UxmVp*xVF|rP&UKnj`YNb(>^H}O=Fup z_vodS{UQ066pOv?@)q2ga9L^Y$&xn$(^IV5)x|btJyN>4abYgofx{jl%ysuaCYowhz`7T?I$#izF&;0OnUGN_H-OsMC z;c^YTcRlE%gWvC<{{hq0UjDEaFHd~V5o9HLn89oFt78i4;+^$3zPS-++C;nBxLwxV>+7GouRHto+@o(!OO$Ni?ss%0&xtckDQy)D`nT^F?SHUj z3IFoY&chx1;_lVFt!Xqqo$%|u+KQ$(pFqe(Phb^rQ=3his!Vt{-K)R3N?R@`ht|4V%+)L~W8&3a01n ze$jl8{gU^jYx5VKkCFZ;byR4j+(YJ+JC`r1AA8I3<@6`c(p~0rrr2FLx3%tR_+H8A zt@n2v|Jzfva(iynlg#JuGV*w1cY7I29%9{|wMs*`(fO#@`;G$_zW<)<$2cW<%Ja_$ zc&D%CY_-Xo7`w9lt2aNttNz2JcUosE55;X?^n!~k&t-dNRn|WtzY7sU{F~Q$o(V2W znmsj&-{V(VoBD?8ew7pbcPG9O@y?#Tp!&sbj`@jNg>B&*%yivF1&WTPtPh-BxIILC zn!quOmNPe0qo-|>IWqmWLl;|-$#mPAw|kDPxNnD#c8wELbiE#No|JEV! z-Ty-Rf$O=)g(Zsr?w!WvlHY6kNzr%OQ?^a*Z1#SBPpkLr*&ArNXO=iW1S&L#IqG%CN_)V?Tc$G)(2 zHl>Rkj#asKs2|wR*87t;Bj+mjhAH>hb}5PMm}S-S^6)(q)t!brYTmuhb-Po#W?%CM z{qmPAlk}Dr7~fl_8yx$D%j6SB!*KKT4Y?l^-}hmOosyB*qpYC~t5#gCu=e@<<=!Z1Hq{aJ6CLCyB4zm+q$ z&wj_%z0zax5^dHsbFR)eSUzok?TH0(`wq?Fz2#iO=k1ppV0qH*+!3+U6U{!pTXZig zgn3s0=LZ#$)9w%clw@S?c<2x;$vwq%@p0!iYs<&1cAhm?uY9@A#{Rs*H|qWU+=E6@ z>smkWIjnTorD)ZWJEaF$ozGpEZggp7&Q&Q-he-9A*9$i9I-Y&A=gal2zRG%`M@zPA z?3`&@k^lKdQq1q4``@1Z)RPyo{7}}6635g3XJwXMWV8Jp=Q8)k+N+vMs_{rOMu(bP|)}HH+ZkJUhQGWrgJW1{+-9+#U)_1y%#3G$oxkUb5VOG_Ro=c54`%D-dRjX-EsiZX|M|nzH+OrS zVmc4o#IpFjOk>&h#P;CNC9|5YM_<16{^-$%%{>LGj}1e&zIoxeoFk%ksm`g57JN5* zFUq9f%FMFJa81d$xyz_vcI=McpAxSH-k&ymW_2g+U}#B-U&;5JQiK0@du}l7v~*dw zb;10*&#F^=Jo$e2-@ds-f9L+ZEV;$**d>N%@tpq zt>V5|UVa^P$>+*4od*SC(j{VS1%;eJ$`YGiy7`|msO4{9)?#&HdD0|1y>#u8!@iH^ zOkI`R|75`{SKo))I;K~Gq)qFe%!^&vcI>bsTbrtx@%xKM%j0ZJG&D1==6c&L&kdR9 zvn6gV+r`g|EqjD+c*s@wNZP*sQ=%=9qU0HM`1jcfo>T6m-g!JFNpM^7+pqJUv#u2W z;_+x7%KHW_`MQ4HQD1Ez?i+=Bl}mqw7%uztJ~cT_ zcvmD#{wm{w^?huXp1&_mSH0oj9J8XpezkC(?`C=bAC~tF_|irZ-*xih0=|eTdqeuHtX3JAY|@#thCt z)*RhQ(=yJu+opMx!)&vW2pY`OV2$ycN}i-wOMxgZ|&Qz-4hMY zc-^_$|L2Bi&(HT)=7n}VSC}L6mAQZV7t2?ZH~qVx_hyAC)67*@1VcBl>9rn8Hk$TD zDqrnvlb!jY9dCtd#NI|{cUUo|nHX=)&6Jea=$*TM$F{`_SbuAVzTB;L;!fD6ivs6w z{#$#rWJ<}Bw>lSYGW_XN+EVsDDowN@Ku0;|1pn{)THBoZKPsoE-_2&5nt8sUEZ_3& z&NmX0-csRm^F!2PMe5DMQ{M6XTz@90^xd3h<;OzC&T1-0yKKuYyfRmK{_IVNOx2SS~nJyuR2m^u~RYSp68F{|MD4@=H=AbhcExb8Edg*m-voDB}^Yhd~Uau z7Wyq0JpD{yp~i$|6#@U&cHFJWiF$i8nyYe!%=BHh;sxpD+f*MaX#A0i5ebrSIMZkK zrQ#^F#?o~auirfQnr$QNshptOeewGGh<<_3`>MU}vn*?xbtIO9QTE*ICrsx)7`zKv zT*F}b_0`ue1dG+PxlbJS5 zR`AuR(U`S(cWOO{a(sFF70GF)yCu5b@X0Er|5AN_u_t~0)-p%MAkWhe&9vv+znbvx zCilsrSvu;gu7~!$-L7%Gihb9i3O$2+Uj>$^P5c<})qm|{1_NH3w^7gUNmrCE+{IOJ zWlMB`SjTBIL^1D~ByQRkdlA^|bQ}-WLs6A{Me(CR& znU9zJ3~Y6qG=F};9fu-oo~xHP|6L%ke)^(JJ#~-z{PSzKY)Q{kc&PWCy>8c;j+7L+ z&EYk9IUfvNd*AN<@F3^A)PGSPt z{7jBbh*+K=u*>*q-zk?Z^O{b7Q#qFm zHZ8yN{14ZEucq7+%TBDTepWN%K(F{auUX&b7P3obo%3FEpKh7!eW-YCD6sUivS7-|1pjSZmuBrMW)3euFSo_wSfgX!=0~1a zS9$IcD}Q@DalKe<{jEo7pT3H1IkxXpP166kmXDHEzVSCUFFalSDDr*I!S;!4uLZs- z9QnOmeetD?tTm_pFJHKpog=~C_s6y{dja)Zlm4!-$y9w`rRe_bTF=EdyRJ&bWhc!` z3sY)1ASjx;@cA~z#W!vSH6Oe`$Ub;m?-$GZ^DAy; z@C$E$!LafDlPL_V<}RuWaQfVMGRu9>>~l7cuM6(Guk(G9+WqXUG3*~E)TqB>4RL#Y zDA}~P?E%k|fMqF>lbZKGJQi%a?N|kSyOzj4wy0WXsm@iYVqU2+JJjw;Z+P~+Uj6LB znMDn@(%N&LGhgGYJy>vm6_3sV(QI{x`lWuqHfzVPx+<=z@?*_u$4q(8ogMqt_@&Q& z-F`RlfVzp|C#^<{yXht7E05ju^`r3y-I+u3#N%*9+gjxD8VRJk0H^1POF6*Y~1uJ$R zcq(!#_3y#iEUCUP`3)Kuuiid$zLgsDBSWdnYra2X{q*^=mqqs0!xK1qRa{qyUTvH6 z>TKWpb&-lS_bL?+l}xVN67^no-c6%u&yzWv=@*SXXT9q$uvXHYv`#c)>GylpPG8U1 zMlS!m?a0-$y)2&XbN;X>J~-AjHEd3P@RM@aHr-E^eX9-T_0&~vTU;IYc-Ilf*q_c# zhAK8oqZod$2n%ffuWIvn<(6rmd;@tiCF+}&crAT+Jt$b|+SNm8JpbF4-?e?owLE%a zg{AS8-FJ*b%Hj_Q?OxV1^~5ICFYiBWG2S5QtomtZ;~MVr*;`+|`TSV+mu=q4hHfj=bBE4(u?HnjBL2*`G#@9vQr-fZQeZ+xu?po?CukW zj1bo1lOmjJ*%J2e-+wf0Mv;%5`TdqHs{f6Ro_TFl{=IU|&M)hyw(MJ1HIXUvdd%ue z{#mmo^{=`gX{1`)lkD6i@{!?*<*iGLex~zz*0OP2jxzsrweo#;$w{+3zmRn^@6~Pc zdEfGAgW#T;sES)*rb~~@FKJiw+x%g!SNHzCv!q2*Hd%h~6R&R1lC2HkbPP@VQ~vSm zqU=upH+6r!cGO>JWqTSW^V@63AMgFL!LhR3`>U6C$Md!qpDMVTT(L@6HB+R^Yr?9# zM=#%B!#m;H2J>ZFJd-^G&T8zDRjynDu5S;dypng4yfRbyS?`pEP4y>_ zM$}wuTsV!tcJ{A(&A~U%CtoS_T+~Pzi*sn zR}$x5EVi`Gr&4tG{PQWoACw>b=$(^ker!&`=jKxj_iL^Z+*D-Jl2z0q(=omE)l3HF zrp?FpyUm?e`)&Qg*$=kwn{Ib+_Ej+z6Q}7qvRsP&7LS|T)T|zTSZ|oA9N9Njes0{6 zSuMh!CTvnpzHsxnwm|{crVsun0vBlq$Qc*!X5>{+e5AePL*e?L0{S7*l`h&UEiVI)P7PR_)-62(=7MN_rxRum9uBdhYDO5Dvz$V_iHR-ZfyD0^6z|n;?dZM;&lHj z+t)2W9IB>uFKW&6AiukBw z?b7H6Q-7Zm*d}r5$cZ}Yhy&$IMob)->4=+C6;P3m9-Fm;Z@&9atMGTKC*}kw$Eqt(i zgJzSpioL9j+QxrOv*xhLzWXXZ>rUY2VKcvppb8YI;0z2n_g1>m@IfhMGvQ$sw$;_vd zGi&Ok#L5r2%hdaQY8GP9+aY0jzF~=D@qU(H);UF!kG-h4a`@b}9g!TpmOlFeDlSw9 zM9h!+&hY%0#E-0Zvt}*$@p#tBRaNW$dxkB`_n0_O-m|psfQ*%qYS-QVH@ucJnXMNd ztDgPyKf7}sPh*Zk{F^BgB895gPCc3S!ej15ZVrCDRh)Ht@yT`@ zm;bibpX)EjSFh)b3uy4EQR>@p%qV+0SFcI2(jxPtM{mR%IT+NL-O&7f^XjpAarb?* z{F2j8n2Od-&{94)>xjXv0H<9F9D4pcH=b0QGykyK?>4zr`QClO*A`11dhQ~zl}~w6 z9P^rAvM=AUy07Q;>y7HFEwcR?-B#|eZa>^R)%;S@X2)rNe;!+O zDf3JjpX%iAT6Ve4-+ChyOYVqxt__}c=tfWX{W-t1kHl9`^z=*mUfSieF6gtC4%@U- z&0D3Ktd*U9cE8ukc(JXbyVK_6eunB@9*zAG1^hwPO>4|l-!{%&C)3SeJ5zLx=Ao&E z^~t|e!t1*&&;Q@cRg&G&+?3JO_#ybm=8Tv3TG^CuZ+-PhsHUv+tNf}NvgJa*ofb@) z98!0*ltZ5JK!5)Kr-4RkrKXcgR{Wo*m&J0KOUHNX&0D9giaz;zAze%Rz{~gHi@Kzy zcWnE&scL4@>f(w2Hf>LNC!zf5dU4I9xnV}NoD{xAc*-($m6@kke3G)gosu-` z%?HnPZkO%qJ7#S>eONX-p#9@}+s~I?J z`1V|CULCjSo2TfB@2+bVO9TV$%|niM8vU+WxJ*>GtR-D&>Y=;VUz5G3%H6y)Cr_=; zh&8&txNrSUmX&L+OCH_nU%TZMLr6>8doE{NgIj;zOm+)Cec`K)$C9f2HUatY_k|O? z{(1IG+}ytB$pyB5pSt?07C%#X^ip=%@v5KvK5@0& z;~Qa@dzhOZ^`sU3TF)09SAEGya!TK~O@RV0lh4;o5m{}NnV>#ZwK#5dMmX=%s72Ew zn)dAIvzhv7Qr&#z{ihbjG%Nj@G$mXxChGmU2KLZ>rmbuKxWAhA(c@v(g8QOob04o* z)n)cmz+Sj<=V87-YS9n--x%^)$o2Ltcp;Kp`SXY8Jnn0?tHdu>MIXA&QDu3BRZn~Q zDcf&dXCz#gsmr8UF`rP~IMb*2{u3pQW=ETyi`LxxoUt`iwyd?{IP062jq#EG(JgNT zmCHTU<~bQpUVr_Ff`7=&PuzzTOS*QVMC2JPK)yFtkF|8j;6N-p+I zf#L?v74I{om6Beqp0<{a{a)rG?JaxwoBa=^^u6BnHaTL$Lf;JWLu+Qa@a&IEtNXu3 z@5aI>FEb=F=O6ohUv}Hp=PPEV!ZN8kSfkC!H< z2miV(yRb}ly-N3|(3p)5GNzgGdRIMEmp}jXLm_IB%i(ALTfWaN6G~}YY`vD|KBiUbOjsYmcGn#wCj$ zwWx=OC#*Tru{JdQ#pzQ8!GAsU56uuRdwTuP%iz+DbuZU_4_guRYTIwILlFyPHT@>K zByyffKYab`se)^Lc}d@md43egaW1#L>5+fz>$lx=?l0Vby?8^|n%yfzdZw`Mi)VRo z_WSCr8JZh9uHEt!j_A-`l`z?sgK0G7lwu$!#xBPPd84UhAX45y%j5%<$2H;FUGWXYA5ExY6iyYo3S4XQ$GwxOTW!AFzHmj~s{j%BT^)#<`&wHR;dn;p& zlViwvv)fEwf8V~DSGNDM`sUJGLJtkzW-s@@B31bA8Job`wuDKu-`0B`N?7r1@fluY z^Ut4xHhny0Ice5(omW*B8`Q2nws74vacbs!xkJA`d0TYnaI8IUzs#(Z^|-6z$IY8- zgS_YYiTWKB`Bul^Tyfv&aI1{mJ=UeF?e3b#bRIV@pW1)^;?BwDrLM<%-mEAS|Jl>W zsXiemPx7>~bNt)NbB$#_b_w0d>EYH-e%zgS|6$?rrcWy8uUTq1mxM?zdw$Au$IgHq zUOpFl`)gL*6!GwwFST&~)QAVXYt0oNv&=rXmi4+^|Ni;g^NlSIyq(3b5I=*ZBH^3E z!iDjwTV4K5StKO;;csq4ma32I1J5(d|5=zc#s|$_cY^)@CzW_z0~5w_maXe{O*5Lj z&S|N@yGMaC`tKNWpREm!R^RoOPwUsj2`bN2{LgS_O*2_16_Vw*JXWs(7(L`TeFO`7@$6k=ZAcluquDT;s z*Qvc$jUF3z+U!}fFQDJj>m=LFwQ3esPc9kMUr+wmT|6`NQq;|aKi^ojm?q4B`~T)G zCu5$7Qq^;xUYvCZUCL1%|5{OWM&;ER$B&BNol(W2);`_pcFj611Gm$StjAprwOghq zT=+b{cJbLgvY&mrc+MZ4;nNpaG1+pqjYZJ$fVp27rr2(}RLy+tVMVZUQ+1Aw(9%gw zTicD&^m!X|D`7py*Vd$EFQ#;@ zEGyX+?=N8a|G8jzQL1#xwEvy&?>9?r@6rB!cCV@3{yR^ylG$5A550f&$NOZ^w$lb# zZ~gT;7!HcO5%{c}>l^BswWIEn?VtCn@}|$WA3BgEng-x`NceJe^cSUxKS;( zXX?NA9cJOzdMcj;KB{7~P!nDI=Wy#*b-o*c0xs$QEuQzrI%xbq$`@d1RI7RJ$ApU8 zX+kasF5cXHxv|#1^zlJm2vo75|9kpEbjJ%ubYwpvJE-uJ9 z|NrDKbra$6{-u8BJ(E1%x?cRqAI?|y?B$7C<*;QlR3BaU`hID9@B+K4qq%c(e_T4W z!e!lRK|3oK=H(|>pXW>TpL+Pz_oZ#mI_CboR$i4E#l-tUwlP9}*OAP%6OPS(%Kq!g z!M91_HVm~lA7`pP*>URI`D{_+HO?zlp7yRd9=Eo@BDYzj=#V?xw=ee@dp;@7J-#Dj z@mjk=f;}BzjPdKu8Y0(^S~JaIospz z(py7QS2b1644Svw?A-?zo}Gp_lbj!F%`=%F8L-Gu`oM+tf7ea=E#W8CemCL%S(A19 z#X2v($vFJIKQD5RmF?SK4V>}wruXae3T5PNDACN><+Y*VP99CiA}a-<{1I|8t;*5!;J4ao9bdQS7}T%KWZlZb z6&K3hh4!)mPv-|d`@LEZ(ORt-6j7fZvXczn#up{$1RQXg=3F5%ibsm7f|1< zwA#JiPF7g9d-l2`Vkghn2&(IUp187Cvt_oS&PJcPUr)CQ`5E7yG-=!7Nu2q$wmWtj zKCV95WU8mlF}c_9hbdF>V+Xm;;|(3m^^T_|t#&P|TzH{m#s&*V&4-VIckle_ z{YvhX<0L^TiGy)l9$#G-`#ktYwwac#!sAK1+_HTaM`(wIH-@OS?&NB1W^L@;vY9n^ z+3sJ{rri7RH8k^ZLWNuXM)uC+!c?^-2O>^haV zEA^te(Vf#8?QiDDtQzsi_o zlo<27vrTvyu;*c5=}YO;8j@0v`B&|3w*T94x;8a=Qhwnaw~iOBHx3tF+RZYZO~J;+ z!=7WsvJLCL3B40MGbz}-BzaL;hF8bw<(Vsvnu)Cuv`M;r(!OcM3)=v%Be74V^xYoqp3f79&iE?Za`X5apq z_%h3oAtCwAy7IoJ){X;$&4-r=PEnAKnkBbib&sKZmEGZ%n={>b%Wj%Z{k!J+35IER zbhY1vm;FW9YBsJY$l5By#fSO-Nb9-XdX@z=NEXOn|LP~Y(@E{rSlmHg&jd^WjfLSA6t z4u{W?*A#i8=1kESuF81hzRq6HDDPxjztPf9-%pk~wQQ5*m;2!-w)b$GQ=zVd2~*w2 z1!qFuN_SoOaysR;zkblanThKsNfb!_oMSKh;KldikpI*F1pK>w`IpTHPhrO-m;B7) zZ@cGfznfh7f4be@>VIlHMbmkGQ&~;pk8%p^Gi4145!v~ap`+sXWUu3Xd!_Ghx6Hox z(?RC_^&NH{DLylE*S|Sen-@Oi_nMqN+Ve6u98?d#UG{SA_G7x2FU?DK_Es!s*m!5r3nxp+pL9bc3p?1lt$eRzE=l<@Nim>ZDufVsE!D#&o%b&-x zOm9Cn=|3%3@~Af;R@=#+xlStN`GKAr)3+-*c=Q!)P+BB=V~?rqoc8pqv99TH*JHhp>wIs|O8=Cy zuxM4orFM@=zi0b}wQ#WsKWSO;=G+p?w4co1B9q>#ZxlM*7Z_7};$xP%-KN=!`yL*7 z&3NhRmr&c=9BVuy6??jWNJ`opHu3$@<@8vs>+BW7^8T@$5%+_~Tf*$N@m1J8$vNc7 zH{tZ%@`ZBm7Bla@s%^FXWb$Hz-#W}{fp7Z`J6i8}bF*}U{?2bntnWkkZ2hbDURH8W zW_Ug=vnZ2obu&}XUB#J3+&%`|Cra!Lm?%A?P-W%qgI_*xNOP}k7cWmW;+XK6_0F_d z)zlr#4NuGNo))!>@VaKR)v6`+N@(L^ovsE%(D*3lW*3vmfbb7 z{559gUru+XCpWSGJ({Yyu>Q{JXL)OOOgT_cGd1s*WZ9Q08Ai3&D&M{{JAIiQ@4oD+ zNMa0kxNcX5#1qLP#=|*6Pj;`kn^6+^D{S7}rOLB!?|gG)wfrKBbzkCF{E63Tlxd1j z@VM?(64T-4pue@{NWypR(`g^(F~uyqCh*JCSza&VG0W*6FD16E(oIQxl)*ar@~xzH z?T-gGzn=QtR*ZM*${9@#)qy`YpEP)J;8T3k;;x=ZvyOwzOKxzmH&m(Ga$elbeVKdW zgF8L1Z09{a(X{JNuBEC&;Fd|I%fdHZu4?8x+4O1Urmvp!u1hb>Ru=Y%Yb<=Pl(T@Z z@N#vYUybZs_Y=D(Y-eBPR%(T<8NO#Ywf0EbHZ9t9a{mAG^Ia9fK3LBE?Kh)5 zJznwu&9%OwFXsN=^Y`CoeZjr^Lg)2n=9>{`%)wIlNrBt|NGj{Dm%B!mF`oPTkg-h@`}a1XYN}H7teff z1WLrK{qAl=l%Skx9#|ioxGc#sm!gNa7`R%W+_#3id$?-KmwX6Kocc!oJ zp4uB3aW?pn+^3D(t!MVluKXAB>dds;Z>CJ_$&qTAmR3E*ZS{RI!8z6(vsZPKc2X&P5 zL?$5Vsd8oMfz8Exq-=X`s0SZBG;{yfRqip%?27j;R*pX8Wz}(TPxJ5o`t`r--+UE+ zqsH>}MCQE-FY3>C#%jMzO8eb8cX@1@d8%;s$Na_OFWevhpY-@%4gVFR$5%!C-X(B@>=PQ*C!|82WW-^YBb;cn<+WQv9WaUl~nUtTC z_`pzm&qJBm4)u_Fo(s%po^5^Ma-gJ1b#?wP#(C}kc@v|o!V{$K`0PltaR@4t;`$n3 zXwNS=JA*50UaL^&&a;1mSMb?hbBm9-eAGOe?c5{rkYA1(k8e(&Uu;+Qa0P#w;sRk0 z)m>AHo@T|ZofBaA`epU3SDa;hyWjt5ToF6bX%@CiL^am`H8m^VUts=B^`yr%amMS{ zxjT6H1JfDBN|Lqyi=1kIw?l15=;xbxPub?I_p)(qTHG+rOW>r<(@HBPZhplto_v4P zW`);Ee4Xc@o}a|~LwM@TuKq=*N`EG@#Z9=UeLuXXOR-e)j%FYC$^5HZ8DF`iuxcr@ z-b`y-e|W3EoKe-PMpeVGs2Jn(7ry=ZTX^GH&RcKpY39%F^yE7IdU5r}>}Olq(}kL^ zdQ09|k;J}hIjgR~yWFbHx#acf-Oq2ox?UMAtfgzRyeg%y z@9PPpr0EB?+|RNIJ9&A_t@l;V$I68aHG99mKDI$|*>VkIwUCm`?aeXzuY@-G*oJwR z*YPWFY0W$id-ZIhzEWWYaIS_fBjt$tqg@ zuB6K)DKq+T?rvwj3ioxHC38cLT=snSdqPut@a+8`w;f77)2KK*T*fj;DW%cm#S)M3 zbTz@tCoYQas7W=ycANkF(Yfkbt3RxbeJ4^ldGo@LA=O?BS8hH!|7Akhe>WNStxexf z-S}Fx^z!r_MNYmBtF3bd?rT=&yLukzHfib!*UlB1_vdNAp_SWLJm0uE?MQmq?hh^@ zyM?*4rmAXB+L6Pwxc+H$&dF%2%EGlQJg+Y94g0s3=jgkq_kvEj9$YSWv$0*uU$tfO z#RW~<7hPCzIb&9IOzJKcC3DS{IlsQ=z53g=pfSRRJyvy#;=OfOvhp5hzvQp~<@?}5 zlT^sZeVK{YHEYwF?>24cTbOZHF)ja642#N*Zi9#BWslU)cz%~}qAs6* z^gmPKU)l0Vd!1c(x)A^7ErPDOx0I^SZk-%vZr3wwpJm?9chfW9gkHUFE0|cf=!2Zv zSCv`K6^_yjU5*p~rtx2_=(=0>_1Jm$>GkJM?Xl3^Fr)Y5PFL$auFPSZ7dj@?X}+B6 zJ^S-^5uIDo>th3sr}s#CG+b!qwmS6b;)Tmvm0#tK=lzKl`P?R8p#QtfWTAQDXWmCl z8zYP=e}7(_cva|K@%Not?vdY}%y)By3ArXsnKXB*I8Xl60B+q=!td5qYkvyjTsXI| zvfB4o+oZgum)9IJ-OcKG@S$bb_wVb?6mR{snd#b)v(0*zig?*Vp2Eihn`V3eu-+lE zWYrCm#fxVpMcmEnzR|e;f6AL(N-tUzem_0gegC^>LXE0BPrte8{8Nm(LtG&gVZ+I)KTz!?g<>@=$@AqFP9egu8*xqu6 zg3X;h{hqV8*nU26wCCVbi&ll6);X_E2Bt6dS+PE{!9_k^WQp|7c5}H8bIPahJ1Y_C zr*tuBfTa9@et^)u*4XxS?L8bLWO+`@@}F(I<~M=&Rp9wB(ld^3PE-4cF;cHSG9U zS*p~0dc~8K^DdsdoRo0b!KG30`o1#_1+Uhrn8{sw#gOITy>8B2D{jw~e;JwO)7>|$ z^u8e;_4j3$%&jzE?z5@35*~^Mmzcgz|1()P$3FB>)2;H}s1>_5Omg;7{3?-9|I+@z z|4IWlo9fzcWeEu@j@iF@cC~e8^{jOlZ_WM0lK#}Xc)N;k$(l1VpZwig&vqP{zO~c7 zaQ~cR(Vw_8UzNQu`@*!#|1-PJ7Wuet?h>a8*dDOzxToq>qm zgYbT-%;VvUu2ig**SdT)wmm2B%<&m+yVpya`r62U3)Q&yMI@T5>Rr~~_s7LvF8KGn z;b@w)?~;}$GuE7pSzI)KvvJMmdDmv^yqdYuk4t);PJ>H@zS87M?Tvpt*KU|O=cHvo z`kmxY8*i-Lci%*cTCk4ceTrsq;OS=_jL>pBzL zp>^-8=Sy$5o_c;#BuAuEek!NS*G-x8zFn8?|Gne8;6=e}e~s==&=q{P+e2%fMM}-l zs94{~5{NBl)y8V-q)2kbyvsCA8 z*c_|zplI%cPq6_Viod^HzOpO2)F!O1r0(xb?aW4T72YZ5ewyi<)ISPVd@izTk764` zXyw`vCrPFw4Vx$QZ|;eDtjR99RNW=a!}DLyPh}@lhqz}(lEGSC=a#GtT2eJ7)VztsLTf0_s2FTXDQa9b?#SvAfr1fwO`=*PFO@4JJs+{s>t<7I|`c}Jl z+KW4R6%1}}B1T!FipnPnIr1mWOR#*WDOc^QA+PKI;ko2yYp3nDZMSa6&X(!g*>vz? zw(7s;`t;rRN}p@w8LYNSxP0&Ubk5%FC_m@Q$ zhcfBR;JkbLF2gMML-7UK(-kdpUDtXq{661b>0Y%A!_Q|+7W>WiJ$-QHPA$LEvlB(` zKHQut_ix!sAJIvB3dGCyo{+Wowfb>$&egt_H44Wx-rm3Je%boPrW@ZPLb52Qrsc+_5R%?En zly@*bMXmnx)3vcvSGsPu-O@RwBhYw%YU!3C7ak;uM{mtEYtw|gQLTbgjlDz6yXf_w9AI{a9 zafeS)I8QP6*XgEfH4GOEgcu@LN-?{&{`|eD^}irR%c>8HC15cT&s{b zC%f0|@VaghvCaBvw(eBl$`9;jT+RobOqRc|p0};%?{f#Y_(wb6K1mc@9F?N|?M`d$ z+>3weKAfL?Ypd^a zeNQ{x<=3Knjf<9*Jt$~5Osr?<`F`xn&y3l9^PeikCK#CRF6mfeJ;`#}iHd3KthcK6 z)i07~$!^=)aO#xCQJGygSNqIeWjQxrE$nn~znoPAzk{Ey>80((p8t}rL`?pEFYRJ7 zzqgduQHLT`96(Tus~{^cfm>TK>JQU&n5T6;_A#}o@f5uYi^ve`+AIi zRBUPbg?Ilq+-z~!BN8&ZW(G6Q#077|c%JRlP0^{9Q8K^(TrZ{iT7?(qlA2FVVQ01W zK5UMfdhy|{-Ln54xi<1~y^6oF-Pxzr^`zqNz3ge{_vU3^ocGis_#L;=D;5{NT^Wrh z8nibCNNtj|cowY^^jCV8lJge7{^slZ^!PGQv@cttn_vFRbkf$$RhCPQ7gwZg@YMO} zu;!VP@9O90?oK_wB>g?({Z);p_v(LbekuJ%+?;{qk#o3n-f>@(f|OH2sZnAlU&q`_ z*`RSPcgv(!5v$htEef$_T;I-Vmu0X1$7&I`>gblaXZ$B_Hdx2b(>h1BxL^zO&V`|6 z(IL(&b3ZlQ`z3okmqWk*##xrkz4A9LzBJ5uZFc|Sw%f@J0-d(Vx_d%fj-i1gk#Ruz>;JLH~t?+9h< z@mghe=&*}R->S|yIYHyCSHiiib8$+HQH!oJ4>JB$p6>EXx@5lk)H~M?nbz1d zMa=)?{&rIALY=p3WplqUXO}Y6etx`om)>tPC9h+y+L>zCCI9G3$W~60D$d@OofwyJ z_TLqEi^ErX#3pvWt@?3RjrZ=+s>`=zrY~QwX!h=ArJ|UGZ0GB9(n~K{Ubo42xSe}s z*6c{1o;iygU3||zQGdjK(Oi=4`n6`8yZ##{^=k3%yA`zhW&8VODc0L=8+C;k|N0Us zuts|B1-~t!vpEe_wo3}ygq*wjnKxm__7gi!RZ330_VzF9w&!K9mc(;@d&A3+-~WZv z_2|q^x7Mgs)cK27`L4K7ziv-g_{eZv}EV zmwcY6ac;AH#mrytxD6y#TIwD=xG(y8X_;Qpxi7YdV-AP+CAS`SpC56c+AXi&eZI?YbthjWhI7RD|cUcbc=w6!_NzDe-}0$1)nYO-r+v|gx5qn&{q_#sfRf;wwVU6*UtgZRPdED0=X4WIP4Vfk_dHkV`MCAA#0)3KBg=O#Jlq&! zzwly|u2$d)g)59!P9ks1gxfebE8n+XAd}~KFYL~%^Xh$EmMKEL-+$R|>|T}4{ZMn$ zW5qcetve?9{|>nG!0Of8z;*2{M-AecZ;1Zd?smSv-KwlIyhrnQB&LdAM-U9m(5Y+TGjCEwbhqu&%;3zPJdND)0i5#@B4(H`3$ao zw*{R)ow=s%K^VZul80{&qcULaX>(uVS;@3a0!UT#L7GUr9KdX6s{9+;33zM5~Prl1j z%L@owu_vC5%d?bm4TJW~x48xrpH{e>GF#_xa^JCwJ_ilkOg8>{_wzV^;`UT8-^!Ug z&$X+GMsh52{MV=wEF|||y({^rwSVjuH7%n|lUsZfPdIzJeRf>X_^?!JpLWRR>E}bI z3-e#9>FC_u(neyDn;owRbaVeD3(= zUl||ybgYHa8AXgDS8eK%v&)OBI%<~DD!lO2lVqX)*Eertne|6ov^e%> zWt#Q;BeLhbX3>`i+Y zU%7ti_Ku9*J`Y1fraH|?b99`gCi$3a{hznzQy9`5cyjmU&SwA7{(RrjKaCS#g*cb{ z&oukJ&Zp$y?20c5{yv|TrPXDvu5fPn`rzrRJ2`C^d0o4>cq+65XT8?p{Bh*9+lsxW z->&~l(g<1@6D^&)CbgzyN-k^QE_T@_wV(Q#98NqsUt5-x8AtuUHnB{sPBu)d`{m>E z8((%LOWa%5TGw`UX3DAZnLDGJRc!l@>^yaCkM1MB#oy!KD;Vu_{=43E&7=Mwx@k|p zKU#cZWIzQP4hpZMdxgD7Mao!fD+X^ogW^CNlJnh&*{U2tFOQjeK zR&QA*-6FW8-PcVfbMaei5&c>dC1(Ex2R-JUu3bO>@Qz6WmhWF(_?wR-LZJGmzN8cP*MG+aWs#*;v%UJDAUBiTLIy+-hbw+N3IyW+=6ionPbLzrsas8vpL`-~Q}$PF^Wc%hIZRiIH*t z+hGo2ZQt7#k%i9hwu|xIWnXsGW0GUZya^Ko!Vbm>K5hJ}qb+e* zW^KUo534@bmTWVVt|&kBref)zMZbN1F6H;#v^+|oyF=Alu(IbvTm^6L#0aCq7sPHH zczfjH3HC2SS^_uMRhOQ3T(0)uPW{YvTP85H&k+0?BziRXYKlW4=XGYW1t)y>^JYGF z*x&!{)BLxqH)N$Z@&xp5vHYEoCHB;B&ASI~$t+15O4sDfY=2n7rFF!$`29lNqY+Gt zmh93nQ4+54XW3Jfx=6=Jw0-yWRTk^ncRzRk^>%kroYDDX+fQ6Nm~xaoV$Z&sodLIv zFY1_XdfMP~`tkXB<$Vm&=^wAE{CW24^You43pjrXq+Bi+VC>(ruc)VZ&FqJlHm_h0 z@ejDVyf&zD-imiKde2$8r~B_N&gf4muba5=W**P6vIxOzom-}CO|0GT`=x%(t)(`9 zf_S~$wf;mIGj6=Jt|3t3#46(?zek1!cM=Z;>=3$i=iJTMxr+Z=&-JhT|L4-@JsT^N zk8@b>IMS@Sd}Y$quly0_O?S$FiuNVM^=aC>YVLS=|G9N{MU%7f+f{2jDy90)|N2w9 zNpSg|!$FP=b2c;1U%f!A<<94`krFC-ocG^}Ul+LSB6jPke`GF)!|E#&rno=c*d?pX z7`?vw@zfL7&d=0~UpZOLUh7nNv~qQU??YqdgM!COUK$-<%y3{6Z&*-o7-uI_I@AuPrzO3mc>*k)e;|1)62^@iOmAlI_Ut6>#9h{QCKQl3In%dR1 z)2?u3WmiSlOL%>jXnZ2w`+Hftt-r#&kUM`eOWYPrUSN0gs%cmf*NdGuc4gg*_szO? zZq~A1!PBKp7gk=hdhUMbpJcMK)B35J>brfDGBsu?r%cOxm)$T)cCmwW%+WsuUsguG zj=HN}sQpf3`s~9Y=8JEM?QZz5e|y%Iy!Rhi(tZm|>{{B>R%B}Z#m|!0Wt(jN*Yxy< zTP-Z}udOs+o_gKDG479l{Ex!*2Ob!)$$HFf+?Sl>GH=a^Xsft;3B0$Ti3Hx?SZv&I zBlQ%!tGrh2|Jgsq{v1D=8FPUxDEi5PyM5ZVA6z%{y{on8*zkRa>xPJ(0ng{Dv$goQ z_J#1STD_HF?Gveu7i@kk4a>~ApwamK^xtK(>$SLzkIrCRJolmRpUugR_sZo9FBRO| zw0Cdpy{mf@f2$pz@yhgiYtC+g>gmR|3;DK}*7wz)^X{#9mpg0DAJ^XP-q$u5gwAnc zI{k5Rbm8Xsd}glNqIqJ^mc3%%!jKd#)*4)}=6w{8Bhx7>t+y=Mhjx8jIBV*@GWUWB z=YK`0>)qQP&Td*Dy6baxMhL58B2#S5qV&J4M*EJrJ}Y{A)ZJZ&`BH73#2yjbQ}AsICPy{t;@#~59`-# zRkD0=kd@0VQ~K_taLx&SyZZ*T5r4CQIM^$%HN>G@|FFb64%{@f%nV9O{V-**<))P zG~3Zp`Hp(YR%M^)5|u}79}Ziqahe}`6gqo$k!{`bU`ZW*;|r=cMPu%VuS>piHlRN| zYOa}l{p3S)3S9aBB)c-3OVr*?m5|8u#n|JQ~<{LY= zd)ZV9+t+BsmzoK3O^*6&I(O4E^9kkb9}ck>rcb=J<$u}M-fOL=D#EH2jf`e6GVn81 zG=Eza))TgV+u~Vo4mA{UiZ$uwY+60n#QW}vFBZ#^Og?{5s{E}a$o^TWLm>0 zZ@6%{NIEDdYNxc@L{8!ijJzYhT4&>=OG*`86&i_+HG<*ByArMy&TRa{pR#PBJAd%P zo$K1B_ijDGX53dar(Jbh0DJY$S)L*3^W8l{4MVl3>`GM5?pG2zshK0$em?x6qT0Nf z3mUJdJ-#Mw)boI=Psgz0btb2f=d&9ZgZfn`fGuSzg_FY(XK z5)cqA(wptC7+JYQ#q&x~SN$5}x6fPuzjuBxH#FCN^PIPyKUE#Ix8XlzIx-fO8K~;-ApUh{~H{Qc-Ou7mC(GH^7NG(J9%5ACEwn= z#$)lq`a)24xVHTz18dePYg-GRy$QM-WUnTEv~i>6q&HfVN?VU-`9IOm+2W+~Fe8-z z-)-*l+R4wo7n-}iE4gQV+Hl$}#hI6PuXrhB$ou)qkIt8R&$H6y-!DA0=HRkt9OldZ z?e4KDczx8E!C2X4@4^Q@MmaV6a+7l<0^VPoAW&qubwhmlRFj?NWoq`v6hwCF>A4(Y z?^sk_xV4bO?Dm1LY^{Z}180YwaxPwCopxq}RMy-X!54qd^|)2z=4|cJu)FQ=(s_AX zZzb%Tn42=USVX?3r(>4N95!d?!tWoiyL~G*68f}5?0((aOZu;uzAc|uYd3FR;+%6I zB0rh896KSV_jdQOM8$;uodE@kWjkhvZ+KkuU3%WN)Jp$+u~&_!eskQ^FEhKlu06!H z=|QsB;}Z)bLZlo1v~;~(BfwHToBHuk3!t(a;ro+gC%m z&{y>Bn(_muUEYLQo_`U?-Md|!{mP{FnTGB^wK=XF3emYOIL~n2Uw^x(r8gAzY?YqL zu-DC6I=^>a+~xRC9(#eFW8&w6w>&r>{_OqI@9%c|8{UqI+W+QQ|GI4LnvJ_ycFt4V zZn!(d&DL(&fnTk@!shF?ZIOBTX2H3rbN@HAx+OC#dm7+%=Z3_oH(eE;3>J9bPp^Xzu|oL)VdWz*_4;!0-IRvLK3$!#v_@7t)pP|)Y_ zc731Y8gyR4NYRa?DUP zl(Q24xNK48&3_(2H<*|5Z0~58aCFd9%Q0ch;0;xf71+u$VCIU(U7GiSPT$Z4&R*%W}7T<6RSDqjcE$^p!|u_Cu$= zGaAfwChz#89hi}9prdkS+1p3YBtEaOm|f0s#6t6*&fjMZi~ha7eQ9gTrL7Lk@s|#X zU0+dN&Q~`#d&BH=B`apF%Uw~<`Q!LB$ye(a@qd?6cNN~n6tB3o(0@O>`i&n6?(?Vb z)mDmS5WQEw;BZY0Q`)=4gE40HjyET{^QcT`b5eP->2Lb$BAE+6rc3^IVP7Hp?0U5c zL*s_q{0n@f{f%W+t~Tac*1q-DU)}QP$kN+qF8=$HRvoZKd~bZ*_EV+IhsE`CHa`EL zlF-|nlX2(Ui~^6%7mxG^#%l=MW#6xnI3YVBWpCv9zj_`wzZKL7^#0E)SjEvJ`QUTL zW4>^u>+DBsK|dp0$cbu9J_)F~93$>Uy~G)~(-S$C!^yvpeaLrE;zO zvzGpe7Y55rpW3gunV!+ZDLm0tR5kDCO}mYb%P#GeFqrw_+`6?X%Zw%%y6v@>p0A{! zv86Wb^RLa}3ydCbGdO%`GedoR#FAu}U2iW;ZpeOSnWsNZQfQM!P1#f5t#M7N@6OL; z%{Q3E)?uYAx4w8;epFV?q}r@B!&aTXc>$XIXJ=R$ua(+zB6>$7e}11l*RehQHCFF8 zEb`I!F#B;&a$D*@ht+X^U;kM8yV&bkhGF-ThZFaw-P`lAF=O#7!TnbMm)YO!ZrITD zdEKm4mY?b_U3!qH{A^i@lSx9~d=7!fsUH*fJv=VICM%}PFGaM_;pOaqORlyZJabB? z@2HJz?MhAV-|aFF!`?e<-*sJ6r`UV@<&)k$l0i$Wp1JI;o38Qp@9KhO3oG>32mIrZ zp2E_8$Kv*}6aL3fY^>O7BjjdVASkgVvQy*kp)A9T{A$@U0-BR*ZcT|w3EB1buTJiM z@p<}>cW1QeF-;cdYiS8cl)t{Rk<&W*!hubfB=W;w2v0e6=|->TrNnNlX{-h@|NM(N z`<}i0n0EK^1jdMNr77w*?sZc`zI)HDIUE_Ry=B|Z$Ez1?n!v>CHZSrqzX{{f#8W1w z=@qjwRvN9iCd9kdT#0qQWyr2CIbSWTpBMRR9X>N-%^KHrI|VK@>N2n2v~Qga%l_bs z-&dA5w#nc7kehqGK>f^i*B|xg?#u3Aw%ja;HX00vOHCpT+uddc|6PGw-s5bRU z*2=SgbBuaP9j6O*4i7%tN2?cG_ro%aB`;fZSNoLGv8bYJbKhM*V*#lrr44VX09%7+&dj! zXVx^48`e9=5fmHeWY1woUzL_ciYL zv^9rojy;Ngny40WB3AKA(WBZup^;{y9NVwJVG&yD%L_jf$KS7`f2U+BrR*O~hdo_u$!&P!t3NB{R1Kh+D%-%^q; zHG3WI8L#Pd;XN0-$)VfRJJh1CEt2`~CTGiGue?2XikirzLyDE{??a+Z-^9rMS$28W zCgTk@R~Eli4iJ6aDxLQ4ICJ|9CasKsV1th>40orvE?(-PG(Yrx@MeK?n^b!we#_Tf zPW#HH5bMCx=@%V;Gi`m819$tWo6~IS=e&LYRyX?8ze?8$?8ap;GB$r_Z%e9F;s3e7 zOPV#fd_C*5aG|Z{va3S=D4KS@HfZ1V=ij}1JXR5FXCCWT_niOAp!va%B9GdO>z*)d zPs}gQb$QOKc0ypxa9|%N!C9ALpIjc&*0ICNRb0p?l4_-N`&Vr#1hP{QXhAeaDemoQ|B! zejGdcGlzT4^#p@QSwdx>-p)MjUAOC!gu=<%zu$#UnOuDP`N)yxOWroN2UyOlIsfe6 zEb}W$-b}#2=IfP>+xbpRUmx|@;?f0!`?K!w6!E&2wfC&*zRq)e(eIfnMU5?@bXIyE zaQRSF#>@5gTKx6kHnr0$_3U?_SlM~f&&p=Yf!)f}N>_Rttv|GP0m}@*1&(2-6=Jup za?NoONsBOE5c9CDm@P%>T#bt|Un#$))#g(v!H;imQvWx9zfR?iCyloo%-OzuV9=i8 zvG2@72j{-J`_V`8&li84#<<_O>R7j2%chIFvnJ2EEp2Wtd+AdR<8J?h_qt|;Ejn^y z9_tmUi2DT%VOQ^@9O>SZd?zgZ?bpO3hv)b|)p%|iynTJq0=8rBw)TD3Vpas1EP8cz zeh=%?|Kj%|^w_5#T>ofAOX1uH$Lj?@?JJjl^TOgG&%5U`J4GwjA9!?omvznj(}v#d ztB$>KOqFSqF|xZGys^{m`p z!Pdga@TO+3q1o1toJL#O9P9Z*sy^(w5~J|7nd9{3)ZLE)Wqao}T@{gC%xk%Gu2S*L zQ*FX_e^P2Y7RF`r|5&XWUiHu{@XY1TH<#z`SDmBd_LO;5azvxvvO9Z!+}sv;C&Hb( z)kEr^$VKyb6PC#$OnPx~l>yz4w#@S1E5C7O?5;wGFB)0w#zDzc-|D{)NX6p!}D*YT;F0*a^GR`$M`cZoH7^~|2jvQsnCxfABBeR#;Z|D~~? zF~gjN46FEMpKTG$@!G39U*`E6ukVhJ?*`mg)oV4mRdF-q&AF!sI@4<_Qj0gPGI=Vk z5L{BCq^IJuy?CrCogN4J?t{-)(_39c`8=t{8hcfUEt1=_N9#p_0g3}ZT9gnNyJ^5CU-KQKkiZ2 z6hC_j@y_P7!>sdy#9umGdlV;ScT!*{+a!0fqZ3*)x&CJROi0?-{G&#Pweq00Q&O0V zP;a?Z2nf2}dw=U(?`?|>?-rY!t9`0!byD_N{KxqF?X|k8Q`pMx zt+0;y$!hjI=*Vlk+D3I@dx_H*-5$Ib+xgDb$3*_i+B?f96u))4r29l$N!5%=<%X{B zzYMuuR-$S@^6h4%K2o&vng5^t$m}_LAMVoMmDSOE_`(;f*;}MnT)#bw^}3mS%j~zI zi3wNsXY#VNPP}_|1$X|QcQ>!z-ZlM{!u6DJ2l=$g*DT|Oh1R8(zBuvTM)vFS3X1|A z-#uo^f2XY8(3W-dIzoegFoAb;&A#KZ!U%i|Rmh!*k&hxqM@|!)YRPRip1z(?z<%VSIJ#y-@ z?U`RrUko^UCv`i6_=nO{o!6>?h>8g+LEAEx zYx(@y`KOYR_q0EW@xn7tAF40D%UCEf!PDd>kL)zweSK;nEU%?Hml^5ly18m(hB98@ z7O76n{a-Ja((16M-n@|Yu4GiC?NsIKoV*8{e)h|7N(OfwmH(EtCx7Y>WB>eh&2#Sb zANirXYtG}o^LHC|%yK-a(Vh@nyf9~CrsP-G9f1qp&-&zT@jhkt_UMSI0<*Rri(UDx z;=cE1^UcfpW&K-BoOVoE$-4Y&kZ8lN?&l@`)33d+_;(=kS<(rUOW(iT*j)CZV*64Z zp|0xttb*q_mu1uk9y+FA`P}+`GS6$fN!QE+RmN{Z9TX8~+p6f7PD*`Di!) zWl?jhqn1~D4WGQ;rtnGE*GZo%FyeX^`?LD+i~SFu5NovU|9e9b1|@+lb&65U z^S^P$uL$?pA>z7Bi#qHX?Vtf6<$A>jjeKo>b z&VJS1BNM#!>4e8!97ZpHE`8Usrry7-x{<@_aOkVB>_cBZ-v4E1diA1iTKLUV48_}P zCYiE!Nq#l^Hl^m~iXZi+@qIbY&*tU{L=}pPJiXb{+$_KEo#{KUN`!pn~o(-bkRQl@>fRG0oT;J!wbKp?8vNIG)LxcO#bad zya8`hJDFxR{au_MwYZv>X_Bh{KGm#N;nuJ-lH0FKybG!lk(%9-4gDgLX?%x=pMdA5WJ;*G(Q)rWi@=R40! zy2AAI<(vK6^>6(yTY8PN+4Juy&CObSKI{#C;&hSWDmU}p^M98$-YZp1-Lxj6|8j@K zf{2wf9+x{BuG#%ILvg2iD)$qO^r>8VYdoLCcg`u;v6wS>-;bqGat&}>>Y3JRhI#1Lt{x9`;gD_8}aSo4SKxD^( zNlt#V?JlPK|6e4t@^!0D*9nPQw$QX*6~n(;PefNSmTlYiS!P<_d?&AiD-Ugdko>#m zW^{(VM_$|)`Gl~qMT=OJzp^U7c^~)fN62YC?kS#kuCF!neEa%f$2yZOg?DxS2Guh1 zzE=G6V1C;k!v%p`Bt$;Fy|v-}+)|$h&CgWqFJ3plu=j|m>w@P?`GmfisJ#%o(Z-~= z^wZ&8>0hpVRINS1J1hIS$>b;NepUF%IQ&lAZ&2KE@#^*SY-(&5IBRZCm7B-@bKbqA z*yAq0S38SbWO$iz)V6g=TUteYv5M4k?B9W9bQ zJA|+Oii&2K(-`vm!Qkwgiudont!k{lD`)oEaY4)8e*&KwCyHrJ{%%pUwIJ|PE6)p`Q*MRI3K3Gy z+Q|p<&e>lI2>z1dYqf3OvGDw^?hDW6cfVWSK8Zo0L~QZn|1Z{7KXlx;^su7IjmZnz zEz+B_e`;B6`29us{49faPR;$_i@!>r`Q9M0_~=*PI)2fWZsA-H-8)_!ePYokvwyQQ z+rKQi-1DC&co%JH_1s#{DIl=t@%@b8NneVS+P_sco)7-F_v$9atr<>47a=pDied4R@FIijJ?$hz;NPoX%hN&1U2LCKi&hP0aZ9GFQu}`TT2rx16YB zi(Jj~ceeQp?+I)=*Xo}MKACUy*rU)VWcv@j{a5-~r%%`syTWmMWLXXiOWT}U|Az-R z%ro;@y3d@y;>6)-p6lx)1hzFF`>;IFeeHem*XM5RIQhYQZNawtJI-abvm7Au2&b3U$0pKEUF#6M4P z32*B|+ifC^by0??vwj?QoUD{AFmn7u8@O<$Ln+EF5P+W%(A&0@Ah-{Phxp^ zF2F`Wsxe;YqWf;ol#mHcTCV+b*91TO_0dly;@gv(n@+CuNy)5EX`K*k@5jIQ*3UTy zF7@4?wIXegd%IBCzEW$klRcVNuUC0Wh zy+*rkda|#r{<6+dcBUx*oyO0Lf_+kBE&2_rtp5kxe_f@Ja`?u+Eem=68FzDf7Y1EZ zeRO5=YUV@HNj9}V9h~&mRvht}bCvn;RzDGo>6Io<+%`e7@8>hxC#<_!=Kgcx(|~V+ zO#)3uNf!@#mQAd^c%XwXLBWG1kYBUVd*3{(-JkUD3++?1KHpRNI{eflmc@Z5qI1`@ z|6r&*sLU-UR6om3hdGvMYr;AHCsG-c8x9B_H<>swJ~O$8fr0gc{sfL1O~&4a9dQzC zjLRFge0j!aeo*|(l82%v0?+)9<<8EmE!aQn_;kf~;WhPk#XKo@PKr&++PGlzB)7g( z;?8PsCWg&=b}0AMggoQSgpH}QgjeWf-*B4v|I>4c_yF(HP3MaDJ!*6MWpG+(nf|8F zCnt3*5-7@IUUcM^(lr65Yb@7Y+h^{(JK0fYa<;DXr{^b*uivhJyp|#N;r*q7f^jKb zt7|@bw$*wa+r51+TfJp*bK@CDt^ zI%O{4c6?4y$5Qzz?+yz;ug<=`SjNTkx{XJh{O{JYjUhqhHCt;#w#BRtyem^Io1wbA z*7J{$-K3;W#j`d`PHourSg($6w#dw_R8V(j5ICl3Qd2iH)eqMR|)nDcr z{?RX8J6>1>`j{SUJQnehebN`VtO|!e09i-vXjPFIF#fn`*kO zEqc1wY@vD%=Pzfy6ka>+3fcPpSo#%9S$>)DbFUsQ-}YyBI$Pvfc0<0xGbfMj(f|MR z+x9JoUVaxUb!9Y;`ywdS?h})(dDeAu=gS6z+C=x8>uxO2cpP?%>8|F*x04E=>%E(9 z70cT#@N#F&kJ~3M|9pL`_hW#Ze#_H~lVuMGvGi6?ob>QawX5VCwW#?4(`r5MzFEEM zYMw{@7r%c`xh?-Hdt_a7SJ168y{*mg;nh96vXIy$_?eYNt#v zdr|PUlTiUNVO~94lP29g_qC%rSbIX%$II1WZzEfsYCqrmy}b|~@1z*AIm375_5QFGd{2r~lTx%Ee3YKMTW9?zr{AxRuAHteuiV48ZT-i? z99Q+fDNA+!^fD|kMSsz* z5Be>V8Qb`-pS#8_`0Rv>%Pqs`&5KqQ>@}->aq64Fv!6mI&+*CGwsl8Z8a6)s9DU?e zY{u!johdym>kIbpxR4>-9aFZ??98lIH~+Fz`63oqs zzl2#`b7i2i;ZwH_CzqK;E8g3je(d7syn>^qH$L4_ohm7EIOc=AgX~Pxnwpc>3-j%b zm~|Tc_&z%=y*_h)#=oD}O@tQ5men3f{~7v|ZC!=szQm4&d6EQP?TVw6#ET7w-qy@aa85R|6RH;*&2-7QyNG$iZ`N(k{(ITNqYu{?K`K`n#qZTii-@CMwkjIWrwi zjyzY*8ee9?|K--SsKbtFTmOsIg|o$#KQ%aRSM)&pmgx3|=HP_Fj`!26D=U55jJ;cL zY=1BRD&pjYkKL;T)#VTWD0iyf6m-So$;8uMt3{y+`o;H>OeeNcgj)O^w+; z-%dLhe9gV&|KW}NWs7dM@Z=X_a<6%kxA^Sr)@i;mx5cBx=U!p9$iL663&Wjt zjMgtOd1SiAo&De{sU24%4leOqT9)F>{)9V0=Iqq$dw-h?SA3fNpTkyi`AIIt|2e)J z&Z|tCpzvk?Y+eqjfhHelQ-K!F!`XD-pSgZ2N^cUoq4*0=T_|5)%DrQrv%dd zSrT>T2Q{3vnWH>Kz1Qe|nQ!VhljPH{J~le=Z5DO-?YCKfN@QH}m3Lpw<=(HzPF+5; z)lp!FxL8T-rVAy>pBtmSSE+B7R$H`1RX}sO^oqK4fb8TP79;OS8Frd#=&D zpGSQE%?oY)i+r?~8tu3?NxnJ6$%A8ZpR&@+<_yD_6BFzT&YtIeS{AWo{gGaWy{r#b zt@ZBQ<#u$(CGY$L`Q=h798G1jU5{^`w<<&YHeYzMu8{Pi%X18Jyc!bcBrRWN9=22G z7vopfU|x=WRue-)<6fOn{BL&I^2q+53;2cD*BzVdb#;Cs`~2Hg(w}EEdo(p|VR`mU zdfA>CQ5{_czh`Vo>fX5|Hu$!L>qE)q9~_*U0=JfapAoR(%J0*&ER1;OmF#3rz0GxC zs^Yrqd*$7`?+@2kx_LA#ecdpFj=bQ$A78rQc#rMv&x4!OTc~-seo%f55 zfaqS2`5!Fia-Mr}`{t2-S(`7}=Izdy_G1(4*AIs@PsaYZtI_&o@~xofQOD06YPPaG zf9z_-laRl5O8zTu?Yi0U|E_M3TQJw}wOd^!EN;~=dtN=ch-Y$0Rp_@_XQT|GR@zoi zv;7enB$R8TF<6 zMIQGO_@H)P(`K>rr5(x4JL~EglJj`wUS~{ux+neN=_{c^$G2A4U;XgVS1vT8h9_*w zImTo2(v_@LCutUxvubmCe*Uy`^Q?a?@_VGxbLNPaX<-B3Z zyPYeur?6^Ec)YAOv&xL-=I;t~_o}LJs-IZAQh4Ud2eD6_XGAbCysJC)f1dNS?RQ;T zw%iQvwb@gUcYk(jP>@G#IBSj5@3=Wn?Qd$|&`O%}SS|l+RsSN+BRt2q-q(J#_=x+v zbVGp;dzZDuG<|ej`AwxNeVM;y(|4zzKHVt~8C~_RO|f<`;TfBW8T$ z49M$_h(9a!YORC2egCZEKJLkr9kV#J9T(j2J(;#&kWF^|UcS{SmCF>@U+g=+?!=;{ zE*o;WbJ=#@db;WR-||DXw-*<6RY>q^@XueXx`{E)RpZo}58Ty1))W}1X|U~Fa;)O* zj?8(5P29&P+$;-!{N?D5DfYL%s?X$Iw}WlRveKKU5@sm4Ecc1DJ7A>I{Iv1=gk@3T z4Gq>irDPr*W-+PsjC@$U%4pujkXuV4kL(oJh|Ha#r67H9=Q)nMoGdqA-}t}GO!t`f z)hyN)uUoPHe}3nF*pqj2e`kfXlbriitBVCJd?n|0`^KcY+rL@<_uay8Rl$#(?0BDD z{#T`Ccvkg~ZhC}Dxw>>}>NM3)rhC)EFYXezc2<03e7qxIs%&$IwN~&ni8ltzcxV19 zOZ~E7kGO)v4F9*+6tlbbY-_zfeR+`M4%V4fnbSTl;8EUiCF)tj+XR_7?~?G3HY=LT zX01+?;^cfP|Kaw(c(z+jf7N=ARWCogGUZB{Xj%S@hBl71ca!RVq&~NME-$b?`aU~H zvTLZPs2t1G5CJZye?eFMJF^>>m(}*k{;Auu;M^THM~CVit#ydcipM{n|qE7ay6Q88lm5bk>7tg|L8tr?o@L<&UCkb6a3ollN zg&WPf*PFOzx$N>|1#OD=#iDFA|F$N7+O~JbBzfoN!u~A`*do0=L28H_eEvg{`VwjlDJvKlohfKnyQXhPPW{x zQpuD*IOplDe&dwW-ml-wERJgWeB;__3&RvqZ{7pRb1trt>Ul3Opuu%$N#`f2yB7=1 z4TR1qEaP2MtiQ}yRAqCM;`PvNJ&w{fGj6<|D<5HP7u7z;_wQM`cdS-SkAJK=+LQ^$_oeY=F2}CBr!^dUnJU%XSd7FhXk{8#Tb_D&~ zJz4nRoONH><`-MZ{>XapXZjPdn!x9*P2PTgziCZA-^=v6=wj#j^KtvKjyHNSpN(0p z|8-f0T76jBhwbMSqMUwcUC2tW@mS$~O8$xg&w&!Fp#T2f&t`@wXP@bsaz@~5%CXii zJ)zv6lB!3;tkNdWpA^2UQMAOSN?GKNROhSoOYhj4skDG|VZE3&O-$Zr-4IHYG~e^Bjo#AdmjKQ8R}^VPFd^81^PnGyIbAg&QSVRe>Sk7g{r_OVLF zY?Fqaf;IQ8bCX#2EZ`FMS-&;H+xu5snSZJN!@A0SuPy&g*v9;%_<_NvawdVB8EX9x zi>{Y#m$-c3!fx3_p$?|Gf4?8`<}dS5IJ)kDA~zf7W-%WP>$O=)Vp%)~{VW6z7jVDJ zc&;1%x`<_--W1oU%(D~Nw(9<6{=Bx$?a!ZY)0Stylev`e(vzq6($(a>U++~u@@M{B z*{9#&ap}nOGkfAPpY8qs=Jk(nOMi%J-!dsaDB`v3QH1j1*2YM6@j3s}pJipSXno$_ z_}{}=S#$SJ`$L!4v@N!NKT%q$Qs$Sr@!rbGZt_c?oO;CJkEp7F1U zd@J=evtN_%;!~4j=2lJ9uWz)<+R?#oP*`5rwDGIkla%?@T6$WVkvzef9-Ii0abWBm@X z@FSl-pS?Kcyw=OC|5Y(6DZG721(It(mXielz)O z$N!M6W9ObF)$=YbT&UNRYnbNY`BH6y;qF}%r?@pYow~Sh@v6u3o94K@HsNzO>Yx3J z<7F=2j##!OHCh^sjQdtT6xiM#9mlSqHnZoy@U;gDHBEgv+frDii+Q*@+SDJ{1)Q~7 zQ$0r~TJFXZ{lwk3V!rXmuTCoS%1A8SP_)percOwcY3tq#E=P(|V>BQx2l#>|aztnwZCMP(T zv8`f~UU5b5xRG1s^@RClT{iiJch+*hZ@hK$%Egr_Z8=K|m+bj*m*@ZV6|esWJn?Uc zU|ryJ?B_(QfCdd_p1{MK_S;YCk<<2NxGwFyP?%BjN|3fAlaQ{VnC(sbt2;Q-RhEwtX!d6eO?J?c4s}%Q`IJvPgOrq1oAI-^1Y5BImbe+9E+2Lxtb3lLLh1 z^&)e-K3({_sA~0UwL1Um)>-dG9~^&Bt9Y&_=W0;lgH+LwRcBVF+pm=P@1XP9a)l3v zwD2;$UNMJ1+xO3(p|SW-O|gUj>z9A`6$Z|>;;v%8`h{iZoXU)udH>I7vwJFSv7ceR z)LTr3b8oOA=dP`7-;V5gzftD%le{qJHz(4iPVGL%V=uCKeN1oplsN~QUE%Z?U1H1npL>aRQzk-GTGB@gpGrN`eT zKg)inF#qAam%6WvtDH8(^YP?c2PsWgX4|E=`dyX@2U}(BtB-<+$>EUTG~1 ze|G!F^2$_!@RN79^L5tqAJ=E)_0KuKUMOhWv&72Z`#x_^Nn5HpXN&RNz8>qcx6SD< zw`%Xy+9n;rlve6F`G0=@Nz1UDl-4^=%fqa)|79NC9K205{!ip}Z=F4t+rGPAt6sk7 zZ{LqIZogD#%cf08<8>1&eIDL5cZHs78cP@>uiCz)(?7eu{jP3Ra*cx_UUW+2Pqmdz zLQ{VS%NGXq`-IC1zt0f7xO<-M)}yx#ua_&Ieco?AH>~fQQXNyy&iN7P*DpTPQxmXf z-}A0p=+zUhMXpE0CT{Z6%Pnjd=l_#r^6<}of#7Opb2+ZW(EYbw7S6XR+5BBmv{E{s z)hjr0QAEC-+1DcvLYR+#KXR;m&2o>E>s7r(`jxLHb}cH=xH&CcJo4WZp_kh#yQ6yw zRZg~_Oj+FQ^j>dyswL}(v?;5DY6BNIY=5a}H1F2*EoLul;@La*6~{gM)up1sdBir; zyGMzij?D}0W-BUicq(Duhq_hTop9nuD7*vpAb`ott#U{PyAf0vE45 zeKlL8G0ju>vXWZ;Vwc1;!2PLjt`)T)&YR%BgMlFXCatet>~}M;2s-_}UZq~ZI{!^|aNPsr81V!X`7iVMqEj5FyIk5Z z-#Ory)XnhseCyPXO#WQ+G&VzC)rL#aPu+f-q($a0qmQ#${;rnbzJD;Q;hW0W1)kZ} z7X<}xx=)+txpH~6V8W`$FRCXlTV=|cFlDa0|IuZtliX)d`Yw|^v(WWV$1J}qDgG+A z=DmNvm_>8u%#6zuk96;T=X^Xhvzq_KCZB)r!>%3qmlXLk>Co{|7meyS)-xa85hz=7 z^`StE=7Fm-_H2EA=(<(r}w~kxOZ-k`O+R~b^VgtCi~O(JUf4#n@!|qqt8v}3-vYl%*~@~*q?U`tu`p$n%8+m^w-ji zUn#2E7az4Staq=>-k-dDr$@%wJ$V|fjXyVh{QACKrHb)luYH5;Ix9#Vb zkqEYZ*3-ei+)M12KYO;MVc3U7=jBe?X|J1-+mfrZ_RaC+iyes`0efdI?pF|(^4(N& zFeBgW+IHjVyS$cVzfxY_>i(``g3KH(+wJwCbqU9AZMr0|U*w*a>-BwnQ{Kifd^ewb zKF2ORF!uAqdY6-*KQ3w0@49A{d8hE{1#!>Ep8^%O*to9PQO=s?a&Tg&MEjhrv4K0M z8aEl|@17U*EZF$&I+N`56sa!^H>U<%2;RHrs{1A1UzbGm&2P=y8K&gCYH#c(^^;~P zs($;u7@n!@p0PU3<9pu3h3S5+T#vilmhTW$Uur7<%3*SY>1#WV#Zktuwq|)x~BQ@-yxP|-F0$dvHFcTs1v=G`;i6%+dG;r;%@yNqI`qRy8a@3zp}eMllJbLv7< z(dD)`-Tr(N&=ks>TXXfx(*<5_<~s0c<0Q?b zA2+g|%bt!YV7z)vaQCsBSMm&Hj|(N4Y4iG<*%ZF-7MS*A`T^FKdC~v6GnkrqpY1H{ zF+P7iQeuM7>hBuTjsM}|F|+7@skp>m^g7^ z8vnEYhmt=FW?WP*Dhm+Y^dVJkT4(P)7YTp9+pX8l*S~jqt&y<4t4)xp`O&IbTT|aL zO6i@`lgTx^#@5>^6swrpy^`nt`8j>p3cfd-ectiRpL?g<@t#Nbj|6TA`FHB=E~WF| zD!JDf%1mRLRP}(v+(*OSG3?qx-6apY&aaxbC9tR8?Mc*0k0ZQQ5gSCiB^XaE^5zhV zEL!kXS4Pt-+%22AUw!B2b;2|L=+8Z)m(ce8xXR0u66su78wHnrPqDZ9apcs;(BO+l zuaw1BzEOE9Jo)~_GkvypMgG%Y#*2F-o?h5klpklgJ>q)eTSE>NGeyQp>=Mcoy;@EE zB<^hdtsb)K=nI z?RDld)i#*^dcrGVtt&#$m`%E8P zs&2)FFIQQ$W|GjVBF{~xFaQ5Jn05GET_8e%2FD9J+JiZ}*#T_v14)=Y2b0$7^|f_mK@tFI^O`{pS8Z?dAcN zc>-zWeLCwk5+D2yI`;GS0m&a%Dtcng_W=<4)qq5+gW{B+nTh|VHi?X#9E%e_sWw8uD`&IMh_kW&z zxAV_I&ufPny)&ZbT zf4_U_w)P?Svb>AlGI3nj?=Lzl5o7;<^8AK*%gS{==Pb;yIYE38Q zmQHy3d0UuN*?t?@8*-hKjjsRwm@=>2aq1!#?-dueFJC_`F>SdE)-Sb+XaEU`F`o#Uv zC9gjzJ>b?kS@`u`>J;CW$x|1m+8m0Ed*xNaI3cicgK!o@!ornidgt5=JFg~vOy-?< z`S*Qai_ZClv07c5_E-cij;(9Em3+fE{ejNm72=}jb~r9rm_3Jof2b1E0==q}4=O!! zzAwmJe(j>w)5G_sIH+*;KWKYoH@{j@OLMAS)Q)#+Y`XVLwcJ_Aa*6q@SlGgVzq(eOCAc)@zISGPZ5HZu=yAkz%F&nI{?&XWuQHa7aSo@-z0}!_WTu zu<7|*e_~2$6)34>@3TAIpr*eu^5{c{Muq0{%RXwGEsS5a+{6H$HBkIP7Jslf#dE=}st)_T&#>uKmSyD00Tn zsDpA1N|7!e%inl*CjQc1c2uCd^4jAC@rD!c9In2cHB;^0EGKrG`O}uvFBUpC_w_ur zy~mbD-doMTddH5nPKujK(k0cm7;13dWIOcW@1%gto~0Yv{#fiv3%1)6qT2Pj_U_Rm zj7t^o#Z8%$^W36Ldtz?!kI$Dn_)hXBOjen@v`h5Odv@cwYkWUH<;nV2a`gJGn^`~p ztJ~g)W?FRW;Rb!rc**png~E14zc#*m9qUxUY+%OkSe<(3O$Wyr76yj@N*iY}ah4ARX#sgEnwqwp2~dpjc&jF8o71k->jPYt3T({ zXNP^^0iWFSBitu$^P5+~Tt8>^j_2x`fBD?nH?VbIzQF0UDv>qzce9)aavwBKkw(qoR1JShBsZ)9mPW&NT!(^)a z*StMVzVlgKT37SsM-ovh&Pe_^r1aGysNE}i)m!y%-=2qvd%gUx-?pXk$g0|^*Pp%4 z`>KbO9b`T4^^N_svQ+VdvZ%L_XVkW_>oQI=vQi7Z^H0lX^@*e&r~@Km-Ue}S-&g3gvDexlz_rE<5|nhL*} zZ^3nQ%B=n+g~i+VKRWx})h_!X$ zo8v$HgJX?pt?tZrL%O z&Cx65W@K1;Qt7E6-|_}2m6HmJW~K3O6gsaSkX?Ez!&xqFTk*8LexmBxDIym)C#}7> zi^GMBFa5x+pYj3)sN+vX$3c3dg8vDm5=d-tc-wntp-uaq2}Uwt#Y7FH4WbGPff!z->Qq-&mRuAi0l zeCec<(Xx$;Ce04BjmgtJ{mWqIUlpb+r~R+*d~+-0bJngEJbHx;KOGh`>0F&tnjCRV zjqCTz2MH?|K0ke9&p)ZN|7$M)Hb}3D`f(uD_jvQ!Q_pxDzns~AX|es((_ar%7#GpO-2%zV3e(bviERdEMcn zD}1?|>!)P$9DKSwJiTMpyLFM<1OjpnGO_1=UCaJ+<%~IPVUqV(fBU>^kz9uWkR@ul~7@b4(zKc(zfN=>N$$F+KTnKQDj=YCpYQdFmPsYbo&!?Z4r#7u45 z?UOjzPVI@#wqjtLCB2H*w*SbXmZ+b)s=Ogtt3TguXN_5Zw9;UWN2C+)x+`VHGfXor za^E^1UoI~p)8o`>pl5WZGb{Y-hwX~>`_{$(c2QYQpx_q z?d^K+E6o?Y?QqH4XXP$)TF8FA@L|tq??3pm6$$;|OSTi-^tiogazRWQ?-#|>c6S)g zuKJU3R9`3b_WERv(~7?fm2X$*U0!xz9n*=jh?7mOCi<#d+zlSgcXBAs@_LXUR8c#7 z!b|@JrmK(Vy}oogh&#jfwE3T?`&v?U4vMw>$JQ3bCHHjSG`_f+uhOmT|Czr>7cKKi zRm*m?a#-=w%HN0gE60u&{Z@@3`_pbE7v`;Hi%$A3! z+B>lZ+o$ S`lo2_U0nPvWW&D$3=9B-9~tcc literal 0 HcmV?d00001 diff --git a/phone-connect/tile.luau b/phone-connect/tile.luau new file mode 100644 index 0000000..4f6f359 --- /dev/null +++ b/phone-connect/tile.luau @@ -0,0 +1,89 @@ +-- tile.luau +-- Phone Connect - control-center tile (thin UI entry). +-- +-- Shows a compact status: connected count or selected device battery. Click +-- opens the details panel. Reads state from the service entry; no DBus here. + +-- Pick which panel id to toggle based on the user's panel_placement setting. + +-- Local translation: reads from pc.trTable (published by service) so users +-- can switch language at runtime, unlike noctalia.tr() which follows system locale. +local function t(key) + local table = noctalia.state.get("pc.trTable") or {} + return table[key] or key +end + +local function panelId() + local mode = noctalia.getConfig("panel_placement") + if mode == "floating" then + return "icefish/phone-connect:details_floating" + elseif mode == "widget" then + return "icefish/phone-connect:details_widget" + end + return "icefish/phone-connect:details" +end + +local function selectedDevice() + local devices = noctalia.state.get("pc.devices") or {} + local order = noctalia.state.get("pc.order") or {} + local sel = noctalia.state.get("pc.selected") + local id = sel + if not id or not devices[id] then id = order[1] end + if id then return devices[id] end + return nil +end + +local function countReachable() + local devices = noctalia.state.get("pc.devices") or {} + local order = noctalia.state.get("pc.order") or {} + local n = 0 + for _, id in ipairs(order) do + local d = devices[id] + if d and d.isReachable == true then n = n + 1 end + end + return n +end + +local function render() + local backend = noctalia.state.get("pc.backend") or { available = false } + if not backend.available then + shortcut.setIcon("phone-off") + shortcut.setLabel(t("status.no_backend")) + shortcut.setActive(false) + shortcut.setEnabled(false) + return + end + + local n = countReachable() + local d = selectedDevice() + shortcut.setEnabled(true) + + if d and d.isReachable and type(d.batteryCharge) == "number" and d.batteryCharge >= 0 then + -- show selected device battery when reachable + shortcut.setIcon("device-mobile") + shortcut.setLabel(tostring(d.batteryCharge) .. "%") + shortcut.setActive(true) + else + shortcut.setIcon("device-mobile") + shortcut.setLabel(n > 0 and (tostring(n) .. " " .. t("status.connected")) + or t("status.no_devices")) + shortcut.setActive(n > 0) + end +end + +function update() + noctalia.setUpdateInterval(3000) + render() +end + +function onClick() + noctalia.togglePanel(panelId()) +end + +noctalia.state.watch("pc.devices", render) +noctalia.state.watch("pc.order", render) +noctalia.state.watch("pc.selected", render) +noctalia.state.watch("pc.backend", render) +noctalia.state.watch("pc.trTable", render) + +render() diff --git a/phone-connect/translations/en.json b/phone-connect/translations/en.json new file mode 100644 index 0000000..ebbe9f7 --- /dev/null +++ b/phone-connect/translations/en.json @@ -0,0 +1,118 @@ +{ + "settings": { + "state_update_interval": { + "label": "State Update Interval", + "description": "Seconds between automatic device state refreshes. 0 disables auto-refresh." + }, + "enable_charging_animation": { + "label": "Show Charging Fill", + "description": "Highlight the bar widget when the device is charging." + }, + "custom_image": { + "label": "Device Image", + "description": "Set a custom image for the selected device. Leave empty for default icon." + }, + "device_alias": { + "label": "Device Alias", + "description": "Custom display name for the selected device." + }, + "language": { + "label": "Language", + "description": "Display language", + "en": "English", + "zh_hans": "简体中文" + }, + "enable_clipboard_action": { + "label": "Show Clipboard Action", + "description": "Show a quick action to send the clipboard to the device." + }, + "show_ongoing_media": { + "label": "Show Ongoing Media", + "description": "Show media currently playing on the phone." + }, + "show_device_placeholder": { + "label": "Show Device Placeholder", + "description": "Show the device graphic in the details panel." + }, + "scan_subdirectories": { + "label": "Scan Subdirectories", + "description": "Recursively scan subdirectories of the recent images path." + }, + "max_recent_images": { + "label": "Max Recent Images", + "description": "Number of recent images to display." + }, + "panel_placement": { + "label": "Panel Placement", + "description": "How the details panel opens: attached to the bar, or floating.", + "attached": "Attached to bar", + "floating": "Floating (center)", + "widget": "Below widget (floating)" + } + }, + "status": { + "unavailable": "Unavailable", + "no_devices": "No devices", + "offline": "Offline", + "connected": "Connected", + "not_paired": "Not paired", + "no_backend": "KDE Connect not running" + }, + "action": { + "ring": "Ring", + "ping": "Ping", + "clipboard": "Clipboard", + "share": "Share", + "browse": "Browse", + "sms": "SMS", + "pair": "Pair", + "accept": "Accept", + "reject": "Reject", + "unpair": "Unpair", + "refresh": "Refresh", + "switch": "Switch device" + }, + "notify": { + "ringing": "Ringing {name}...", + "ping_sent": "Ping sent to {name}", + "clipboard_sent": "Clipboard sent", + "pairing_sent": "Pairing request sent", + "paired": "Device paired", + "unpaired": "Device unpaired", + "opening_browser": "Opening file browser...", + "sms_sent": "SMS sent" + }, + "error": { + "ring_failed": "Failed to ring device", + "ping_failed": "Failed to send ping", + "clipboard_failed": "Failed to send clipboard", + "share_failed": "Failed to share", + "browse_failed": "Failed to browse device", + "pairing_failed": "Pairing failed", + "accept_failed": "Failed to accept pairing", + "reject_failed": "Failed to reject pairing", + "unpair_failed": "Unpair failed", + "no_backend": "No backend" + }, + "panel": { + "title": "Phone Connect", + "empty": "No devices found. Pair a device in KDE Connect.", + "devices_connected": "{connected} connected • {paired} paired", + "recent_images": "Recent Images", + "no_recent_images": "No recent images found", + "now_playing": "Now Playing", + "no_media": "No media playing", + "image_path": "Custom Image", + "sms": "Send SMS", + "sms_dest": "Phone number", + "sms_msg": "Message", + "sms_send": "Send", + "battery": "Battery", + "network": "Network", + "pairing": "Pairing", + "type": "Type", + "paired": "Paired", + "unpaired": "Unpaired", + "unknown": "unknown" + } +} \ No newline at end of file diff --git a/phone-connect/widget.luau b/phone-connect/widget.luau new file mode 100644 index 0000000..bbabf92 --- /dev/null +++ b/phone-connect/widget.luau @@ -0,0 +1,133 @@ +-- widget.luau +-- Phone Connect - bar widget (thin UI entry). +-- +-- Renders a bar pill reflecting the selected device: device glyph + battery +-- percent, colored by state (charging -> primary, low -> error, offline -> +-- dimmed). Left-click opens the details panel; right-click opens settings. +-- Reads state from the service entry; no DBus logic here. + +-- Local translation: reads from pc.trTable (published by service) so users +-- can switch language at runtime, unlike noctalia.tr() which follows system locale. +local function t(key) + local table = noctalia.state.get("pc.trTable") or {} + return table[key] or key +end + +local function panelId() + local mode = noctalia.getConfig("panel_placement") + if mode == "floating" then + return "icefish/phone-connect:details_floating" + elseif mode == "widget" then + return "icefish/phone-connect:details_widget" + end + return "icefish/phone-connect:details" +end + +local function glyphFor(d) + if not d or not d.isReachable then return "phone-off" end + local t = d.type + if t == "tablet" then return "device-tablet" end + if t == "laptop" then return "device-laptop" end + if t == "desktop" or t == "computer" then return "device-desktop" end + if t == "tv" then return "device-tv" end + return "device-mobile" +end + +local function batteryGlyph(charge, charging) + if charging then return "battery-charging" end + if type(charge) ~= "number" then return "battery" end + if charge >= 90 then return "battery" end + if charge >= 60 then return "battery-3" end + if charge >= 30 then return "battery-2" end + if charge > 0 then return "battery-1" end + return "battery-off" +end + +local function selectedDevice() + local devices = noctalia.state.get("pc.devices") or {} + local order = noctalia.state.get("pc.order") or {} + local sel = noctalia.state.get("pc.selected") + local id = sel + if not id or not devices[id] then id = order[1] end + if id then return devices[id] end + return nil +end + +local function render() + local backend = noctalia.state.get("pc.backend") or { available = false } + local d = selectedDevice() + local enableCharging = noctalia.getConfig("enable_charging_animation") + + local children + if not backend.available then + -- no backend: dim phone-off glyph + localized status + children = { + ui.glyph({ name = "phone-off", size = 14, color = "on_surface/0.4" }), + ui.label({ text = t("status.no_backend"), fontSize = 10, + color = "on_surface/0.5" }), + } + elseif not d then + children = { + ui.glyph({ name = "phone-off", size = 14, color = "on_surface/0.4" }), + ui.label({ text = t("status.no_devices"), fontSize = 10, + color = "on_surface/0.5" }), + } + else + local reachable = d.isReachable == true + local charge = d.batteryCharge + local charging = d.batteryCharging == true + local hasCharge = type(charge) == "number" and charge >= 0 + + -- device glyph: primary when charging, dim when offline, normal otherwise + local glyphColor = "on_surface" + if reachable and charging and enableCharging then + glyphColor = "primary" + elseif not reachable then + glyphColor = "on_surface/0.4" + end + + local kids = { ui.glyph({ name = glyphFor(d), size = 14, color = glyphColor }) } + + if reachable and hasCharge then + -- battery glyph + percent, colored by level + local batColor = "on_surface" + if charge <= 20 then batColor = "error" + elseif charging then batColor = "primary" end + table.insert(kids, ui.glyph({ name = batteryGlyph(charge, charging), + size = 12, color = batColor })) + table.insert(kids, ui.label({ + text = tostring(charge) .. "%", fontSize = 11, + fontWeight = "medium", color = batColor, + })) + elseif not reachable and d.isPaired then + -- offline but paired: show a small dimmed "offline" tag + table.insert(kids, ui.label({ text = t("status.offline"), + fontSize = 10, color = "on_surface/0.5" })) + end + children = kids + end + + local container = barWidget.isVertical() and ui.column or ui.row + barWidget.render(container({ gap = 5, align = "center" }, children)) +end + +function update() + noctalia.setUpdateInterval(2000) + render() +end + +function onClick() + noctalia.togglePanel(panelId()) +end + +function onRightClick() + noctalia.openSettings() +end + +noctalia.state.watch("pc.devices", render) +noctalia.state.watch("pc.order", render) +noctalia.state.watch("pc.selected", render) +noctalia.state.watch("pc.backend", render) +noctalia.state.watch("pc.trTable", render) + +render()