diff --git a/amcr_viewer/amcr_codelists.py b/amcr_viewer/amcr_codelists.py index 268d0fd..e138c5d 100644 --- a/amcr_viewer/amcr_codelists.py +++ b/amcr_viewer/amcr_codelists.py @@ -1,64 +1,60 @@ # -*- coding: utf-8 -*- import os import csv -import codecs import requests -import json -# Cesta k adresáři pluginu +# Define paths for the plugin and its codelists directory PLUGIN_DIR = os.path.dirname(__file__) CODELISTS_DIR = os.path.join(PLUGIN_DIR, 'codelists') def ensure_codelists_dir(): + """Creates the codelists directory if it does not exist.""" if not os.path.exists(CODELISTS_DIR): os.makedirs(CODELISTS_DIR) -# --- 1. NAČÍTÁNÍ DAT --- - -def load_csv_data(filename): - """Obecná funkce pro načtení CSV souboru do slovníku""" - data = {} +def parse_codelist_file(filename, target_dict=None): + """Reads a CSV codelist file and populates the target dictionary grouped by categories.""" + if target_dict is None: + target_dict = {} + path = os.path.join(CODELISTS_DIR, filename) - if not os.path.exists(path): - return data - + + # Return early if the file doesn't exist to avoid missing file errors + if not os.path.exists(path): + return target_dict + try: - with codecs.open(path, 'r', 'utf-8') as f: + # Open the file using standard UTF-8 encoding + with open(path, 'r', encoding='utf-8') as f: reader = csv.reader(f, delimiter=';') - # Zkusíme přeskočit hlavičku, pokud tam je - first_row = next(reader, None) - # Pokud soubor není prázdný, zpracujeme ho - if first_row: - # Pokud první řádek vypadá jako data (neobsahuje slovo "Název"), vrátíme ho do hry - # Ale my budeme generovat soubory s hlavičkou, takže OK. - pass - + # Skip the CSV header row + next(reader, None) + + # Iterate through rows and extract label, code, and category for row in reader: if len(row) >= 3: label = row[0].strip() code = row[1].strip() - category = row[2].strip() + cat = row[2].strip() + clean = code if code else None - # Tady můžeme filtrovat podle kategorie, - # nebo prostě vrátit všechno jako {label: code} - # Pro jednoduchost vracíme {label: code} - clean_code = code if code else None - data[label] = clean_code + # Initialize a new dictionary for a category if encountered for the first time + if cat not in target_dict: + target_dict[cat] = {} + + # Assign the extracted code to the corresponding label within the category + target_dict[cat][label] = clean except Exception as e: - print(f"AMČR Chyba čtení {filename}: {e}") + print(f"AMČR Codelist Read Error for {filename}: {e}") - return data + return target_dict def load_all_data(): - """ - Načte statický heslář I dynamický heslář vedoucích. - Vrací slovník slovníků. - """ + """Loads all static and dynamic codelists during plugin startup.""" ensure_codelists_dir() - # 1. Načteme hlavní statický heslář - # Musíme ho rozparsovat podle kategorií, tak jak to bylo předtím + # Initialize the base structure with empty dictionaries for all expected categories categorized_data = { 'obdobi': {}, 'typ_akce': {}, 'areal': {}, 'kraj': {}, 'organizace': {}, 'okres': {}, 'katastr': {}, @@ -66,77 +62,50 @@ def load_all_data(): 'jistota': {}, 'lokalita_zachovalost': {} } - # Funkce pro roztřídění načteného slovníku (tohle je trochu redundance, ale pro zachování logiky) - def parse_file(filename): - path = os.path.join(CODELISTS_DIR, filename) - if not os.path.exists(path): return - - try: - with codecs.open(path, 'r', 'utf-8') as f: - reader = csv.reader(f, delimiter=';') - next(reader, None) # Skip header - for row in reader: - if len(row) >= 3: - label = row[0].strip() - code = row[1].strip() - cat = row[2].strip() - clean = code if code else None - - if cat in categorized_data: - categorized_data[cat][label] = clean - except: pass - - # Načteme soubory - parse_file('heslar.csv') # Statické - parse_file('vedouci.csv') # Dynamické (pokud existuje) + # Parse the default static codelist and the dynamically generated leaders codelist + parse_codelist_file('heslar.csv', categorized_data) + parse_codelist_file('vedouci.csv', categorized_data) return categorized_data -# --- 2. AKTUALIZACE DAT (DOWNLOAD) --- - def download_vedouci(): - """ - Stáhne seznam vedoucích z API (pomocí onlyFacets) a uloží do codelists/vedouci.csv. - """ + """Fetches the list of leaders from the AMČR API and saves it to a CSV file.""" ensure_codelists_dir() - # Tvá URL + pojistka, abychom dostali všechny záznamy (limit -1) + # API endpoint for fetching facet data for leaders url = "https://digiarchiv.aiscr.cz/api/search/query?entity=akce&sort=datestamp%20desc&page=0&onlyFacets=True&rows=0" try: - r = requests.get(url, timeout=20) # Raději delší timeout pro velký seznam + # Execute the GET request with a 20-second timeout + r = requests.get(url, timeout=20) r.raise_for_status() data = r.json() - # Cesta k datům dle tvého JSONu: - # {"facet_counts": { "f_vedouci": [ {"name": "Novák", ...}, ... ] }} + # Extract the leaders list from the JSON response using safe dict getters vedouci_list = data.get('facet_counts', {}).get('f_vedouci', []) - if not vedouci_list: - # Zkusíme ještě alternativní cestu, kdyby API vrátilo standardní Solr strukturu - # (facet_counts -> facet_fields -> f_vedouci) vedouci_list = data.get('facet_counts', {}).get('facet_fields', {}).get('f_vedouci', []) csv_path = os.path.join(CODELISTS_DIR, 'vedouci.csv') count = 0 - with codecs.open(csv_path, 'w', 'utf-8') as f: + + # Open the target CSV file for writing without extra blank lines + with open(csv_path, 'w', encoding='utf-8', newline='') as f: writer = csv.writer(f, delimiter=';') + + # Write the standard header required by the parser function writer.writerow(['Název', 'Kód', 'Kategorie']) - # NOVÁ LOGIKA PARSOVÁNÍ + # Iterate through the API results and format them for the CSV for item in vedouci_list: name = None - - # Varianta A: Položka je slovník {"name": "Jan Novák", "value": 10} if isinstance(item, dict): name = item.get('name') - - # Varianta B: Položka je jen string (kdyby se API vrátilo k plochému seznamu) elif isinstance(item, str): name = item - # Pokud máme jméno a není to číslo (count), zapíšeme + # Ignore pure numbers (which are usually counts) and write valid names if name and not str(name).isdigit(): writer.writerow([name, name, 'vedouci']) count += 1 @@ -146,33 +115,32 @@ def download_vedouci(): except Exception as e: return False, str(e) -# --- GLOBAL DATA --- -# Toto se načte při startu QGISu +# Initialize global codelist data when the module is imported _DATA = load_all_data() -OBDOBI = _DATA['obdobi'] -TYP_AKCE = _DATA['typ_akce'] -AREAL = _DATA['areal'] -KRAJE = _DATA['kraj'] -ORGANIZACE = _DATA['organizace'] -OKRESY = _DATA['okres'] -KATASTRY = _DATA['katastr'] -VEDOUCI = _DATA['vedouci'] -PIAN_PRESNOST = _DATA['pian_presnost'] -TYP_LOKALITY = _DATA['typ_lokality'] -DRUH_LOKALITY = _DATA['druh_lokality'] -JISTOTA = _DATA['jistota'] -LOKALITA_ZACHOVALOST = _DATA['lokalita_zachovalost'] +# Safely extract individual categories into global variables for easy access across the plugin +OBDOBI = _DATA.get('obdobi', {}) +TYP_AKCE = _DATA.get('typ_akce', {}) +AREAL = _DATA.get('areal', {}) +KRAJE = _DATA.get('kraj', {}) +ORGANIZACE = _DATA.get('organizace', {}) +OKRESY = _DATA.get('okres', {}) +KATASTRY = _DATA.get('katastr', {}) +VEDOUCI = _DATA.get('vedouci', {}) +PIAN_PRESNOST = _DATA.get('pian_presnost', {}) +TYP_LOKALITY = _DATA.get('typ_lokality', {}) +DRUH_LOKALITY = _DATA.get('druh_lokality', {}) +JISTOTA = _DATA.get('jistota', {}) +LOKALITA_ZACHOVALOST = _DATA.get('lokalita_zachovalost', {}) def refresh_vedouci_cache(): - """ - Znovu načte soubor vedouci.csv a aktualizuje globální proměnnou VEDOUCI. - Použijeme 'update', aby se zachovala reference na objekt (pokud ho dialog už používá). - """ - temp_data = load_all_data() - new_vedouci = temp_data['vedouci'] + """Reloads only the 'vedouci.csv' file to quickly update the cache without full initialization.""" + # Parse only the targeted file containing the updated leaders + temp_data = parse_codelist_file('vedouci.csv') + new_vedouci = temp_data.get('vedouci', {}) - # Vyčistíme a naplníme existující slovník (in-place update) + # Clear the existing global dictionary and update it with the fresh data VEDOUCI.clear() VEDOUCI.update(new_vedouci) + return len(VEDOUCI) \ No newline at end of file diff --git a/amcr_viewer/amcr_dialog.py b/amcr_viewer/amcr_dialog.py index 433151b..87336da 100644 --- a/amcr_viewer/amcr_dialog.py +++ b/amcr_viewer/amcr_dialog.py @@ -11,40 +11,62 @@ from .amcr_codelists import (OBDOBI, TYP_AKCE, KRAJE, AREAL, ORGANIZACE, download_vedouci, refresh_vedouci_cache) class FilterableSelectionDialog(QDialog): + """ + A custom dialog for selecting multiple items from a list with a search filter. + """ def __init__(self, title, data_dict, preselected_codes, parent=None): super().__init__(parent) self.setWindowTitle(f"Výběr: {title}") self.resize(400, 500) + + # Store the source data and previously selected items self.data_dict = data_dict self.preselected = preselected_codes if preselected_codes else [] + layout = QVBoxLayout() + + # Setup search input for filtering items self.search_bar = QLineEdit() self.search_bar.setPlaceholderText("Hledat v seznamu...") self.search_bar.textChanged.connect(self.filter_list) layout.addWidget(self.search_bar) + + # Main list widget for displaying selectable items self.list_widget = QListWidget() self.populate_list() layout.addWidget(self.list_widget) + + # Standard OK/Cancel dialog buttons buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) buttons.accepted.connect(self.accept) buttons.rejected.connect(self.reject) layout.addWidget(buttons) + self.setLayout(layout) def populate_list(self): + # Sort items alphabetically by their display name sorted_names = sorted(self.data_dict.keys()) for name in sorted_names: code = self.data_dict[name] item = QListWidgetItem(name) + + # Store the actual code (ID) hidden in the UserRole item.setData(Qt.UserRole, code) + + # Make the item checkable (adds a checkbox) item.setFlags(item.flags() | Qt.ItemIsUserCheckable) + + # Restore previous selection state if code in self.preselected: item.setCheckState(Qt.Checked) else: item.setCheckState(Qt.Unchecked) + self.list_widget.addItem(item) def filter_list(self, text): + # Hide items that don't match the search text (case-insensitive) search_text = text.lower() for i in range(self.list_widget.count()): item = self.list_widget.item(i) @@ -54,6 +76,7 @@ class FilterableSelectionDialog(QDialog): item.setHidden(False) def get_selected_codes(self): + """Returns the hidden codes and display labels of all checked items.""" codes = [] labels = [] for i in range(self.list_widget.count()): @@ -66,15 +89,20 @@ class FilterableSelectionDialog(QDialog): # --- Main window --- class AmcrFilterDialog(QDialog): + """ + The main filtering UI where users set criteria before downloading data. + """ def __init__(self, typ_dat, parent=None): super(AmcrFilterDialog, self).__init__(parent) self.setWindowTitle("Filtr AMČR") self.resize(500, 750) + + # Determines if we are fetching 'akce' (projects) or 'lokalita' (locations) self.typ_dat = typ_dat - # Cache for filtering + # Cache dictionary to store selected codes for each category self.selection_cache = { 'organizace': [], 'kraj': [], 'obdobi': [], 'areal': [], 'typ_akce': [], 'okres': [], 'katastr': [], 'vedouci': [], 'pian_presnost': [], @@ -83,6 +111,7 @@ class AmcrFilterDialog(QDialog): layout = QVBoxLayout() + # Filter by current map canvas extent self.chk_bbox = QCheckBox("Omezit vyhledávání rozsahem okna") self.chk_bbox.setChecked(True) layout.addWidget(self.chk_bbox) @@ -94,14 +123,91 @@ class AmcrFilterDialog(QDialog): layout.addWidget(self.chk_posevidence) layout.addSpacing(10) + + # Spatial information – valid for all - def setup_picker(label_text, cache_key, data_source, extra_btn=None): + self.picker_kraj = self.setup_picker("Kraj", 'kraj', KRAJE) + layout.addWidget(self.picker_kraj) + + self.picker_okres = self.setup_picker("Okres", 'okres', OKRESY) + layout.addWidget(self.picker_okres) + + self.picker_katastr = self.setup_picker("Katastr", 'katastr', KATASTRY) + layout.addWidget(self.picker_katastr) + + self.picker_presnost = self.setup_picker("PIAN – přesnost", 'pian_presnost', PIAN_PRESNOST) + layout.addWidget(self.picker_presnost) + + # Filters valid for Akce + + if self.typ_dat == "akce": + self.picker_org = self.setup_picker("Organizace", 'organizace', ORGANIZACE) + layout.addWidget(self.picker_org) + + # Button to fetch fresh project leaders from the API + self.btn_update_vedouci = QPushButton("🔄") + self.btn_update_vedouci.setToolTip("Aktualizovat seznam vedoucích z API") + self.btn_update_vedouci.setFixedWidth(30) + self.btn_update_vedouci.clicked.connect(self.action_update_vedouci) + + self.picker_vedouci = self.setup_picker("Vedoucí výzkumu", 'vedouci', VEDOUCI, extra_btn=self.btn_update_vedouci) + layout.addWidget(self.picker_vedouci) + + # Type of event + + self.picker_typ = self.setup_picker("Typ výzkumu", 'typ_akce', TYP_AKCE) + layout.addWidget(self.picker_typ) + + # Filters valid for Lokality + + if self.typ_dat == "lokalita": + self.picker_typ_lokality = self.setup_picker("Lokalita – typ", 'typ_lokality', TYP_LOKALITY) + layout.addWidget(self.picker_typ_lokality) + + self.picker_druh_lokality = self.setup_picker("Lokalita – druh", 'druh_lokality', DRUH_LOKALITY) + layout.addWidget(self.picker_druh_lokality) + + self.picker_jistota = self.setup_picker("Lokalita – jistota určení", 'jistota', JISTOTA) + layout.addWidget(self.picker_jistota) + + self.picker_lokalita_zachovalost = self.setup_picker("Lokalita - stav dochování", 'lokalita_zachovalost', LOKALITA_ZACHOVALOST) + layout.addWidget(self.picker_lokalita_zachovalost) + + # Contextual information + + self.picker_obdobi = self.setup_picker("Období", 'obdobi', OBDOBI) + layout.addWidget(self.picker_obdobi) + + self.picker_areal = self.setup_picker("Areál", 'areal', AREAL) + layout.addWidget(self.picker_areal) + + # Option to download related components table + self.chk_komponenty = QCheckBox("Načíst komponenty") + layout.addWidget(self.chk_komponenty) + + # Pushes everything above to the top + layout.addStretch(1) + + # Main dialog OK/Cancel buttons + buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + self.setLayout(layout) + + def setup_picker(self, label_text, cache_key, data_source, extra_btn=None): + """ + Creates a reusable UI component consisting of a label, a read-only + text field showing selected items, and a button to open the selection dialog. + """ row_widget = QGroupBox(label_text) # row_widget.setFlat(True) row_layout = QHBoxLayout() row_layout.setContentsMargins(5, 5, 5, 5) + # Read-only field displaying the names of selected items display_field = QLineEdit() display_field.setReadOnly(True) display_field.setPlaceholderText("Nic nevybráno (vše)") @@ -110,16 +216,22 @@ class AmcrFilterDialog(QDialog): btn = QPushButton("Vybrat...") btn.setFixedWidth(80) + # Nested function that handles opening the dialog and saving results def open_dialog(): dlg = FilterableSelectionDialog(label_text, data_source, self.selection_cache[cache_key], self) - if dlg.exec_() == QDialog.Accepted: + if dlg.exec() == QDialog.Accepted: codes, labels = dlg.get_selected_codes() + + # Update local cache with selected IDs self.selection_cache[cache_key] = codes + + # Update the UI text field with selected names if labels: display_field.setText(", ".join(labels)) else: display_field.clear() + # Special case: Pre-fill specific accuracy levels by default if cache_key == 'pian_presnost': display_field.setText("odchylka jednotky metrů, odchylka desítky metrů, odchylka stovky metrů") self.selection_cache[cache_key] = ['HES-000861', 'HES-000862', 'HES-000863'] @@ -129,94 +241,28 @@ class AmcrFilterDialog(QDialog): row_layout.addWidget(display_field) row_layout.addWidget(btn) + # Add an optional extra button (e.g., the refresh button for leaders) if extra_btn: row_layout.addWidget(extra_btn) row_widget.setLayout(row_layout) return row_widget - # Spatial information – valid for all - - self.picker_kraj = setup_picker("Kraj", 'kraj', KRAJE) - layout.addWidget(self.picker_kraj) - - self.picker_okres = setup_picker("Okres", 'okres', OKRESY) - layout.addWidget(self.picker_okres) - - self.picker_katastr = setup_picker("Katastr", 'katastr', KATASTRY) - layout.addWidget(self.picker_katastr) - - self.picker_presnost = setup_picker("PIAN – přesnost", 'pian_presnost', PIAN_PRESNOST) - layout.addWidget(self.picker_presnost) - - # Filters valid for Akce - - if self.typ_dat == "akce": - self.picker_org = setup_picker("Organizace", 'organizace', ORGANIZACE) - layout.addWidget(self.picker_org) - - self.btn_update_vedouci = QPushButton("🔄") - self.btn_update_vedouci.setToolTip("Aktualizovat seznam vedoucích z API") - self.btn_update_vedouci.setFixedWidth(30) - self.btn_update_vedouci.clicked.connect(self.action_update_vedouci) - - self.picker_vedouci = setup_picker("Vedoucí výzkumu", 'vedouci', VEDOUCI, extra_btn=self.btn_update_vedouci) - layout.addWidget(self.picker_vedouci) - - # Type of event - - self.picker_typ = setup_picker("Typ výzkumu", 'typ_akce', TYP_AKCE) - layout.addWidget(self.picker_typ) - - # Filters valid for Lokality - - if self.typ_dat == "lokalita": - self.picker_typ_lokality = setup_picker("Lokalita – typ", 'typ_lokality', TYP_LOKALITY) - layout.addWidget(self.picker_typ_lokality) - - self.picker_druh_lokality = setup_picker("Lokalita – druh", 'druh_lokality', DRUH_LOKALITY) - layout.addWidget(self.picker_druh_lokality) - - self.picker_jistota = setup_picker("Lokalita – jistota určení", 'jistota', JISTOTA) - layout.addWidget(self.picker_jistota) - - self.picker_lokalita_zachovalost = setup_picker("Lokalita - stav dochování", 'lokalita_zachovalost', LOKALITA_ZACHOVALOST) - layout.addWidget(self.picker_lokalita_zachovalost) - - # Contextual information - - self.picker_obdobi = setup_picker("Období", 'obdobi', OBDOBI) - layout.addWidget(self.picker_obdobi) - - self.picker_areal = setup_picker("Areál", 'areal', AREAL) - layout.addWidget(self.picker_areal) - - self.chk_komponenty = QCheckBox("Načíst komponenty") - layout.addWidget(self.chk_komponenty) - - layout.addStretch(1) - - buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) - buttons.accepted.connect(self.accept) - buttons.rejected.connect(self.reject) - layout.addWidget(buttons) - - self.setLayout(layout) - def action_update_vedouci(self): + # Change cursor to loading state to indicate background task QApplication.setOverrideCursor(Qt.WaitCursor) try: success, msg = download_vedouci() if success: count = refresh_vedouci_cache() - QApplication.restoreOverrideCursor() QMessageBox.information(self, "Úspěch", f"{msg}\nNyní je v paměti {count} osob.") else: - QApplication.restoreOverrideCursor() QMessageBox.warning(self, "Chyba", f"Nepodařilo se stáhnout data:\n{msg}") except Exception as e: - QApplication.restoreOverrideCursor() QMessageBox.critical(self, "Chyba", str(e)) + finally: + # Safely restore the normal cursor even if an error occurs + QApplication.restoreOverrideCursor() def get_bbox(self): return "true" if self.chk_bbox.isChecked() else "false" @@ -225,6 +271,7 @@ class AmcrFilterDialog(QDialog): return "true" if self.chk_komponenty.isChecked() else "false" def get_filters(self): + """Compiles the user selections from the cache into API-ready filter parameters.""" filters = {} if self.selection_cache['kraj']: diff --git a/amcr_viewer/amcr_tools.py b/amcr_viewer/amcr_tools.py index 2b6d3a6..7bd237c 100644 --- a/amcr_viewer/amcr_tools.py +++ b/amcr_viewer/amcr_tools.py @@ -11,11 +11,11 @@ import json import xml.etree.ElementTree as ET import re -# Global translations cache +# Global cache to store translated terms from the Digital Archive TRANSLATIONS = {} -# Download Digiarchive's vocabulary def load_translations(): + """Fetches the official Czech translation dictionary from the AISCR API.""" global TRANSLATIONS if TRANSLATIONS: return @@ -26,22 +26,32 @@ def load_translations(): if r.status_code == 200: TRANSLATIONS = r.json() except Exception as e: - print(f"Chyba při stahování hesláře: {e}") + print(f"Error downloading vocabulary: {e}") def tr_code(code): + """Translates a technical code into a human-readable string using the global cache.""" if not code: return "" return TRANSLATIONS.get(code, code) def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false"): + """ + Main processing function: + 1. Determines search area (Bounding Box) + 2. Fetches metadata and geometries from API + 3. Creates QGIS memory layers and populates them with features + """ load_translations() - # 1. Bounding box + # --- 1. COORDINATE TRANSFORMATION --- + # Get current map extent and transform it from project CRS (usually S-JTSK) to WGS-84 for the API extent = canvas.extent() crs_src = canvas.mapSettings().destinationCrs() crs_dest = QgsCoordinateReferenceSystem("EPSG:4326") xform = QgsCoordinateTransform(crs_src, crs_dest, QgsProject.instance()) extent_wgs = xform.transformBoundingBox(extent) + + # Format the bounding box string as required by the API: minLat,minLon,maxLat,maxLon bbox_str = f"{extent_wgs.yMinimum()},{extent_wgs.xMinimum()},{extent_wgs.yMaximum()},{extent_wgs.xMaximum()}" url = "https://digiarchiv.aiscr.cz/api/search/query" @@ -50,21 +60,21 @@ def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false") QApplication.setOverrideCursor(Qt.WaitCursor) try: - # =================== - # A) METADATA (Fieldwork event/Site) - # =================== + # ========================================== + # A) METADATA FETCHING (Fieldwork/Site) + # ========================================== base_params = { "mapa": "true", - "sort": "ident_cely asc" + "sort": "ident_cely asc", + "entity": typ_dat } - base_params["entity"] = typ_dat - + # Restrict search to map window if requested if bb == "true": base_params["loc_rpt"] = bbox_str - # Apply filters + # Apply multi-select filters from the dialog using the ':or' syntax required by the API if filters: for key, value in filters.items(): if not value: @@ -76,13 +86,17 @@ def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false") docs = [] current_page = 0 - BATCH_DOCS = 500 - MAX_LIMIT = 20000 - feats_k = [] + BATCH_DOCS = 500 # Records per API request + MAX_LIMIT = 20000 # Safety limit to prevent QGIS from freezing + feats_k = [] # List for component features (non-spatial) seen_ids = set() target_pian_ids_count = 0 + + # Check if we should skip negative results based on filter + skip_negativni = filters.get('posevidence') == 'true' if filters else False + # --- API PAGINATION LOOP --- while True: base_params['rows'] = BATCH_DOCS if current_page > 0: @@ -100,6 +114,7 @@ def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false") if not batch_docs: break + # Filter out duplicates and append to main list new_docs = [] for d in batch_docs: ident = d.get('ident_cely') @@ -117,7 +132,7 @@ def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false") break current_page += 1 - QApplication.processEvents() + QApplication.processEvents() # Keep UI responsive except Exception as e: print(f"Chyba při stránkování na straně {current_page}: {e}") @@ -128,12 +143,31 @@ def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false") return # ========================================== - # Attribute parsing + # B) ATTRIBUTE PARSING # ========================================== + + # pian_lookup maps a Geometry ID (PIAN) to a list of its associated metadata pian_lookup = {} target_pian_ids = set() actions_with_geom = 0 + # Helper function for safe single-value extraction + def g(doc, key, default=""): + val = doc.get(key) + if isinstance(val, list): + return str(val[0]) if val else default + return str(val) if val is not None else default + + # Helper function for safe list-value extraction and joining + def g_list(doc, key, translate=False): + val = doc.get(key, []) + if not isinstance(val, list): + val = [val] if val else [] + if translate: + return ", ".join([tr_code(str(x)) for x in val if x]) + return ", ".join([str(x) for x in val if x]) + + # Process each downloaded metadata record for doc in docs: piani = doc.get('az_dj_pian', []) if not piani: @@ -141,23 +175,11 @@ def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false") actions_with_geom += 1 - def g(key, default=""): - val = doc.get(key) - if isinstance(val, list): - return str(val[0]) if val else default - return str(val) if val is not None else default - - def g_list(key, translate=False): - val = doc.get(key, []) - if not isinstance(val, list): - val = [val] if val else [] - if translate: - return ", ".join([tr_code(str(x)) for x in val if x]) - return ", ".join([str(x) for x in val if x]) - + # Extract protected data (fields not available in public Solr index) az_chranene = doc.get('az_chranene_udaje', {}) chranene = doc.get('akce_chranene_udaje') or doc.get('lokalita_chranene_udaje') or {} + # Format additional cadastral areas from dictionaries dalsi_kat = az_chranene.get('dalsi_katastr', []) dalsi_kat_str = "" if isinstance(dalsi_kat, list): @@ -168,25 +190,26 @@ def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false") lokalita_nazev = chranene.get('nazev', "") lokalita_popis = chranene.get('popis', "") - # Prepate common metadata + # Core metadata structure meta = { "ident_cely": doc.get('ident_cely', ''), - "az_okres": g('az_okres'), - "katastr": g_list('katastr'), + "az_okres": g(doc, 'az_okres'), + "katastr": g_list(doc, 'katastr'), "dalsi_katastr": dalsi_kat_str, - "pristupnost": g('pristupnost'), - "loc": g_list('loc') + "pristupnost": g(doc, 'pristupnost'), + "loc": g_list(doc, 'loc') } + # Add entity-specific metadata if typ_dat == "akce": meta.update({ - "akce_hlavni_vedouci": g('akce_hlavni_vedouci'), - "akce_organizace": tr_code(g('akce_organizace')), - "akce_specifikace_data": tr_code(g('akce_specifikace_data')), - "akce_datum_zahajeni": g('akce_datum_zahajeni'), - "akce_datum_ukonceni": g('akce_datum_ukonceni'), - "akce_hlavni_typ": tr_code(g('akce_hlavni_typ')), - "akce_vedlejsi_typ": g_list('akce_vedlejsi_typ', translate=True), + "akce_hlavni_vedouci": g(doc, 'akce_hlavni_vedouci'), + "akce_organizace": tr_code(g(doc, 'akce_organizace')), + "akce_specifikace_data": tr_code(g(doc, 'akce_specifikace_data')), + "akce_datum_zahajeni": g(doc, 'akce_datum_zahajeni'), + "akce_datum_ukonceni": g(doc, 'akce_datum_ukonceni'), + "akce_hlavni_typ": tr_code(g(doc, 'akce_hlavni_typ')), + "akce_vedlejsi_typ": g_list(doc, 'akce_vedlejsi_typ', translate=True), "lokalizace_okolnosti": str(lokalizace) if lokalizace else "", "akce_je_nz": "Ano" if doc.get('akce_je_nz') is True else "Ne", }) @@ -195,33 +218,42 @@ def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false") meta.update({ "lokalita_nazev": lokalita_nazev, "lokalita_popis": lokalita_popis, - "lokalita_zachovalost": tr_code(g('lokalita_zachovalost')), - "lokalita_druh": tr_code(g('lokalita_druh')), - "lokalita_typ": tr_code(g('lokalita_typ_lokality')), + "lokalita_zachovalost": tr_code(g(doc, 'lokalita_zachovalost')), + "lokalita_druh": tr_code(g(doc, 'lokalita_druh')), + "lokalita_typ": tr_code(g(doc, 'lokalita_typ_lokality')), }) + # Documentation units (DJ) within the record djs = doc.get('az_dokumentacni_jednotka', []) for dj in djs: - if filters and filters.get('posevidence') == 'true' and dj.get('dj_negativni_jednotka') is True: + # Filter out negative evidence units if requested + if skip_negativni and dj.get('dj_negativni_jednotka') is True: continue - dj_meta = meta.copy() dj_id = dj.get('ident_cely') - dj_meta['dj_id'] = dj_id dj_typ = dj.get('dj_typ') - dj_meta['dj_typ_value'] = dj_typ.get('value') if dj_typ else "" - dj_meta['dj_negativni'] = "Negativní" if dj.get('dj_negativni_jednotka') is True else "Pozitivní" + + # Merge general meta with documentation unit specific data + dj_meta = { + **meta, + 'dj_id': dj_id, + 'dj_typ_value': dj_typ.get('value') if dj_typ else "", + 'dj_negativni': "Negativní" if dj.get('dj_negativni_jednotka') is True else "Pozitivní" + } + + # Link Documentation Unit to Geometry (PIAN) dj_pian = dj.get('dj_pian') if dj_pian: dj_pian_value = dj_pian.get('id') if dj_pian_value: target_pian_ids.add(dj_pian_value) - target_pian_ids_count = target_pian_ids_count+1 + target_pian_ids_count += 1 if dj_pian_value not in pian_lookup: pian_lookup[dj_pian_value] = [] pian_lookup[dj_pian_value].append(dj_meta) + # Parse non-spatial components if requested (for relational tables) if komponenty == "true": komps = dj.get('dj_komponenta', []) for komp in komps: @@ -229,7 +261,6 @@ def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false") atributy = [ komp.get('ident_cely', ""), dj_id, - # komponenta_aktivita ..?, komp.get('komponenta_areal', {}).get('value', ""), komp.get('komponenta_obdobi', {}).get('value', "") ] @@ -242,16 +273,15 @@ def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false") # ========================================== - # B) Geometry (PIAN) + # C) GEOMETRY FETCHING (PIAN) # ========================================== ids_list = list(target_pian_ids) total_pians = len(ids_list) docs_pian = [] - BATCH_PIAN = 50 + BATCH_PIAN = 200 # Geometry requests are batch-processed to stay under URL length limits iface.messageBar().pushMessage("AMCR", f"Záznamů: {len(docs)} (z toho {actions_with_geom} s mapou). Stahuji {total_pians} unikátních geometrií, vykresluji {target_pian_ids_count} geometrií...", level=1) - # Seznam polí pro PIAN fl_pian = ["ident_cely", "pian_typ", "pian_chranene_udaje", "pian_presnost"] for i in range(0, total_pians, BATCH_PIAN): @@ -275,19 +305,18 @@ def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false") print(f"Chyba PIAN: {e}") # ========================================== - # C) TVORBA VRSTEV + # D) LAYER CREATION (QGIS Memory Layers) # ========================================== - vl_poly = QgsVectorLayer("Polygon?crs=epsg:5514", "AMCR Plochy", "memory") - vl_line = QgsVectorLayer("LineString?crs=epsg:5514", "AMCR Linie", "memory") - vl_point = QgsVectorLayer("Point?crs=epsg:5514", "AMCR Body", "memory") - layers = [vl_poly, vl_line, vl_point] - if typ_dat == "akce": - archeologicky_zaznam = "Akce" - elif typ_dat == "lokalita": - archeologicky_zaznam = "Lokalita" + archeologicky_zaznam = "Akce" if typ_dat == "akce" else "Lokalita" - # Definice sloupců atributové tabulky + # Initialize three layers for different geometry types (S-JTSK CRS) + vl_poly = QgsVectorLayer("Polygon?crs=epsg:5514", f"AMCR_{archeologicky_zaznam}_Polygony", "memory") + vl_line = QgsVectorLayer("LineString?crs=epsg:5514", f"AMCR_{archeologicky_zaznam}_Linie", "memory") + vl_point = QgsVectorLayer("Point?crs=epsg:5514", f"AMCR_{archeologicky_zaznam}_Body", "memory") + layers = [vl_poly, vl_line, vl_point] + + # Define attribute table structure cols = [ QgsField("PIAN", QVariant.String), QgsField("Přesnost", QVariant.String), @@ -302,8 +331,10 @@ def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false") QgsField("Další katastry", QVariant.String) ] + # Extend table based on data type if typ_dat == "akce": cols += [ + QgsField("Akce – lokalizace", QVariant.String), QgsField("Vedoucí akce", QVariant.String), QgsField("Organizace", QVariant.String), QgsField("Specifikace data", QVariant.String), @@ -311,28 +342,36 @@ def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false") QgsField("Datum ukončení", QVariant.String), QgsField("Hlavní typ", QVariant.String), QgsField("Vedlejší typ", QVariant.String), - QgsField("Zjištění", QVariant.String), - QgsField("Akce – lokalizace", QVariant.String), + QgsField("Zjištění", QVariant.String), QgsField("Akce – nahrazuje NZ", QVariant.String), ] elif typ_dat == "lokalita": cols += [ - QgsField("Název lokality", QVariant.String), - QgsField("Popis lokality", QVariant.String), - QgsField("Typ lokality", QVariant.String), - QgsField("Druh lokality", QVariant.String), - QgsField("Zachovalost", QVariant.String) + QgsField("nazev_lokality", QVariant.String), + QgsField("popis_lokality", QVariant.String), + QgsField("typ_lokality", QVariant.String), + QgsField("druh_lokality", QVariant.String), + QgsField("zachovalost", QVariant.String) ] cols.append(QgsField("Přístupnost", QVariant.String)) + # Use aliases for technical field names + alias_map = { + "nazev_lokality": "Název lokality", + "popis_lokality": "Popis lokality", + "typ_lokality": "Typ lokality", + "druh_lokality": "Druh lokality", + "zachovalost": "Zachovalost" + } + + # Create a non-spatial table for components if requested if komponenty == "true": vl_komponenty = QgsVectorLayer("None", "AMCR Komponenty", "memory") pr = vl_komponenty.dataProvider() komponenty_cols = [ - QgsField("komponenta", QVariant.String), # ident_cely + QgsField("komponenta", QVariant.String), QgsField("dj_id", QVariant.String), - # potenciálně QgsField("komponenta_aktivita", QVariant.String), QgsField("komponenta_areal", QVariant.String), QgsField("komponenta_obdobi", QVariant.String) ] @@ -346,98 +385,97 @@ def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false") for vl in layers: vl.dataProvider().addAttributes(cols) vl.updateFields() + for tech_name, alias in alias_map.items(): + idx = vl.fields().lookupField(tech_name) + if idx != -1: + vl.setFieldAlias(idx, alias) + # Lists to hold features before batch-adding to layers feats_p, feats_l, feats_pt = [], [], [] + # --- FEATURE POPULATION --- for doc in docs_pian: try: pid = doc.get('ident_cely', '') if pid not in pian_lookup: continue - + metas = pian_lookup[pid] - # Geometry processing + # Extract WKT geometry from protected JSON data raw = doc.get('pian_chranene_udaje') if isinstance(raw, list) and raw: raw = raw[0] - jdata = json.loads(raw) if isinstance(raw, str) else (raw if isinstance(raw, dict) else {}) + jdata = json.loads(raw) if isinstance(raw, str) else (raw or {}) wkt = None if jdata.get('geom_sjtsk_wkt'): - wkt = jdata['geom_sjtsk_wkt'].get('value') + wkt = jdata.get('geom_sjtsk_wkt', {}).get('value') elif jdata.get('geom_wkt'): - wkt = jdata['geom_wkt'].get('value') + wkt = jdata.get('geom_wkt', {}).get('value') - # PIAN attributes pian_presnost = tr_code(str(doc.get('pian_presnost', ''))) pian_typ = tr_code(str(doc.get('pian_typ', ''))) + # Final precision filter check if filters and filters.get('f_pian_presnost') and doc.get('pian_presnost') not in filters.get('f_pian_presnost'): continue if wkt: geom = QgsGeometry.fromWkt(wkt) if geom.isGeosValid(): + t = geom.type() + target_list = None + if t == QgsWkbTypes.PolygonGeometry: + target_list = feats_p + elif t == QgsWkbTypes.LineGeometry: + target_list = feats_l + elif t == QgsWkbTypes.PointGeometry: + target_list = feats_pt + if target_list is None: + continue + + is_akce = (typ_dat == "akce") + + # Create a QGIS feature for each documentation unit associated with this geometry for meta in metas: feat = QgsFeature() feat.setGeometry(geom) atributy = [ - pid, - pian_presnost, - pian_typ, - meta['dj_id'], - meta['dj_typ_value'], - meta['loc'], - meta['ident_cely'], + pid, pian_presnost, pian_typ, meta['dj_id'], + meta['dj_typ_value'], meta['loc'], meta['ident_cely'], "https://digiarchiv.aiscr.cz/id/" + meta['ident_cely'], - meta['az_okres'], - meta['katastr'], - meta['dalsi_katastr'] + meta['az_okres'], meta['katastr'], meta['dalsi_katastr'] ] - if typ_dat == "akce": - atributy += [ - meta['akce_hlavni_vedouci'], - meta['akce_organizace'], - meta['akce_specifikace_data'], - meta['akce_datum_zahajeni'], - meta['akce_datum_ukonceni'], - meta['akce_hlavni_typ'], - meta['akce_vedlejsi_typ'], - meta['dj_negativni'], - meta['lokalizace_okolnosti'], - meta['akce_je_nz'] - ] - - elif typ_dat == "lokalita": - atributy += [ - meta['lokalita_nazev'], - meta['lokalita_popis'], - meta['lokalita_typ'], - meta['lokalita_druh'], + if is_akce: + atributy.extend([ + meta['lokalizace_okolnosti'], meta['akce_hlavni_vedouci'], + meta['akce_organizace'], meta['akce_specifikace_data'], + meta['akce_datum_zahajeni'], meta['akce_datum_ukonceni'], + meta['akce_hlavni_typ'], meta['akce_vedlejsi_typ'], + meta['dj_negativni'], meta['akce_je_nz'] + ]) + else: + atributy.extend([ + meta['lokalita_nazev'], meta['lokalita_popis'], + meta['lokalita_typ'], meta['lokalita_druh'], meta['lokalita_zachovalost'] - ] + ]) atributy.append(meta['pristupnost']) - feat.setAttributes(atributy) - - t = geom.type() - if t == QgsWkbTypes.PolygonGeometry: - feats_p.append(feat) - elif t == QgsWkbTypes.LineGeometry: - feats_l.append(feat) - elif t == QgsWkbTypes.PointGeometry: - feats_pt.append(feat) + target_list.append(feat) + except Exception as ex: print(f"Chyba při tvorbě feature: {ex}") pass + # --- ADDING TO QGIS INTERFACE --- proj = QgsProject.instance() added = 0 layers_to_process = [ - (feats_p, vl_poly, "Plochy"), + (feats_p, vl_poly, "Polygony"), (feats_l, vl_line, "Linie"), (feats_pt, vl_point, "Body"), ] @@ -449,7 +487,7 @@ def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false") if f: l.dataProvider().addFeatures(f) l.updateExtents() - l.setName(f"AMČR {n} (Filtrováno)") + l.setName(f"AMCR_{archeologicky_zaznam}_{n}") proj.addMapLayer(l) if n != "Komponenty": added += len(f) @@ -457,23 +495,22 @@ def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false") if added > 0: iface.messageBar().pushMessage("AMCR", f"Hotovo. Záznamů: {len(docs)} (s geom: {actions_with_geom}). Vykresleno: {added} prvků.", level=0) - # Relation + # --- RELATIONSHIP MANAGEMENT --- + # Set up automatic links between spatial layers and the component table if komponenty == "true": parent_layers = [ - (vl_poly, "Plochy"), + (vl_poly, "Polygony"), (vl_line, "Linie"), (vl_point, "Body") ] rel_manager = proj.relationManager() for parent_layer, label in parent_layers: rel = QgsRelation() - #rel_id = f"rel_{parent_layer.id()}_komponenty" rel_name = f"Komponenty pro {label}" - #rel.setId(rel_id) rel.setName(rel_name) rel.setReferencingLayer(vl_komponenty.id()) rel.setReferencedLayer(parent_layer.id()) - rel.addFieldPair("dj_id", "Dokumentační jednotka") # Upravit název parent sloupce po změně názvů sloupců u vrstev akcí/lokalit + rel.addFieldPair("dj_id", "Dokumentační jednotka") rel.generateId() if rel.isValid(): rel_manager.addRelation(rel) @@ -486,4 +523,5 @@ def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false") except Exception as e: iface.messageBar().pushMessage("Chyba", str(e), level=2) finally: - QApplication.restoreOverrideCursor() + # Always restore cursor, even after failure + QApplication.restoreOverrideCursor() \ No newline at end of file diff --git a/amcr_viewer/amcr_viewer.py b/amcr_viewer/amcr_viewer.py index ae3f0bb..fdcefe3 100644 --- a/amcr_viewer/amcr_viewer.py +++ b/amcr_viewer/amcr_viewer.py @@ -9,31 +9,48 @@ from .resources import * import os.path class AmcrViewer: + """ + Main plugin class that manages the GUI elements, menu entries, + and coordinates the flow between user input and data processing. + """ def __init__(self, iface): + """ + Constructor initializes the connection to QGIS interface and sets up + internationalization (i18n). + """ self.iface = iface self.plugin_dir = os.path.dirname(__file__) + + # Determine the user's locale to load appropriate translation files locale = QSettings().value('locale/userLocale')[0:2] locale_path = os.path.join( self.plugin_dir, 'i18n', 'AmcrViewer_{}.qm'.format(locale)) + # Install the translator if a translation file for the current locale exists if os.path.exists(locale_path): self.translator = QTranslator() self.translator.load(locale_path) QCoreApplication.installTranslator(self.translator) + # Initialize internal state self.actions = [] self.menu = self.tr(u'&AMČR Viewer') self.first_start = None def tr(self, message): + """Helper method for translating strings within the AmcrViewer context.""" return QCoreApplication.translate('AmcrViewer', message) def add_action(self, icon_path, text, callback, enabled_flag=True, add_to_menu=True, add_to_toolbar=True, status_tip=None, whats_this=None, parent=None): + """ + Helper method to create QActions and automatically register them + into the QGIS Menu and Toolbar. + """ icon = QIcon(icon_path) action = QAction(icon, text, parent) action.triggered.connect(callback) @@ -45,26 +62,33 @@ class AmcrViewer: if whats_this is not None: action.setWhatsThis(whats_this) + # Standard QGIS API for adding icons and menu items if add_to_toolbar: self.iface.addToolBarIcon(action) if add_to_menu: self.iface.addPluginToMenu(self.menu, action) - self.actions.append(action) + # Store only actions that are directly attached to the QGIS UI for later cleanup + if add_to_toolbar or add_to_menu: + self.actions.append(action) + return action def initGui(self): - - import os - plugin_dir = os.path.dirname(__file__) - icon_akce_path = os.path.join(plugin_dir, 'akce.png') - icon_lokality_path = os.path.join(plugin_dir, 'lokality.png') + """ + Called when the plugin is loaded. Creates the menu structure, + sub-actions, and the dropdown tool button in the toolbar. + """ + # Define paths for action-specific icons + icon_akce_path = os.path.join(self.plugin_dir, 'akce.png') + icon_lokality_path = os.path.join(self.plugin_dir, 'lokality.png') - # 1. Vytvoření společného menu + # 1. Create a container menu for the plugin self.plugin_menu = QMenu() - # 2. Vytvoření akcí (bez automatického přidání do lišty a menu) + # 2. Create sub-actions (Download Projects / Download Sites) + # add_to_menu/toolbar is False because these go into our custom dropdown menu self.action_download_akce = self.add_action( icon_path=icon_akce_path, text=self.tr(u'Stáhnout data akcí | AMČR Viewer'), @@ -85,41 +109,63 @@ class AmcrViewer: ) self.plugin_menu.addAction(self.action_download_lokality) - # 3. Přidání rozbalovacího menu do hlavního menu QGIS + # 3. Create the main project action and attach the menu to it main_icon = QIcon(icon_akce_path) self.main_action = QAction(main_icon, 'AMČR Viewer', self.iface.mainWindow()) self.main_action.setMenu(self.plugin_menu) self.iface.addPluginToMenu(self.menu, self.main_action) - # 4. Přidání rozevíracího tlačítka do nástrojové lišty (Toolbar) + # 4. Create and configure a QToolButton for the QGIS Toolbar + # This button acts as a dropdown menu button (MenuButtonPopup) self.tool_button = QToolButton() self.tool_button.setMenu(self.plugin_menu) self.tool_button.setDefaultAction(self.action_download_akce) self.tool_button.setPopupMode(QToolButton.MenuButtonPopup) - # Vložení vytvořeného tlačítka do QGIS rozhraní - self.iface.addToolBarWidget(self.tool_button) + # Add the widget directly to the toolbar and store the reference for cleanup + self.toolbar_action = self.iface.addToolBarWidget(self.tool_button) self.first_start = True def unload(self): + """ + Called when the plugin is disabled or removed. + Ensures all GUI elements are removed from QGIS to avoid ghost icons. + """ + # 1. Remove the custom entry from the main 'Plugins' menu + if hasattr(self, 'main_action'): + self.iface.removePluginMenu(self.menu, self.main_action) + + # 2. Remove the custom QToolButton from the toolbar + if hasattr(self, 'toolbar_action'): + self.iface.removeToolBarIcon(self.toolbar_action) + + # 3. Clean up any remaining actions registered in self.actions for action in self.actions: - self.iface.removePluginMenu(self.tr(u'&AMČR Viewer'), action) + self.iface.removePluginMenu(self.menu, action) self.iface.removeToolBarIcon(action) + self.actions.clear() + # 4. Reset map tools if currently active if hasattr(self, 'tool'): self.iface.mapCanvas().unsetMapTool(self.tool) # --- Data downloading --- def run_download(self, typ_dat): - + """ + Triggered by menu/toolbar actions. Opens the filter dialog and + hands off the parameters to the data loader. + """ + # Open the specific filter dialog (Projects vs Sites) dlg = AmcrFilterDialog(typ_dat) - result = dlg.exec_() + result = dlg.exec() + # If user confirmed the dialog (OK button), gather filters and load data if result == 1: filters = dlg.get_filters() bbox = dlg.get_bbox() komponenty = dlg.get_komponenty() + # Access the map canvas and start the fetch/render process from amcr_tools canvas = self.iface.mapCanvas() - load_amcr_data(canvas, bbox, filters, typ_dat, komponenty) + load_amcr_data(canvas, bbox, filters, typ_dat, komponenty) \ No newline at end of file