Tags and files fuctions

This commit is contained in:
Jan Doubravský
2025-09-24 14:30:23 +02:00
parent 9ea2da0d7f
commit 531b05cd03
14 changed files with 661 additions and 169 deletions

3
.gitignore vendored
View File

@@ -1,2 +1,3 @@
.venv
__pycache__
__pycache__
.pytest_cache

View File

@@ -1,3 +1,10 @@
{
"editor.rulers": [80]
"editor.rulers": [
80
],
"python.testing.pytestArgs": [
"tests"
],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true
}

BIN
Test.db3

Binary file not shown.

View File

@@ -0,0 +1,7 @@
{
"new": true,
"ignored": false,
"tags": [
"Rozlišení/4K"
]
}

BIN
data/samples/50.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

File diff suppressed because one or more lines are too long

View File

@@ -1,21 +1,18 @@
from pathlib import Path
import json
from .tag import Tag
class File():
def __init__(self, file_path: Path, tagmanager = None) -> None:
class File:
def __init__(self, file_path: Path, tagmanager=None) -> None:
self.file_path = file_path
self.metadata_filename = self.get_metadata_filename(self.file_path)
self.filename = file_path.name
self.metadata_filename = self.file_path.parent / f".{self.filename}.!tag"
self.new = True
self.ignored = False
self.tags = []
self.tagmanager = tagmanager
self.get_metadata()
def get_metadata_filename(self, file_path: Path) -> Path:
file_name = file_path.name
metadata_filename =Path(f".{file_name}.!tag")
return metadata_filename
def get_metadata(self) -> None:
if not self.metadata_filename.exists():
self.new = True
@@ -24,12 +21,12 @@ class File():
else:
self.load_metadata()
def save_metadata(self) -> None:
"""Save object state into metadata file as JSON."""
def save_metadata(self):
data = {
"new": self.new,
"ignored": self.ignored,
"tags": self.tags,
# ukládáme full_path tagů
"tags": [tag.full_path if isinstance(tag, Tag) else tag for tag in self.tags],
}
with open(self.metadata_filename, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
@@ -39,4 +36,13 @@ class File():
data = json.load(f)
self.new = data.get("new", True)
self.ignored = data.get("ignored", False)
self.tags = data.get("tags", [])
self.tags = []
if not self.tagmanager:
return
for tag_str in data.get("tags", []):
if "/" in tag_str:
category, name = tag_str.split("/", 1)
tag = self.tagmanager.add_tag(category, name)
self.tags.append(tag)

View File

@@ -1,30 +1,39 @@
# Imports
from pathlib import Path
from .file import File
from .tag_manager import TagManager
from .utils import list_files
class FileManager():
def __init__(self, tagmanager: TagManager) -> None:
class FileManager:
def __init__(self, tagmanager: TagManager):
self.filelist = []
self.folders = []
self.tagmanager = tagmanager
self.on_files_changed = None # callback do GUI
def append(self, folder: Path) -> None:
self.folders.append(folder)
for each in list_files(folder):
file = File(each, self.tagmanager)
self.filelist.append(file)
def list_files(folder_path: str | Path) -> list[Path]:
"""
Vrátí seznam Path objektů všech souborů uvnitř složky (rekurzivně).
if each.name.endswith(".!tag"): # ignoruj metadata soubory
continue
file_obj = File(each, self.tagmanager)
self.filelist.append(file_obj)
:param folder_path: cesta ke složce (string nebo Path)
:return: list objektů Path
"""
folder = Path(folder_path)
if not folder.is_dir():
raise NotADirectoryError(f"{folder} není platná složka.")
if self.on_files_changed:
self.on_files_changed(self.filelist)
return [file_path for file_path in folder.rglob("*") if file_path.is_file()]
def assign_tag_to_file_objects(self, files_objs: list[File], tag: str):
for f in files_objs:
# tag může být string nebo Tag
if isinstance(tag, str):
if "/" in tag:
category, name = tag.split("/", 1)
tag_obj = self.tagmanager.add_tag(category, name)
else:
tag_obj = tag
else:
tag_obj = tag
if tag_obj not in f.tags:
f.tags.append(tag_obj)
f.save_metadata()
if self.on_files_changed:
self.on_files_changed(self.filelist)

View File

@@ -0,0 +1,22 @@
class Tag:
def __init__(self, category: str, name: str):
self.category = category
self.name = name
@property
def full_path(self):
return f"{self.category}/{self.name}"
def __str__(self):
return self.full_path
def __repr__(self):
return f"Tag({self.full_path})"
def __eq__(self, other):
if isinstance(other, Tag):
return self.category == other.category and self.name == other.name
return False
def __hash__(self):
return hash((self.category, self.name))

View File

@@ -1,3 +1,36 @@
class TagManager():
from .tag import Tag
class TagManager:
def __init__(self):
pass
self.tags_by_category = {} # {category: set(Tag)}
def add_category(self, category: str):
if category not in self.tags_by_category:
self.tags_by_category[category] = set()
def remove_category(self, category: str):
if category in self.tags_by_category:
del self.tags_by_category[category]
def add_tag(self, category: str, name: str) -> Tag:
self.add_category(category)
tag = Tag(category, name)
self.tags_by_category[category].add(tag)
return tag
def remove_tag(self, category: str, name: str):
if category in self.tags_by_category:
tag = Tag(category, name)
self.tags_by_category[category].discard(tag)
if not self.tags_by_category[category]:
self.remove_category(category)
def get_all_tags(self):
"""Vrací list všech tagů full_path"""
return [tag.full_path for tags in self.tags_by_category.values() for tag in tags]
def get_categories(self):
return list(self.tags_by_category.keys())
def get_tags_in_category(self, category: str):
return list(self.tags_by_category.get(category, []))

7
src/core/utils.py Normal file
View File

@@ -0,0 +1,7 @@
from pathlib import Path
def list_files(folder_path: str | Path) -> list[Path]:
folder = Path(folder_path)
if not folder.is_dir():
raise NotADirectoryError(f"{folder} není platná složka.")
return [file_path for file_path in folder.rglob("*") if file_path.is_file()]

View File

@@ -1,32 +1,69 @@
# src/gui/gui.py
import os
import sys
import subprocess
import tkinter as tk
from tkinter import ttk, simpledialog, messagebox
from tkinter import ttk, simpledialog, messagebox, filedialog
from pathlib import Path
from src.core.image_handler import load_icon
from src.core.image import load_icon
from src.core.file_manager import FileManager
from src.core.tag_manager import TagManager
from src.core.file import File
from src.core.tag import Tag
class TagSelectionDialog(tk.Toplevel):
def __init__(self, parent, tags: list[str]):
super().__init__(parent)
self.title("Vyber tagy")
self.selected_tags = []
self.vars = {}
tk.Label(self, text="Vyber tagy k přiřazení:").pack(pady=5)
frame = tk.Frame(self)
frame.pack(padx=10, pady=5)
for tag in tags:
var = tk.BooleanVar(value=False)
chk = tk.Checkbutton(frame, text=tag, variable=var)
chk.pack(anchor="w")
self.vars[tag] = var
btn_frame = tk.Frame(self)
btn_frame.pack(pady=5)
tk.Button(btn_frame, text="OK", command=self.on_ok).pack(side="left", padx=5)
tk.Button(btn_frame, text="Cancel", command=self.destroy).pack(side="left", padx=5)
self.transient(parent)
self.grab_set()
parent.wait_window(self)
def on_ok(self):
self.selected_tags = [tag for tag, var in self.vars.items() if var.get()]
self.destroy()
class App:
def __init__(self, filehandler: FileManager):
self.states = {} # Tree states (checkbox on/off)
self.files = {} # Path -> set(tags), napojíš na SQLite3FileHandler
def __init__(self, filehandler: FileManager, tagmanager: TagManager):
self.states = {} # tree states (checkboxy)
self.listbox_map = {} # filename -> list[File]
self.selected_tree_item_for_context = None
self.selected_list_index_for_context = None
self.filehandler = filehandler
self.tagmanager = tagmanager
# callback z FileManageru
self.filehandler.on_files_changed = self.update_files_from_manager
# ==================================================
# MAIN GUI
# ==================================================
def main(self) -> None:
def main(self):
root = tk.Tk()
root.title("Tagger")
root.geometry("900x600")
self.root = root
# ---- Ikony (už připravené)
# ---- Ikony
unchecked = load_icon("src/resources/images/32/32_unchecked.png")
checked = load_icon("src/resources/images/32/32_checked.png")
self.icons = {"unchecked": unchecked, "checked": checked}
@@ -37,6 +74,8 @@ class App:
menu_bar = tk.Menu(root)
root.config(menu=menu_bar)
file_menu = tk.Menu(menu_bar, tearoff=0)
file_menu.add_command(label="Open Folder...", command=self.open_folder_dialog)
file_menu.add_separator()
file_menu.add_command(label="Exit", command=root.quit)
menu_bar.add_cascade(label="File", menu=file_menu)
@@ -53,7 +92,7 @@ class App:
self.tree.bind("<Button-3>", self.on_tree_right_click)
# ---- Listbox (right)
self.listbox = tk.Listbox(main_frame)
self.listbox = tk.Listbox(main_frame, selectmode="extended")
self.listbox.grid(row=0, column=1, sticky="nsew", padx=4, pady=4)
self.listbox.bind("<Double-1>", self.on_list_double)
self.listbox.bind("<Button-3>", self.on_list_right_click)
@@ -70,15 +109,237 @@ class App:
self.list_menu = tk.Menu(root, tearoff=0)
self.list_menu.add_command(label="Otevřít soubor", command=self.list_open_file)
self.list_menu.add_command(label="Smazat z indexu", command=self.list_remove_file)
self.list_menu.add_command(label="Assign Tag", command=self.assign_tag_to_selected)
# ---- Sample root node
root_id = self.tree.insert("", "end", text="Root", image=self.icons["unchecked"])
# ---- Root node
root_id = self.tree.insert("", "end", text="Štítky")
self.states[root_id] = False
self.tree.item(root_id, open=True)
self.root_id = root_id
# ⚡ refresh listbox při startu
self.update_files_from_manager(self.filehandler.filelist)
self.refresh_list()
root.mainloop()
# ==================================================
# FILE REFRESH + MAP
# ==================================================
def update_files_from_manager(self, filelist):
self.listbox.delete(0, "end")
self.listbox_map = {}
checked_tags = self.get_checked_full_tags()
for f in filelist:
# ignoruj metadata soubory
if f.file_path.name.endswith(".!tag") or f.file_path.name.startswith("."):
continue
if not checked_tags or checked_tags.issubset(set(f.tags)):
filename = f.file_path.name
self.listbox.insert("end", filename)
if filename not in self.listbox_map:
self.listbox_map[filename] = []
self.listbox_map[filename].append(f)
self.status_bar.config(text=f"Zobrazeno {self.listbox.size()} položek")
def get_selected_files_objects(self):
indices = self.listbox.curselection()
files = []
for idx in indices:
filename = self.listbox.get(idx)
files.extend(self.listbox_map.get(filename, []))
return files
# ==================================================
# ASSIGN TAG
# ==================================================
def assign_tag_to_selected(self):
files = self.get_selected_files_objects()
if not files:
self.status_bar.config(text="Nebyly vybrány žádné soubory")
return
# generujeme seznam tagů přímo z TagManageru (aktuální)
all_tags = []
for category in self.tagmanager.get_categories():
for tag in self.tagmanager.get_tags_in_category(category):
all_tags.append(f"{category}/{tag.name}") # vždy aktuální seznam
if not all_tags:
messagebox.showwarning("Chyba", "Žádné tagy nejsou definovány")
return
# vytvoříme dialog **po načtení všech tagů**
dialog = TagSelectionDialog(self.root, all_tags)
selected_tags = dialog.selected_tags
if not selected_tags:
self.status_bar.config(text="Nebyl vybrán žádný tag")
return
# přiřazení tagů souborům
for full_tag in selected_tags:
if "/" in full_tag:
category, name = full_tag.split("/", 1)
self.filehandler.assign_tag_to_file_objects(files, Tag(category, name))
self.update_files_from_manager(self.filehandler.filelist)
self.status_bar.config(text=f"Přiřazeny tagy: {', '.join(selected_tags)}")
# ==================================================
# DOUBLE CLICK OPEN
# ==================================================
def on_list_double(self, event):
for f in self.get_selected_files_objects():
self.open_file(f.file_path)
# ==================================================
# OPEN FILE
# ==================================================
def open_file(self, path):
try:
if sys.platform.startswith("win"):
os.startfile(path)
elif sys.platform.startswith("darwin"):
subprocess.call(["open", path])
else:
subprocess.call(["xdg-open", path])
self.status_bar.config(text=f"Otevírám: {path}")
except Exception as e:
messagebox.showerror("Chyba", f"Nelze otevřít {path}: {e}")
# ==================================================
# LIST CONTEXT MENU
# ==================================================
def on_list_right_click(self, event):
idx = self.listbox.nearest(event.y)
if idx is None:
return
self.selected_list_index_for_context = idx
self.listbox.selection_clear(0, "end")
self.listbox.selection_set(idx)
self.list_menu.tk_popup(event.x_root, event.y_root)
def list_open_file(self):
for f in self.get_selected_files_objects():
self.open_file(f.file_path)
def list_remove_file(self):
files = self.get_selected_files_objects()
if not files:
return
ans = messagebox.askyesno("Smazat z indexu", f"Odstranit {len(files)} souborů z indexu?")
if ans:
for f in files:
if f in self.filehandler.filelist:
self.filehandler.filelist.remove(f)
self.update_files_from_manager(self.filehandler.filelist)
self.status_bar.config(text=f"Odstraněno {len(files)} souborů z indexu")
# ==================================================
# OPEN FOLDER
# ==================================================
def open_folder_dialog(self):
folder = filedialog.askdirectory(title="Vyber složku pro sledování")
if not folder:
return
folder_path = Path(folder)
try:
self.filehandler.append(folder_path)
# po načtení propíš tagy z metadat do TagManageru
for f in self.filehandler.filelist:
if f.tags and f.tagmanager:
for t in f.tags: # t je Tag
f.tagmanager.add_tag(t.category, t.name)
self.status_bar.config(text=f"Přidána složka: {folder_path}")
self.refresh_tree_tags()
except Exception as e:
messagebox.showerror("Chyba", f"Nelze přidat složku {folder}: {e}")
# ==================================================
# TREE EVENTS
# ==================================================
def on_tree_left_click(self, event):
region = self.tree.identify("region", event.x, event.y)
if region not in ("tree", "icon"):
return
item_id = self.tree.identify_row(event.y)
if not item_id:
return
item_text = self.tree.item(item_id, "text")
parent_id = self.tree.parent(item_id)
if parent_id == "":
# root nebo kategorie → jen toggle open/close
is_open = self.tree.item(item_id, "open")
self.tree.item(item_id, open=not is_open)
return
# pokud je to tag → toggle checkbox
self.states[item_id] = not self.states.get(item_id, False)
self.tree.item(item_id, image=self.icons["checked"] if self.states[item_id] else self.icons["unchecked"])
self.status_bar.config(text=f"Tag {'checked' if self.states[item_id] else 'unchecked'}: {self.build_full_tag(item_id)}")
self.update_files_from_manager(self.filehandler.filelist)
def on_tree_right_click(self, event):
item_id = self.tree.identify_row(event.y)
if item_id:
self.selected_tree_item_for_context = item_id
self.tree.selection_set(item_id)
self.tree_menu.tk_popup(event.x_root, event.y_root)
else:
menu = tk.Menu(self.root, tearoff=0)
menu.add_command(label="Nový top-level tag", command=lambda: self.tree_add_tag(background=True))
menu.tk_popup(event.x_root, event.y_root)
# ==================================================
# TREE TAG CRUD
# ==================================================
def tree_add_tag(self, background=False):
name = simpledialog.askstring("Nový tag", "Název tagu:")
if not name:
return
parent = self.selected_tree_item_for_context if not background else self.root_id
new_id = self.tree.insert(parent, "end", text=name)
self.states[new_id] = False
# ⚡ aktualizace TagManageru
if parent == self.root_id:
category = name
self.tagmanager.add_category(category)
else:
# tag pod existující kategorií
category = self.tree.item(parent, "text")
self.tagmanager.add_tag(category, name)
self.status_bar.config(text=f"Vytvořen tag: {self.build_full_tag(new_id)}")
def tree_delete_tag(self):
item = self.selected_tree_item_for_context
if not item:
return
full = self.build_full_tag(item)
ans = messagebox.askyesno("Smazat tag", f"Opravdu chcete smazat '{full}'?")
if not ans:
return
tag_name = self.tree.item(item, "text")
parent_id = self.tree.parent(item)
self.tree.delete(item)
self.states.pop(item, None)
# odebrání z TagManageru
if parent_id == self.root_id:
self.tagmanager.remove_category(tag_name)
else:
category = self.tree.item(parent_id, "text")
self.tagmanager.remove_tag(category, tag_name)
self.update_files_from_manager(self.filehandler.filelist)
self.status_bar.config(text=f"Smazán tag: {full}")
# ==================================================
# TREE HELPERS
# ==================================================
@@ -94,108 +355,18 @@ class App:
def get_checked_full_tags(self):
return {self.build_full_tag(i) for i, v in self.states.items() if v}
# ==================================================
# TREE EVENTS
# ==================================================
def on_tree_left_click(self, event):
region = self.tree.identify("region", event.x, event.y)
if region in ("tree", "icon"):
item_id = self.tree.identify_row(event.y)
if item_id:
self.states[item_id] = not self.states.get(item_id, False)
self.tree.item(item_id, image=self.icons["checked"] if self.states[item_id] else self.icons["unchecked"])
self.status_bar.config(text=f"Tag {'checked' if self.states[item_id] else 'unchecked'}: {self.build_full_tag(item_id)}")
self.refresh_list()
def refresh_tree_tags(self):
# smažeme všechny pod root
for child in self.tree.get_children(self.root_id):
self.tree.delete(child)
def on_tree_right_click(self, event):
item_id = self.tree.identify_row(event.y)
if item_id:
self.selected_tree_item_for_context = item_id
self.tree.selection_set(item_id)
self.tree_menu.tk_popup(event.x_root, event.y_root)
else:
# klik do prázdna = nabídka top-level tagu
self.selected_tree_item_for_context = None
menu = tk.Menu(self.root, tearoff=0)
menu.add_command(label="Nový top-level tag", command=lambda: self.tree_add_tag(background=True))
menu.tk_popup(event.x_root, event.y_root)
# projdeme všechny kategorie a tagy
for category in self.tagmanager.get_categories():
cat_id = self.tree.insert(self.root_id, "end", text=category)
self.states[cat_id] = False
for tag in self.tagmanager.get_tags_in_category(category):
tag_id = self.tree.insert(cat_id, "end", text=tag.name, image=self.icons["unchecked"])
self.states[tag_id] = False
def tree_add_tag(self, background=False):
name = simpledialog.askstring("Nový tag", "Název tagu:")
if not name:
return
parent = self.selected_tree_item_for_context if not background else ""
new_id = self.tree.insert(parent, "end", text=name, image=self.icons["unchecked"])
self.states[new_id] = False
self.status_bar.config(text=f"Vytvořen tag: {self.build_full_tag(new_id)}")
def tree_delete_tag(self):
item = self.selected_tree_item_for_context
if not item:
return
full = self.build_full_tag(item)
ans = messagebox.askyesno("Smazat tag", f"Opravdu chcete smazat '{full}'?")
if not ans:
return
self.tree.delete(item)
self.states.pop(item, None)
self.refresh_list()
self.status_bar.config(text=f"Smazán tag: {full}")
# ==================================================
# LIST EVENTS
# ==================================================
def on_list_double(self, event):
index = self.listbox.curselection()
if index:
self.open_file(self.listbox.get(index[0]))
def on_list_right_click(self, event):
idx = self.listbox.nearest(event.y)
if idx is None:
return
self.selected_list_index_for_context = idx
self.listbox.selection_clear(0, "end")
self.listbox.selection_set(idx)
self.list_menu.tk_popup(event.x_root, event.y_root)
def list_open_file(self):
idx = self.selected_list_index_for_context
if idx is not None:
self.open_file(self.listbox.get(idx))
def list_remove_file(self):
idx = self.selected_list_index_for_context
if idx is not None:
path = self.listbox.get(idx)
ans = messagebox.askyesno("Smazat z indexu", f"Odstranit '{path}' z indexu?")
if ans and path in self.files:
del self.files[path]
self.refresh_list()
self.status_bar.config(text=f"Odstraněno z indexu: {path}")
# ==================================================
# FILE LIST REFRESH
# ==================================================
def refresh_list(self):
checked = self.get_checked_full_tags()
self.listbox.delete(0, "end")
for path, tags in self.files.items():
if not checked or checked.issubset(tags):
self.listbox.insert("end", path)
self.status_bar.config(text=f"Zobrazeno {self.listbox.size()} položek")
# ==================================================
# OPEN FILE
# ==================================================
def open_file(self, path):
try:
if sys.platform.startswith("win"):
os.startfile(path)
elif sys.platform.startswith("darwin"):
subprocess.call(["open", path])
else:
subprocess.call(["xdg-open", path])
self.status_bar.config(text=f"Otevírám: {path}")
except Exception as e:
messagebox.showerror("Chyba", f"Nelze otevřít {path}: {e}")
# otevřeme root
self.tree.item(self.root_id, open=True)

View File

@@ -1,21 +0,0 @@
import sys, os
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from src.core.SQL_handler import SQLite3FileHandler
from pathlib import Path
db_path = Path("Test.db3")
test = SQLite3FileHandler(db_path=db_path)
try:
test.insert_tag("test")
except:
pass
try:
test.insert_category("Test")
except:
pass
test.insert_relation_tag_cat(4, 7)
print(test.fetch_all("RELATIONS"))
test.close()

40
tests/test_image.py Normal file
View File

@@ -0,0 +1,40 @@
import sys, os
import tempfile
from pathlib import Path
import pytest
# přidáme src do sys.path (pokud nespouštíš pytest s -m nebo PYTHONPATH=src)
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src")))
from core.image import load_icon
from PIL import Image, ImageTk
import tkinter as tk
@pytest.fixture(scope="module")
def tk_root():
"""Fixture pro inicializaci Tkinteru (nutné pro ImageTk)."""
root = tk.Tk()
yield root
root.destroy()
def test_load_icon_returns_photoimage(tk_root):
# vytvoříme dočasný obrázek
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
tmp_path = Path(tmp.name)
try:
# vytvoříme 100x100 červený obrázek
img = Image.new("RGB", (100, 100), color="red")
img.save(tmp_path)
icon = load_icon(tmp_path)
# musí být PhotoImage
assert isinstance(icon, ImageTk.PhotoImage)
# ověříme velikost 16x16
assert icon.width() == 16
assert icon.height() == 16
finally:
tmp_path.unlink(missing_ok=True)