Commit e16823cd authored by Anton Palgunov's avatar Anton Palgunov

feat: Shop initial work

parent e9694e8c
......@@ -36,6 +36,40 @@
]
},
{
"name": "python3-requests",
"buildsystem": "simple",
"build-commands": [
"pip3 install --verbose --exists-action=i --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} \"requests==2.32.2\" --no-build-isolation"
],
"sources": [
{
"type": "file",
"url": "https://files.pythonhosted.org/packages/1c/d5/c84e1a17bf61d4df64ca866a1c9a913874b4e9bdc131ec689a0ad013fb36/certifi-2024.7.4-py3-none-any.whl",
"sha256": "c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90"
},
{
"type": "file",
"url": "https://files.pythonhosted.org/packages/63/09/c1bc53dab74b1816a00d8d030de5bf98f724c52c1635e07681d312f20be8/charset-normalizer-3.3.2.tar.gz",
"sha256": "f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"
},
{
"type": "file",
"url": "https://files.pythonhosted.org/packages/e5/3e/741d8c82801c347547f8a2a06aa57dbb1992be9e948df2ea0eda2c8b79e8/idna-3.7-py3-none-any.whl",
"sha256": "82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"
},
{
"type": "file",
"url": "https://files.pythonhosted.org/packages/c3/20/748e38b466e0819491f0ce6e90ebe4184966ee304fe483e2c414b0f4ef07/requests-2.32.2-py3-none-any.whl",
"sha256": "fc06670dd0ed212426dfeb94fc1b983d917c4f9847c863f313c9dfaaffb7c23c"
},
{
"type": "file",
"url": "https://files.pythonhosted.org/packages/ca/1c/89ffc63a9605b583d5df2be791a27bc1a42b7c32bab68d3c8f2f73a98cd4/urllib3-2.2.2-py3-none-any.whl",
"sha256": "a448b2f64d686155468037e1ace9f2d2199776e17f0a46610480d311f73e3472"
}
]
},
{
"name": "python3-docutils",
"buildsystem": "simple",
"build-commands": [
......
import os
import shutil
import tempfile
import threading
import requests
import time
import yaml
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Gtk, Adw, Gdk, GObject, GLib
## ToDo: Унести это в адаптеры для работы с API
GITHUB_API_URL = "https://api.github.com/search/repositories"
GITLAB_API_URL = "https://gitlab.com/api/v4/projects"
CACHE_DIR = os.path.join(os.path.expanduser("~"), ".cache", "tuneit_shop")
if not os.path.exists(CACHE_DIR):
os.makedirs(CACHE_DIR, exist_ok=True)
MODULES_DIR = os.path.join(os.path.expanduser("~"), ".local", "share", "tuneit", "modules")
if not os.path.exists(MODULES_DIR):
os.makedirs(MODULES_DIR, exist_ok=True)
###############################################################################
# Работа с репами и кеш
###############################################################################
def load_from_cache():
cache_file = os.path.join(CACHE_DIR, "modules_cache.json")
if not os.path.exists(cache_file):
return None, 0
with open(cache_file, "r", encoding="utf-8") as f:
import json
data = json.load(f)
return data["repos"], data["last_update"]
def save_to_cache(repos):
cache_file = os.path.join(CACHE_DIR, "modules_cache.json")
with open(cache_file, "w", encoding="utf-8") as f:
import json
json.dump({
"last_update": int(time.time()),
"repos": repos
}, f, ensure_ascii=False, indent=2)
def fetch_repos_from_github(search_query, page=1, per_page=30):
# Пример https://api.github.com/search/repositories?q=tuneit-mod+in:name
params = {
"q": search_query + " in:name",
"sort": "stars",
"order": "desc",
"page": page,
"per_page": per_page
}
r = requests.get(GITHUB_API_URL, params=params, timeout=10)
r.raise_for_status()
data = r.json()
return data.get("items", [])
def fetch_repos_from_gitlab(search_query, page=1, per_page=30):
# Пример https://gitlab.com/api/v4/projects?search=tuneit-mod
params = {
"search": search_query,
"order_by": "star_count",
"sort": "desc",
"page": page,
"per_page": per_page
}
r = requests.get(GITLAB_API_URL, params=params, timeout=10)
r.raise_for_status()
data = r.json()
return data
def parse_module_yaml(raw_url):
try:
r = requests.get(raw_url, timeout=10)
r.raise_for_status()
return yaml.safe_load(r.text)
except:
return None
def retrieve_modules_full_list(force_refresh=False, initial_page=1, per_page=30):
"""
Реализует загрузку и логику суточного кеша
"""
# 1. Проверяем кеш
cached_repos, last_update = load_from_cache()
day_in_seconds = 24 * 60 * 60
now = int(time.time())
# 2. Если кеш валиден и нет force_refresh
if (cached_repos is not None) and (not force_refresh) and (now - last_update < day_in_seconds):
return cached_repos
# 3. Иначе загружаем данные
aggregated_repos = []
# GitHub
github_items = fetch_repos_from_github("tuneit-mod", page=initial_page, per_page=per_page)
for item in github_items:
# Проверяем, что репозиторий (item["name"]) действительно начинается с tuneit-mod
if not item["name"].startswith("tuneit-mod"):
continue
# Пробуем скачать module.yaml
print(item)
raw_url = f"https://raw.githubusercontent.com/{item['owner']['login']}/{item['name']}/{item['default_branch']}/module.yaml"
module_info = parse_module_yaml(raw_url)
aggregated_repos.append({
"source": "github",
"repo_name": item["name"],
"repo_full_name": item["full_name"],
"stars": item["stargazers_count"],
"branch": item["default_branch"],
"description": item["description"] or "",
"html_url": item["html_url"],
"star_url": item["html_url"] + "/stargazers",
"module_yaml": module_info
})
# GitLab
gitlab_items = fetch_repos_from_gitlab("tuneit-mod", page=initial_page, per_page=per_page)
for item in gitlab_items:
if not item["name"].startswith("tuneit-mod"):
continue
# Пробуем скачать module.yaml
namespace_and_repo = item["path_with_namespace"] # "username/reponame"
raw_url = f"https://gitlab.com/{namespace_and_repo}/-/raw/{item['default_branch']}/module.yaml"
module_info = parse_module_yaml(raw_url)
aggregated_repos.append({
"source": "gitlab",
"repo_name": item["name"],
"repo_full_name": namespace_and_repo,
"branch": item["default_branch"],
"stars": item["star_count"],
"description": item["description"] or "",
"html_url": item["web_url"],
"star_url": item["web_url"] + "/-/starrers",
"module_yaml": module_info
})
# Сохраняем в кеш
save_to_cache(aggregated_repos)
return aggregated_repos
###############################################################################
# Логика UI
###############################################################################
def download_and_install_repo(source, repo_full_name, repo_name, branch):
"""
Скачивает ZIP-архив репозитория и распаковывает его
в папку ~/.local/share/tuneit/modules/<repo_name>.
"""
if source == "github":
url = f"https://github.com/{repo_full_name}/archive/refs/heads/{branch}.zip"
else:
# GitLab:
url = f"https://gitlab.com/{repo_full_name}/-/archive/{branch}/{repo_name}.zip"
# Временная папка
tmp_dir = tempfile.mkdtemp()
zip_path = os.path.join(tmp_dir, f"{repo_name}.zip")
try:
r = requests.get(url, stream=True)
r.raise_for_status()
with open(zip_path, "wb") as f:
for chunk in r.iter_content(chunk_size=8192):
f.write(chunk)
# Распакуем zip
import zipfile
with zipfile.ZipFile(zip_path, "r") as zip_ref:
zip_ref.extractall(tmp_dir)
# Найдём распакованную папку вида <repo_name>-main или что-то вроде
extracted_dirs = [d for d in os.listdir(tmp_dir) if os.path.isdir(os.path.join(tmp_dir, d)) and d.startswith(repo_name)]
if extracted_dirs:
extracted_path = os.path.join(tmp_dir, extracted_dirs[0])
# Создаём папку назначения
dest_path = os.path.join(MODULES_DIR, repo_name)
# Если уже была папка, удалим её
if os.path.exists(dest_path):
shutil.rmtree(dest_path)
shutil.move(extracted_path, dest_path)
finally:
shutil.rmtree(tmp_dir, ignore_errors=True)
###############################################################################
# Виджет “карточка” модуля
###############################################################################
class ModuleCard(Gtk.Box):
"""
Отображает карточку модуля.
"""
__gtype_name__ = "ModuleCard"
def __init__(self, module_data):
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=6)
self.module_data = module_data
# Настраиваем стили и отступы
self.set_margin_top(12)
self.set_margin_bottom(12)
self.set_margin_start(12)
self.set_margin_end(12)
# Фрейм или карточка
frame = Gtk.Frame()
frame.set_margin_top(6)
frame.set_margin_bottom(6)
frame.set_margin_start(6)
frame.set_margin_end(6)
self.append(frame)
card_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
card_box.set_margin_top(6)
card_box.set_margin_bottom(6)
card_box.set_margin_start(6)
card_box.set_margin_end(6)
frame.set_child(card_box)
# Обложка (cover)
cover_url = ""
if module_data["module_yaml"]:
cover_url = module_data["module_yaml"].get("cover", "")
if cover_url:
# TODO: Загрузка обложки
try:
texture = Gdk.Texture.new_for_uri(cover_url)
cover_image = Gtk.Image.new_from_paintable(texture)
cover_image.set_size_request(200, 150) # 800x600 – размер обложки
card_box.append(cover_image)
except:
pass
title_label = Gtk.Label()
if module_data["module_yaml"] and module_data["module_yaml"].get("name"):
title_label.set_label(module_data["module_yaml"]["name"])
else:
title_label.set_label(module_data["repo_name"])
title_label.set_xalign(0.0)
title_label.set_markup(f"<b>{title_label.get_label()}</b>")
card_box.append(title_label)
# Описание
desc_label = Gtk.Label()
desc_label.set_xalign(0.0)
desc_label.set_wrap(True)
if module_data["module_yaml"] and module_data["module_yaml"].get("description"):
desc_label.set_text(module_data["module_yaml"]["description"])
else:
desc_label.set_text(module_data.get("description", ""))
card_box.append(desc_label)
# Кол-во звёзд
stars_label = Gtk.Label()
stars_label.set_xalign(0.0)
stars_label.set_text(f"Stars: {module_data['stars']}")
card_box.append(stars_label)
# Кнопка “Установить”
install_button = Gtk.Button(label="Установить")
install_button.connect("clicked", self.on_install_clicked)
card_box.append(install_button)
# Ссылка на репозиторий
link_repo_button = Gtk.LinkButton.new_with_label(module_data["html_url"], "Открыть репозиторий")
card_box.append(link_repo_button)
def on_install_clicked(self, button):
"""Обработчик нажатия “Установить”. Запускает скачивание и установку в другом потоке."""
def worker():
download_and_install_repo(
self.module_data["source"],
self.module_data["repo_full_name"],
self.module_data["repo_name"],
self.module_data["branch"]
)
# TODO: Показать уведомление в UI, что установка завершена
threading.Thread(target=worker, daemon=True).start()
###############################################################################
# Инициализация Shop Stack
###############################################################################
def init_shop_stack(shop_pagestack, shop_listbox, shop_split_view):
"""
Инициализация стека страницы “Магазин”.
"""
refresh_button = Gtk.Button(label="Принудительно обновить список")
refresh_button.connect("clicked", on_refresh_button_clicked)
# Индикатор загрузки
spinner = Gtk.Spinner()
spinner.set_size_request(24, 24)
# В боксе горизонтально разместим кнопку и спиннер
hbox_top = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
hbox_top.append(refresh_button)
hbox_top.append(spinner)
# Создадим FlowBox для карточек, по 3 в ряд
flowbox = Gtk.FlowBox()
flowbox.set_vexpand(True) # Растянем по вертикали
flowbox.set_min_children_per_line(3)
flowbox.set_max_children_per_line(3)
flowbox.set_selection_mode(Gtk.SelectionMode.NONE)
# Включим только вертикальный scroller
scroller = Gtk.ScrolledWindow()
scroller.set_child(flowbox)
# Содержимое страницы
vbox_main = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
vbox_main.append(hbox_top)
vbox_main.append(scroller)
# Показываем в стеке
shop_pagestack.add_child(vbox_main)
# TODO: Вынести это отсюда
shop_pagestack._flowbox = flowbox
shop_pagestack._spinner = spinner
shop_pagestack._current_page = 1
shop_pagestack._per_page = 6
shop_pagestack._all_data = []
shop_pagestack._loading = False
shop_pagestack._all_loaded = False
# Подключим сигнал прокрутки для ленивой подгрузки (пагинации)
scroller.connect("edge-reached", on_edge_reached)
# Начальная загрузка данных
def load_data_initial():
load_data(shop_pagestack, force_refresh=False, page=1)
GLib.idle_add(load_data_initial)
def on_refresh_button_clicked(button):
"""Обработчик нажатия кнопки “Принудительно обновить список”."""
split_view = button.get_ancestor(Gtk.Widget)
while split_view and not hasattr(split_view, "_flowbox"):
split_view = split_view.get_parent()
if not split_view:
return
load_data(split_view, force_refresh=True, page=1)
def on_edge_reached(scrolled_window, pos):
"""
edge-reached срабатывает, когда прокрутка достигает края
"""
if pos == Gtk.PositionType.BOTTOM:
load_next_page(scrolled_window)
def load_next_page(split_view):
"""Загружает следующую страницу"""
if split_view._all_loaded:
return
if split_view._loading:
return
next_page = split_view._current_page + 1
load_data(split_view, force_refresh=False, page=next_page)
def load_data(split_view, force_refresh=False, page=1):
"""
Загружает данные.
Если force_refresh=True, то сбрасывает всё и грузит заново.
"""
if split_view._loading:
return
split_view._loading = True
split_view._spinner.start()
# TODO: Не работает на мета информации, передалать
# Если это новая загрузка (page=1 или force_refresh), чистим flowbox и список
# if page == 1 or force_refresh:
# split_view._flowbox.foreach(lambda child: split_view._flowbox.remove(child))
# split_view._all_data = []
# split_view._all_loaded = False
def worker():
try:
repos = retrieve_modules_full_list(force_refresh=force_refresh, initial_page=page, per_page=split_view._per_page)
except Exception as e:
repos = []
# Имитируем, что мы действительно получили только часть данных:
start_idx = (page - 1) * split_view._per_page
end_idx = start_idx + split_view._per_page
page_data = repos[start_idx:end_idx]
# Если page_data меньше per_page, значит всё
if len(page_data) < split_view._per_page:
split_view._all_loaded = True
# Дополняем общий список
split_view._all_data.extend(page_data)
# Обновляем UI в основном потоке
def update_ui():
for repo in page_data:
card = ModuleCard(repo)
split_view._flowbox.insert(card, -1)
split_view._current_page = page
split_view._spinner.stop()
split_view._loading = False
return False # чтобы GLib.idle_add один раз выполнилось
GLib.idle_add(update_ui)
threading.Thread(target=worker, daemon=True).start()
class Repository:
def __init__(self, name, description, url):
self.name = name
self.description = description
self.url = url
def display_info(self):
return {
"Name": self.name,
"Description": self.description,
"URL": self.url
# "source": "github",
# "repo_name": item["name"],
# "repo_full_name": item["full_name"],
# "stars": item["stargazers_count"],
# "branch": item["default_branch"],
# "description": item["description"] or "",
# "html_url": item["html_url"],
# "star_url": item["html_url"] + "/stargazers",
# "module_yaml": module_info
}
\ No newline at end of file
class GitHubService:
def __init__(self, base_url="https://api.github.com"):
self.base_url = base_url
def search_repositories(self, query):
# Logic to search repositories
pass
def fetch_repository_data(self, repo_id):
# Logic to fetch detailed repository data
pass
def parse_results(self, results):
# Logic to parse the results from the API response
pass
def download_module(self, repo):
# Download zip file from the repository
pass
\ No newline at end of file
class GitLabService:
def __init__(self, base_url="https://gitlab.com/api/v4"):
self.base_url = base_url
def search_repositories(self, query):
# Logic to search repositories
pass
def fetch_repository_data(self, repo_id):
# Logic to fetch detailed repository data
pass
def parse_results(self, results):
# Logic to parse the results from the API response
pass
def download_module(self, repo):
# Download zip file from the repository
pass
\ No newline at end of file
class PlatformService:
def __init__(self, base_url):
self.base_url = base_url
def search_repositories(self, query):
# Logic to search repositories
pass
def fetch_repository_data(self, repo_id):
# Logic to fetch detailed repository data
pass
def parse_results(self, results):
# Logic to parse the results from the API response
pass
def download_module(self, repo):
# Download zip file from the repository
pass
\ No newline at end of file
......@@ -73,7 +73,38 @@ template $TuneitWindow: Adw.ApplicationWindow {
}
Adw.ViewStackPage {
child: Box {};
child: Box {
Adw.NavigationSplitView shop_split_view {
hexpand: true;
content: Adw.NavigationPage {
Adw.ToolbarView {
[top]
Adw.HeaderBar shop_content_bar {
decoration-layout: "";
visible: false;
}
Stack shop_pagestack {}
}
};
sidebar: Adw.NavigationPage {
Adw.ClampScrollable {
margin-bottom: 8;
margin-end: 8;
margin-start: 8;
margin-top: 8;
ListBox shop_listbox {
styles [
"navigation-sidebar",
]
}
}
};
}
};
icon-name: "system-software-install-symbolic";
name: "shop";
title: _("Shop");
......
......@@ -20,6 +20,7 @@
from gi.repository import Adw, Gtk
from .settings import init_settings_stack
from .shop import init_shop_stack
@Gtk.Template(resource_path='/ru/ximperlinux/TuteIt/window.ui')
class TuneitWindow(Adw.ApplicationWindow):
......@@ -28,8 +29,14 @@ class TuneitWindow(Adw.ApplicationWindow):
settings_pagestack = Gtk.Template.Child()
settings_listbox = Gtk.Template.Child()
settings_split_view = Gtk.Template.Child()
shop_pagestack = Gtk.Template.Child()
shop_listbox = Gtk.Template.Child()
shop_split_view = Gtk.Template.Child()
def __init__(self, **kwargs):
super().__init__(**kwargs)
init_settings_stack(self.settings_pagestack, self.settings_listbox, self.settings_split_view)
init_shop_stack(self.shop_pagestack, self.shop_listbox, self.shop_split_view)
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment