Merge pull request #60 from ARUP-CAS/version/v2.1.0

release: AMČR Viewer 2.1.0
This commit is contained in:
david-spacil authored and GitHub committed 2026-09-01 22:11:36 +02:00
commit 7cffcdb235
8 files changed
+3953 -8323

No files matched your search

+73 -21
View File
@@ -9,24 +9,30 @@ from qgis.core import QgsMessageLog, Qgis
# Define paths for the plugin and its codelists directory
PLUGIN_DIR = os.path.dirname(__file__)
CODELISTS_DIR = os.path.join(PLUGIN_DIR, 'codelists')
BASE_URL = "https://api.aiscr.cz/2.2/oai"
BASE_URL_AMCR = "https://api.aiscr.cz/2.2/oai"
BASE_URL_DA = "https://digiarchiv.aiscr.cz/api/search/query"
OUTPUT_FILE = os.path.join(CODELISTS_DIR, 'heslar.csv')
slovnicek = {
'obdobi': 'heslo:obdobi',
'typ_akce': 'heslo:akce_typ',
'areal': 'heslo:areal',
'kraj': 'ruian_kraj',
'organizace': 'organizace',
'okres': 'ruian_okres',
'katastr': 'ruian_katastr',
'vedouci': 'osoba',
'pian_presnost': 'heslo:pian_presnost',
'typ_lokality': 'heslo:lokalita_typ',
'druh_lokality': 'heslo:lokalita_druh',
'jistota': 'heslo:jistota_urceni',
'lokalita_zachovalost': 'heslo:stav_dochovani',
'pristupnost': 'heslo:pristupnost'
'obdobi': (BASE_URL_AMCR, 'heslo:obdobi'),
'typ_akce': (BASE_URL_AMCR, 'heslo:akce_typ'),
'areal': (BASE_URL_AMCR, 'heslo:areal'),
'kraj': (BASE_URL_AMCR, 'ruian_kraj'),
'organizace': (BASE_URL_AMCR, 'organizace'),
'okres': (BASE_URL_AMCR, 'ruian_okres'),
'katastr': (BASE_URL_AMCR, 'ruian_katastr'),
'pian_presnost': (BASE_URL_AMCR, 'heslo:pian_presnost'),
'typ_lokality': (BASE_URL_AMCR, 'heslo:lokalita_typ'),
'druh_lokality': (BASE_URL_AMCR, 'heslo:lokalita_druh'),
'jistota': (BASE_URL_AMCR, 'heslo:jistota_urceni'),
'lokalita_zachovalost': (BASE_URL_AMCR, 'heslo:stav_dochovani'),
'pristupnost': (BASE_URL_AMCR, 'heslo:pristupnost'),
'nalez_kategorie': (BASE_URL_AMCR, 'heslo:predmet_druh_kat'),
'druh_nalezu': (BASE_URL_AMCR, 'heslo:predmet_druh'),
'specifikace': (BASE_URL_AMCR, 'heslo:predmet_specifikace'),
'nalezove_okolnosti': (BASE_URL_AMCR, 'heslo:nalezove_okolnosti'),
'vedouci': (BASE_URL_DA, 'f_vedouci'),
'nalezce': (BASE_URL_DA, 'f_nalezce'),
}
NS = {
@@ -97,13 +103,19 @@ def load_all_data():
return categorized_data
def fetch_set(internal_name, api_set, task=None):
def fetch_set(base_url, internal_name, api_set, task=None):
dataset = []
params = {
params_amcr = {
"verb": "ListRecords",
"metadataPrefix": "oai_dc",
"set": api_set
}
params_da = {
"entity": "samostatny_nalez" if internal_name == "nalezce" else "akce",
"rows": 0,
"noFacets": "false",
"onlyFacets": "true"
}
while True:
# Check for cancellation at each iteration
@@ -111,7 +123,8 @@ def fetch_set(internal_name, api_set, task=None):
return None
try:
response = requests.get(BASE_URL, params=params, timeout=30)
if "digiarchiv" not in base_url:
response = requests.get(base_url, params=params_amcr, timeout=30)
response.raise_for_status()
root = ET.fromstring(response.content) # nosec
@@ -173,7 +186,7 @@ def fetch_set(internal_name, api_set, task=None):
# Pagination
token = root.find('.//oai:resumptionToken', NS)
if token is not None and token.text:
params = {
params_amcr = {
"verb": "ListRecords",
"resumptionToken": token.text
}
@@ -181,6 +194,25 @@ def fetch_set(internal_name, api_set, task=None):
else:
break
else:
response = requests.get(base_url, params=params_da, timeout=30)
response.raise_for_status()
data_json = response.json()
records = data_json['facet_counts']['facet_fields'][api_set]
for r in records:
nazev = r["name"]
dataset.append({
'Název': nazev,
'Kód': nazev,
'Kategorie': internal_name
})
break
except Exception as e:
QgsMessageLog.logMessage(
f"Chyba u setu {api_set}: {e}",
@@ -195,8 +227,13 @@ def download_heslare(task=None):
ensure_codelists_dir()
all_data = []
total_sets = len(slovnicek)
# index, (interni, api_nazev)
for index, (key, value) in enumerate(slovnicek.items()):
base_url = value[0]
interni = key
api_nazev = value[1]
for index, (interni, api_nazev) in enumerate(slovnicek.items()):
# Check if the user cancelled the task via the QGIS taskbar
if task and task.isCanceled():
return False
@@ -206,7 +243,7 @@ def download_heslare(task=None):
"AMČR", Qgis.Info)
# Pass the task correctly to the updated fetch function
data = fetch_set(interni, api_nazev, task=task)
data = fetch_set(base_url, interni, api_nazev, task=task)
if data is None:
return False # Cancelled mid-download
@@ -260,6 +297,16 @@ def refresh_globals():
LOKALITA_ZACHOVALOST.update(data.get('lokalita_zachovalost', {}))
PRISTUPNOST.clear()
PRISTUPNOST.update(data.get('pristupnost', {}))
NALEZ_KATEGORIE.clear()
NALEZ_KATEGORIE.update(data.get('nalez_kategorie', {}))
DRUH_NALEZU.clear()
DRUH_NALEZU.update(data.get('druh_nalezu', {}))
SPECIFIKACE.clear()
SPECIFIKACE.update(data.get('specifikace', {}))
NALEZOVE_OKOLNOSTI.clear()
NALEZOVE_OKOLNOSTI.update(data.get('nalezove_okolnosti', {}))
NALEZCE.clear()
NALEZCE.update(data.get('nalezce', {}))
# Initialize empty dicts that will be populated immediately below
@@ -277,5 +324,10 @@ DRUH_LOKALITY = {}
JISTOTA = {}
LOKALITA_ZACHOVALOST = {}
PRISTUPNOST = {}
NALEZ_KATEGORIE = {}
DRUH_NALEZU = {}
SPECIFIKACE = {}
NALEZOVE_OKOLNOSTI = {}
NALEZCE = {}
refresh_globals()
+250 -22
View File
@@ -3,18 +3,33 @@ from qgis.PyQt.QtWidgets import (QDialog, QVBoxLayout,
QLineEdit, QDialogButtonBox,
QCheckBox, QGroupBox, QPushButton,
QListWidget, QListWidgetItem, QHBoxLayout,
QMessageBox, QLabel, QFormLayout)
QMessageBox, QLabel, QFormLayout,
QGridLayout, QScrollArea, QFrame, QWidget)
from qgis.PyQt.QtCore import Qt, QSettings
from qgis.core import (QgsTask, QgsApplication,
QgsMessageLog, Qgis, QgsAuthMethodConfig)
from qgis.gui import QgsDateEdit
from qgis.utils import iface
from .amcr_codelists import (OBDOBI, TYP_AKCE, KRAJE, AREAL, ORGANIZACE,
OKRESY, KATASTRY, VEDOUCI, PIAN_PRESNOST,
TYP_LOKALITY, DRUH_LOKALITY, JISTOTA,
LOKALITA_ZACHOVALOST, PRISTUPNOST,
NALEZ_KATEGORIE, DRUH_NALEZU, SPECIFIKACE,
NALEZOVE_OKOLNOSTI, NALEZCE,
download_heslare, refresh_globals)
# The date filter of the API requires both bounds; a one-sided range makes
# the server fail with an ArrayIndexOutOfBoundsException and '*' is not
# accepted either. An empty picker is therefore replaced by these sentinels,
# which are also the default limits of QgsDateEdit.
DATE_OPEN_FROM = "0001-01-01"
DATE_OPEN_TO = "9999-12-31"
# Shown by a date picker that is left empty
DATE_NULL_TEXT = "neomezeno"
# Keep Python references to running tasks. QgsTaskManager only holds the
# C++ object; without a Python-side reference the wrapper can be garbage
# collected before the task finishes, which crashes QGIS.
@@ -44,16 +59,19 @@ class UpdateCodelistsTask(QgsTask):
refresh_globals()
QgsMessageLog.logMessage(
"Hesláře AMČR byly úspěšně aktualizovány.",
"AMČR", Qgis.Info)
"AMČR", Qgis.Info
)
else:
if self.isCanceled():
QgsMessageLog.logMessage(
"Aktualizace heslářů byla zrušena.",
"AMČR", Qgis.Warning)
"AMČR", Qgis.Warning
)
else:
QgsMessageLog.logMessage(
f"Chyba aktualizace: {self.exception}",
"AMČR", Qgis.Critical)
"AMČR", Qgis.Critical
)
class FilterableSelectionDialog(QDialog):
@@ -151,12 +169,31 @@ class AmcrFilterDialog(QDialog):
# Cache dictionary to store selected codes for each category
self.selection_cache = {
'organizace': [], 'kraj': [], 'obdobi': [], 'areal': [],
'typ_akce': [], 'okres': [], 'katastr': [], 'vedouci': [],
'pian_presnost': [], 'pristupnost': [], 'typ_lokality': [],
'druh_lokality': [], 'jistota': [], 'lokalita_zachovalost': []
'organizace': [],
'kraj': [],
'obdobi': [],
'areal': [],
'typ_akce': [],
'okres': [],
'katastr': [],
'vedouci': [],
'pian_presnost': [],
'pristupnost': [],
'typ_lokality': [],
'druh_lokality': [],
'jistota': [],
'lokalita_zachovalost': [],
'nalez_kategorie': [],
'druh_nalezu': [],
'specifikace': [],
'nalezove_okolnosti': [],
'nalezce': [],
}
# Date range pickers, filled by setup_date_range():
# (API field, label for messages, 'from' widget, 'to' widget)
self.date_ranges = []
layout = QVBoxLayout()
# Filter by current map canvas extent
@@ -169,6 +206,8 @@ class AmcrFilterDialog(QDialog):
if self.typ_dat == "akce":
self.chk_posevidence = QCheckBox("Pouze pozitivní zjištění")
layout.addWidget(self.chk_posevidence)
self.chk_proj_akce = QCheckBox("Pouze projektové akce")
layout.addWidget(self.chk_proj_akce)
layout.addSpacing(10)
@@ -187,13 +226,6 @@ class AmcrFilterDialog(QDialog):
)
layout.addWidget(self.picker_katastr)
self.picker_presnost = self.setup_picker(
"PIAN – přesnost",
'pian_presnost',
PIAN_PRESNOST
)
layout.addWidget(self.picker_presnost)
self.picker_pristupnost = self.setup_picker(
"Přístupnost",
'pristupnost',
@@ -203,7 +235,15 @@ class AmcrFilterDialog(QDialog):
# Filters valid for Akce
if self.typ_dat == "akce":
if self.typ_dat in ["lokalita", "akce"]:
self.picker_presnost = self.setup_picker(
"PIAN – přesnost",
'pian_presnost',
PIAN_PRESNOST
)
layout.addWidget(self.picker_presnost)
if self.typ_dat in ["samostatny_nalez", "akce"]:
self.picker_org = self.setup_picker(
"Organizace",
'organizace',
@@ -211,6 +251,7 @@ class AmcrFilterDialog(QDialog):
)
layout.addWidget(self.picker_org)
if self.typ_dat == "akce":
self.picker_vedouci = self.setup_picker(
"Vedoucí výzkumu",
'vedouci',
@@ -227,6 +268,12 @@ class AmcrFilterDialog(QDialog):
)
layout.addWidget(self.picker_typ)
self.box_datum = self.setup_date_range("Datum", [
("akce_datum_zahajeni", "Zahájení", "Datum zahájení"),
("akce_datum_ukonceni", "Ukončení", "Datum ukončení"),
])
layout.addWidget(self.box_datum)
# Filters valid for Lokality
if self.typ_dat == "lokalita":
@@ -263,6 +310,50 @@ class AmcrFilterDialog(QDialog):
self.picker_obdobi = self.setup_picker("Období", 'obdobi', OBDOBI)
layout.addWidget(self.picker_obdobi)
if self.typ_dat == "samostatny_nalez":
self.picker_nalez_kategorie = self.setup_picker(
"Kategorie nálezu",
'nalez_kategorie',
NALEZ_KATEGORIE
)
layout.addWidget(self.picker_nalez_kategorie)
self.picker_druh_nalezu = self.setup_picker(
"Druh nálezu",
'druh_nalezu',
DRUH_NALEZU
)
layout.addWidget(self.picker_druh_nalezu)
self.picker_specifikace = self.setup_picker(
"Specifikace nálezu",
'specifikace',
SPECIFIKACE
)
layout.addWidget(self.picker_specifikace)
self.picker_nalezove_okolnosti = self.setup_picker(
"Okolnosti nálezu",
'nalezove_okolnosti',
NALEZOVE_OKOLNOSTI
)
layout.addWidget(self.picker_nalezove_okolnosti)
self.picker_nalezce = self.setup_picker(
"Nálezce",
'nalezce',
NALEZCE
)
layout.addWidget(self.picker_nalezce)
# Lokalita has no date field in the index at all, so the block
# is built only for the two entities that do
self.box_datum = self.setup_date_range("Datum nálezu", [
("samostatny_nalez_datum_nalezu", "", "Datum nálezu"),
])
layout.addWidget(self.box_datum)
if self.typ_dat != "samostatny_nalez":
self.picker_areal = self.setup_picker("Areál", 'areal', AREAL)
layout.addWidget(self.picker_areal)
@@ -284,6 +375,7 @@ class AmcrFilterDialog(QDialog):
self.lbl_komponenty_warning.setVisible(False)
layout.addWidget(self.lbl_komponenty_warning)
if self.typ_dat != "samostatny_nalez":
self.chk_komponenty.toggled.connect(
self.lbl_komponenty_warning.setVisible
)
@@ -291,6 +383,21 @@ class AmcrFilterDialog(QDialog):
# Pushes everything above to the top
layout.addStretch(1)
# The filter stack is taller than the window on every entity
# (over 1000 px for 'akce'), so it scrolls. The buttons stay
# outside the scroll area, otherwise the user would have to
# scroll to the bottom just to confirm the dialog.
content = QWidget()
content.setLayout(layout)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QFrame.Shape.NoFrame)
scroll.setWidget(content)
outer = QVBoxLayout()
outer.addWidget(scroll)
# Main dialog OK/Cancel/Update buttons
buttons = QDialogButtonBox()
@@ -311,9 +418,9 @@ class AmcrFilterDialog(QDialog):
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
outer.addWidget(buttons)
self.setLayout(layout)
self.setLayout(outer)
def setup_picker(self, label_text, cache_key, data_source, extra_btn=None):
"""
@@ -361,7 +468,7 @@ class AmcrFilterDialog(QDialog):
self.selection_cache[cache_key] = [
'HES-000861',
'HES-000862',
'HES-000863'
'HES-000863',
]
btn.clicked.connect(open_dialog)
@@ -376,6 +483,94 @@ class AmcrFilterDialog(QDialog):
row_widget.setLayout(row_layout)
return row_widget
def setup_date_range(self, title, rows):
"""
Creates a compact date range block: one row per API date field,
each with a 'from' and a 'to' picker.
rows is a list of (api_field, row_label, name_for_messages).
An empty row_label is used when the group box title already names
the field, which keeps the single-row variant from repeating itself.
A picker left empty means an open bound; the sentinel is
substituted in get_filters(), not here, so that an untouched
block adds no filter at all.
"""
row_widget = QGroupBox(title)
grid = QGridLayout()
grid.setContentsMargins(5, 5, 5, 5)
grid.setVerticalSpacing(3)
grid.setHorizontalSpacing(6)
for row, (api_field, row_label, name) in enumerate(rows):
if row_label:
grid.addWidget(QLabel(row_label), row, 0)
date_from = self._date_edit(
f"{name} – od (prázdné = bez dolní meze)"
)
date_to = self._date_edit(
f"{name} – do (prázdné = bez horní meze)"
)
separator = QLabel("–")
separator.setAlignment(Qt.AlignmentFlag.AlignCenter)
grid.addWidget(date_from, row, 1)
grid.addWidget(separator, row, 2)
grid.addWidget(date_to, row, 3)
self.date_ranges.append((api_field, name, date_from, date_to))
grid.setColumnStretch(1, 1)
grid.setColumnStretch(3, 1)
row_widget.setLayout(grid)
return row_widget
@staticmethod
def _date_edit(tooltip):
"""
A date picker that may stay empty.
clear() is essential here – setEmpty() looks empty but leaves
isNull() False with today's date, which would silently apply
a filter the user never set.
"""
widget = QgsDateEdit()
widget.setAllowNull(True)
widget.setNullRepresentation(DATE_NULL_TEXT)
widget.setDisplayFormat("d. M. yyyy")
widget.setCalendarPopup(True)
widget.clear()
widget.setToolTip(tooltip)
return widget
def accept(self):
"""
Blocks the dialog on a reversed date range. The API answers such
a query with zero records and no error, which is indistinguishable
from a genuinely empty result.
"""
reversed_ranges = [
name for _, name, date_from, date_to in self.date_ranges
if not date_from.isNull() and not date_to.isNull()
and date_from.date() > date_to.date()
]
if reversed_ranges:
QMessageBox.warning(
self,
"Neplatné rozmezí",
"U těchto filtrů je počáteční datum novější než koncové:\n"
+ "\n".join(f"• {name}" for name in reversed_ranges)
+ "\n\nDotaz by nevrátil žádný záznam. Opravte rozmezí, "
"nebo jedno z polí vyprázdněte."
)
return
super().accept()
def action_update_heslare(self):
# Create the task instance and keep a reference so the Python
# wrapper survives until the task finishes
@@ -428,7 +623,10 @@ class AmcrFilterDialog(QDialog):
return "true" if self.chk_bbox.isChecked() else "false"
def get_komponenty(self):
if self.typ_dat in ["akce", "lokalita"]:
return "true" if self.chk_komponenty.isChecked() else "false"
else:
return "false"
def get_filters(self):
"""Compiles the user selections from the cache into
@@ -453,14 +651,17 @@ class AmcrFilterDialog(QDialog):
if self.typ_dat == "akce":
if self.chk_posevidence.isChecked():
filters['posevidence'] = 'true'
if self.selection_cache['organizace']:
filters['f_organizace'] = self.selection_cache['organizace']
if self.chk_proj_akce.isChecked():
filters['proj_akce'] = 'true'
if self.selection_cache['typ_akce']:
filters['f_typ_vyzkumu'] = self.selection_cache['typ_akce']
if self.selection_cache['vedouci']:
filters['f_vedouci'] = self.selection_cache['vedouci']
if self.typ_dat == "lokalita":
if self.selection_cache['organizace']:
filters['f_organizace'] = self.selection_cache['organizace']
if self.selection_cache['typ_lokality']:
filters['f_typ_lokality'] = self.selection_cache['typ_lokality']
if self.selection_cache['druh_lokality']:
@@ -470,6 +671,33 @@ class AmcrFilterDialog(QDialog):
if self.selection_cache['lokalita_zachovalost']:
filters['f_lokalita_zachovalost'] = self.selection_cache['lokalita_zachovalost']
# Samostatné nálezy
if self.selection_cache['nalez_kategorie']:
filters['f_kategorie'] = self.selection_cache['nalez_kategorie']
if self.selection_cache['druh_nalezu']:
filters['f_druh_nalezu'] = self.selection_cache['druh_nalezu']
if self.selection_cache['specifikace']:
filters['f_specifikace'] = self.selection_cache['specifikace']
if self.selection_cache['nalezove_okolnosti']:
filters['f_nalezove_okolnosti'] = self.selection_cache['nalezove_okolnosti']
if self.selection_cache['nalezce']:
filters['f_nalezce'] = self.selection_cache['nalezce']
# Date ranges – the API needs both bounds, so an empty picker is
# replaced by a sentinel. A block with both pickers empty adds no
# filter at all; sending the full 0001–9999 range would only
# clutter the log without narrowing anything.
for api_field, _, date_from, date_to in self.date_ranges:
if date_from.isNull() and date_to.isNull():
continue
od = (DATE_OPEN_FROM if date_from.isNull()
else date_from.date().toString("yyyy-MM-dd"))
do = (DATE_OPEN_TO if date_to.isNull()
else date_to.date().toString("yyyy-MM-dd"))
filters[api_field] = f"{od},{do}"
return filters
+354 -28
View File
@@ -26,6 +26,17 @@ LAST_LOGIN_ERROR: str | None = None
# a second download while the first one is still running
_LOADING = False
archeologicky_zaznam_l = [
"akce",
"lokalita",
]
typ_dat_vocab = {
"akce": "Akce",
"lokalita": "Lokalita",
"samostatny_nalez": "Samostatný nález",
}
def _log(msg: str, level=Qgis.MessageLevel.Info):
"""
@@ -35,6 +46,14 @@ def _log(msg: str, level=Qgis.MessageLevel.Info):
QgsMessageLog.logMessage(msg, "AMČR login", level)
def _log_data(msg: str, level=Qgis.MessageLevel.Info):
"""
Shortcut: writes a message to the QGIS log used by the data download
(Messages panel → AMČR tab).
"""
QgsMessageLog.logMessage(msg, "AMČR", level)
def login_to_api(username: str, password: str):
"""
Logs in to the Digiarchiv API using a username and password.
@@ -179,6 +198,21 @@ def _api_get_json(url, params, timeout=30) -> dict:
_log("Přihlašovací údaje nejsou uloženy – pokračuji anonymně.",
Qgis.MessageLevel.Warning)
# Log the URL that was actually sent, so a query returning nothing can
# be replayed in a browser instead of being reconstructed from the code
url_log = resp.url
if len(url_log) > 500:
url_log = f"{url_log[:500]}… (celkem {len(resp.url)} znaků)"
_log_data(f"GET {url_log}")
# The API reports errors with HTTP 200 and an 'error' key instead of
# a 'response' block – without this the caller sees only empty results
if isinstance(body, dict) and body.get("error"):
_log_data(
f"API vrátilo chybu: {body['error']}",
Qgis.MessageLevel.Critical
)
if body is None:
raise ValueError(
f"API nevrátilo platný JSON (HTTP {resp.status_code})"
@@ -283,7 +317,7 @@ def load_amcr_data(canvas, bb, filters=None,
base_params = {
"mapa": "true",
"sort": "ident_cely asc",
"entity": typ_dat
"entity": typ_dat,
}
# Restrict search to map window if requested
@@ -301,6 +335,14 @@ def load_amcr_data(canvas, bb, filters=None,
else:
base_params[key] = str(value).strip()
# Filters exactly as they arrived from the dialog – an empty result
# caused by a stray filter is otherwise indistinguishable from an
# empty map window
_log_data(
f"Stahuji '{typ_dat}' (bbox={bb}, komponenty={komponenty}), "
f"filtry: {filters if filters else 'žádné'}"
)
docs = []
current_page = 0
BATCH_DOCS = 500 # Records per API request
@@ -317,6 +359,12 @@ def load_amcr_data(canvas, bb, filters=None,
else False
)
only_projektove_akce = (
filters.get('proj_akce') == 'true'
if filters
else False
)
# Check whether we should filter results based on component filters
filter_areal = "f_areal" in filters if filters else False
filter_datace = "f_obdobi" in filters if filters else False
@@ -325,6 +373,10 @@ def load_amcr_data(canvas, bb, filters=None,
# gets an explicit error/warning instead of a silent partial result
network_error = False
# Set when the API answers with an error instead of a result set –
# keeps an invalid parameter distinguishable from an empty result
api_error = False
# --- API PAGINATION LOOP ---
while True:
base_params['rows'] = BATCH_DOCS
@@ -335,11 +387,24 @@ def load_amcr_data(canvas, bb, filters=None,
try:
resp_json = _api_get_json(url, params=base_params, timeout=30)
data = resp_json.get('response', {})
# Errors arrive with HTTP 200 and no 'response' block;
# _api_get_json has already logged the reason
if 'response' not in resp_json:
api_error = True
break
data = resp_json['response']
batch_docs = data.get('docs', [])
num_found = data.get('numFound', 0)
if not batch_docs:
if current_page == 0:
_log_data(
"Dotaz proběhl v pořádku, ale nevrátil žádný "
f"záznam (numFound={num_found}).",
Qgis.MessageLevel.Warning
)
break
fetched_total += len(batch_docs)
@@ -399,6 +464,15 @@ def load_amcr_data(canvas, bb, filters=None,
)
return
if api_error and not docs:
iface.messageBar().pushMessage(
"AMCR",
"Dotaz na API selhal – podrobnosti v panelu Zprávy, "
"záložka AMČR.",
level=Qgis.MessageLevel.Critical
)
return
if not docs:
iface.messageBar().pushMessage(
"AMCR",
@@ -415,7 +489,7 @@ def load_amcr_data(canvas, bb, filters=None,
# to a list of its associated metadata
pian_lookup = {}
target_pian_ids = set()
actions_with_geom = 0
entries_with_geom = 0
# Helper: safely extract a single value
def g(doc, key, default=""):
@@ -434,12 +508,16 @@ def load_amcr_data(canvas, bb, filters=None,
return ", ".join([str(x) for x in val if x])
# Process each downloaded metadata record
if typ_dat in archeologicky_zaznam_l:
for doc in docs:
piani = doc.get('az_dj_pian', [])
if not piani:
continue
actions_with_geom += 1
if only_projektove_akce and not doc.get("akce_projekt", False):
continue
entries_with_geom += 1
# Extract protected fields ('or {}' – key may hold None)
az_chranene = doc.get('az_chranene_udaje') or {}
@@ -470,7 +548,7 @@ def load_amcr_data(canvas, bb, filters=None,
"katastr": g_list(doc, 'katastr'),
"dalsi_katastr": dalsi_kat_str,
"pristupnost": g(doc, 'pristupnost'),
"loc": g_list(doc, 'loc')
"loc": g_list(doc, 'loc'),
}
# Add entity-specific metadata
@@ -515,6 +593,11 @@ def load_amcr_data(canvas, bb, filters=None,
if doc.get('akce_je_nz') is True
else "Ne"
),
"projekt": g(
doc,
'akce_projekt',
""
),
})
elif typ_dat == "lokalita":
@@ -647,15 +730,17 @@ def load_amcr_data(canvas, bb, filters=None,
iface.messageBar().pushMessage(
"AMCR",
f"Záznamů: {len(docs)} (z toho {actions_with_geom} s mapou). "
f"Záznamů: {len(docs)} (z toho {entries_with_geom} s mapou). "
f"Stahuji {total_pians} unikátních geometrií, "
f"vykresluji {target_pian_ids_count} geometrií...",
level=Qgis.MessageLevel.Info
)
fl_pian = [
"ident_cely", "pian_typ",
"pian_chranene_udaje", "pian_presnost"
"ident_cely",
"pian_typ",
"pian_chranene_udaje",
"pian_presnost",
]
for i in range(0, total_pians, BATCH_PIAN):
@@ -668,12 +753,19 @@ def load_amcr_data(canvas, bb, filters=None,
"entity": "pian",
"q": fq_pian,
"rows": len(batch),
"fl": ",".join(fl_pian)
"fl": ",".join(fl_pian),
}
try:
QApplication.processEvents()
r_json = _api_get_json(url, params=params_pian, timeout=15)
docs_pian.extend(r_json.get('response', {}).get('docs', []))
if 'response' not in r_json:
QgsMessageLog.logMessage(
"API nevrátilo geometrie PIAN pro dávku "
f"{i // BATCH_PIAN + 1} – viz chyba výše.",
"AMČR", Qgis.MessageLevel.Warning
)
continue
docs_pian.extend(r_json['response'].get('docs', []))
except requests.exceptions.RequestException as e:
# Network is down – stop immediately instead of
# uselessly retrying every remaining batch
@@ -689,43 +781,180 @@ def load_amcr_data(canvas, bb, filters=None,
"AMČR", Qgis.MessageLevel.Warning
)
elif typ_dat == "samostatny_nalez":
nalezy = {}
for doc in docs:
sn_loc = g_list(doc, "loc")
if not sn_loc:
continue
entries_with_geom += 1
sn_id = g(
doc,
"ident_cely"
)
sn_nalezce = g(
doc,
"samostatny_nalez_nalezce"
)
sn_hloubka = g(
doc,
"samostatny_nalez_hloubka"
)
sn_pristupnost = g(
doc,
"pristupnost"
)
sn_datum = g(
doc,
"samostatny_nalez_datum_nalezu"
)
sn_poznamka = g(
doc,
"samostatny_nalez_poznamka"
)
sn_spec = tr_code(g(
doc,
"samostatny_nalez_specifikace"
))
sn_evidencni = g(
doc,
"samostatny_nalez_evidencni_cislo"
)
sn_obdobi = tr_code(g(
doc,
"samostatny_nalez_obdobi"
))
sn_presna = g(
doc,
"samostatny_nalez_presna_datace"
)
sn_okres = g(
doc,
"samostatny_nalez_okres"
)
sn_pocet = g(
doc,
"samostatny_nalez_pocet"
)
sn_druh = tr_code(g(
doc,
"samostatny_nalez_druh_nalezu"
))
sn_predano_org = tr_code(g(
doc,
"samostatny_nalez_predano_organizace"
))
sn_okolnosti = tr_code(g(
doc,
"samostatny_nalez_okolnosti"
))
sn_projekt = g(
doc,
"samostatny_nalez_projekt"
)
sn_chranene = doc.get("samostatny_nalez_chranene_udaje") or {}
sn_lokalizace = g(
sn_chranene,
"lokalizace"
)
sn_katastr = sn_chranene.get('katastr', {}).get('value')
wkt = None
wkt_is_wgs = False
if sn_chranene.get('geom_sjtsk_wkt'):
wkt = sn_chranene.get('geom_sjtsk_wkt', {}).get('value')
elif sn_chranene.get('geom_wkt'):
# Fallback geometry is in WGS-84 and must be
# transformed to S-JTSK before use
wkt = sn_chranene.get('geom_wkt', {}).get('value')
wkt_is_wgs = True
meta = {
"ident_cely": sn_id,
"projekt": sn_projekt,
"okres": sn_okres,
"katastr": sn_katastr,
"nalezce": sn_nalezce,
"datum": sn_datum,
"okolnosti": sn_okolnosti,
"hloubka_cm": sn_hloubka,
"loc": sn_loc,
"lokalizace": sn_lokalizace,
"obdobi": sn_obdobi,
"presna_datace": sn_presna,
"nalez": sn_druh,
"material": sn_spec,
"pocet": sn_pocet,
"poznamka": sn_poznamka,
"pred_org": sn_predano_org,
"evidencni": sn_evidencni,
"pristupnost": sn_pristupnost,
"wkt": wkt,
"wkt_is_wgs": wkt_is_wgs,
}
nalezy[sn_id] = meta
if not entries_with_geom:
iface.messageBar().pushMessage(
"AMCR",
f"Nalezeno {len(docs)} záznamů, ale žádný nemá geometrii.",
level=Qgis.MessageLevel.Warning
)
return
# ==========================================
# D) LAYER CREATION (QGIS Memory Layers)
# ==========================================
archeologicky_zaznam = "Akce" if typ_dat == "akce" else "Lokalita"
archeologicky_zaznam = typ_dat_vocab[typ_dat]
# Initialize three layers for different geometry types (S-JTSK CRS)
archeologicky_zaznam_esc = archeologicky_zaznam.replace(" ", "_")
vl_poly = QgsVectorLayer(
"Polygon?crs=epsg:5514",
f"AMCR_{archeologicky_zaznam}_Polygony",
f"AMCR_{archeologicky_zaznam_esc}_Polygony",
"memory"
)
vl_line = QgsVectorLayer(
"LineString?crs=epsg:5514",
f"AMCR_{archeologicky_zaznam}_Linie",
f"AMCR_{archeologicky_zaznam_esc}_Linie",
"memory"
)
vl_point = QgsVectorLayer(
"Point?crs=epsg:5514",
f"AMCR_{archeologicky_zaznam}_Body",
f"AMCR_{archeologicky_zaznam_esc}_Body",
"memory"
)
layers = [vl_poly, vl_line, vl_point]
# Define attribute table structure
cols = []
if typ_dat in ["akce", "lokalita"]:
cols = [
QgsField("pian", QMetaType.Type.QString),
QgsField("presnost", QMetaType.Type.QString),
QgsField("pian_typ", QMetaType.Type.QString),
QgsField("dj", QMetaType.Type.QString),
QgsField("typ_dj", QMetaType.Type.QString),
QgsField("definicni_body", QMetaType.Type.QString),
]
cols += [
QgsField(typ_dat, QMetaType.Type.QString),
QgsField("definicni_body", QMetaType.Type.QString),
QgsField("odkaz_do_digiarchivu", QMetaType.Type.QString),
QgsField("okres", QMetaType.Type.QString),
QgsField("katastr", QMetaType.Type.QString),
QgsField("dalsi_katastry", QMetaType.Type.QString)
QgsField("dalsi_katastry", QMetaType.Type.QString),
]
# Extend table based on data type
@@ -741,6 +970,7 @@ def load_amcr_data(canvas, bb, filters=None,
QgsField("vedlejsi_typ", QMetaType.Type.QString),
QgsField("zjisteni", QMetaType.Type.QString),
QgsField("nahrazuje_NZ", QMetaType.Type.QString),
QgsField("projekt", QMetaType.Type.QString),
]
elif typ_dat == "lokalita":
cols += [
@@ -748,10 +978,27 @@ def load_amcr_data(canvas, bb, filters=None,
QgsField("popis_lokality", QMetaType.Type.QString),
QgsField("typ_lokality", QMetaType.Type.QString),
QgsField("druh_lokality", QMetaType.Type.QString),
QgsField("zachovalost", QMetaType.Type.QString)
QgsField("zachovalost", QMetaType.Type.QString),
]
elif typ_dat == "samostatny_nalez":
cols += [
QgsField("projekt", QMetaType.Type.QString),
QgsField("nalezce", QMetaType.Type.QString),
QgsField("datum", QMetaType.Type.QString),
QgsField("okolnosti", QMetaType.Type.QString),
QgsField("hloubka_cm", QMetaType.Type.QString),
QgsField("lokalizace", QMetaType.Type.QString),
QgsField("obdobi", QMetaType.Type.QString),
QgsField("presna_datace", QMetaType.Type.QString),
QgsField("nalez", QMetaType.Type.QString),
QgsField("material", QMetaType.Type.QString),
QgsField("pocet", QMetaType.Type.QString),
QgsField("poznamka", QMetaType.Type.QString),
QgsField("pred_org", QMetaType.Type.QString),
QgsField("evidencni", QMetaType.Type.QString),
]
cols.append(QgsField("Přístupnost", QMetaType.Type.QString))
cols.append(QgsField("pristupnost", QMetaType.Type.QString))
# Use aliases for technical field names
alias_map = {
@@ -784,6 +1031,21 @@ def load_amcr_data(canvas, bb, filters=None,
"komponenta": "Komponenta",
"komponenta_areal": "Areál",
"komponenta_obdobi": "Období",
"projekt": "Projekt",
"pristupnost": "Přístupnost",
"nalezce": "Nálezce",
"datum": "Datum nálezu",
"okolnosti": "Nálezové okolnosti",
"hloubka_cm": "Hloubka (cm)",
"lokalizace": "Lokalizace",
"obdobi": "Období",
"presna_datace": "Přesná datace",
"nalez": "Nález",
"material": "Materiál",
"pocet": "Počet předmětů",
"poznamka": "Poznámka/bližší popis",
"pred_org": "Předáno organizaci",
"evidencni": "Evidenční číslo",
}
if komponenty == "true":
@@ -813,6 +1075,7 @@ def load_amcr_data(canvas, bb, filters=None,
)
# --- FEATURE POPULATION ---
if typ_dat in ["akce", "lokalita"]:
for doc in docs_pian:
try:
pid = doc.get('ident_cely', '')
@@ -898,13 +1161,13 @@ def load_amcr_data(canvas, bb, filters=None,
pian_typ,
meta['dj_id'],
meta['dj_typ_value'],
meta['loc'],
meta['ident_cely'],
meta['loc'],
"https://digiarchiv.aiscr.cz/id/"
+ meta['ident_cely'],
meta['az_okres'],
meta['katastr'],
meta['dalsi_katastr']
meta['dalsi_katastr'],
]
if is_akce:
atributy.extend([
@@ -917,7 +1180,8 @@ def load_amcr_data(canvas, bb, filters=None,
meta['akce_hlavni_typ'],
meta['akce_vedlejsi_typ'],
meta['dj_negativni'],
meta['akce_je_nz']
meta['akce_je_nz'],
meta['projekt'],
])
else:
atributy.extend([
@@ -925,7 +1189,7 @@ def load_amcr_data(canvas, bb, filters=None,
meta['lokalita_popis'],
meta['lokalita_typ'],
meta['lokalita_druh'],
meta['lokalita_zachovalost']
meta['lokalita_zachovalost'],
])
atributy.append(meta['pristupnost'])
@@ -946,6 +1210,68 @@ def load_amcr_data(canvas, bb, filters=None,
"AMČR", Qgis.MessageLevel.Warning
)
elif typ_dat == "samostatny_nalez":
for n in nalezy.values():
try:
wkt = n["wkt"]
wkt_is_wgs = n["wkt_is_wgs"]
if wkt:
geom = QgsGeometry.fromWkt(wkt)
if geom.isNull():
continue
if wkt_is_wgs:
geom.transform(xform_wgs_to_sjtsk)
if not geom.isGeosValid():
geom = geom.makeValid()
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
feat = QgsFeature()
feat.setGeometry(geom)
atributy = [
n["ident_cely"],
n["loc"],
"https://digiarchiv.aiscr.cz/id/"
+ n['ident_cely'],
n["okres"],
n["katastr"],
"",
n["projekt"],
n["nalezce"],
n["datum"],
n["okolnosti"],
n["hloubka_cm"],
n["lokalizace"],
n["obdobi"],
n["presna_datace"],
n["nalez"],
n["material"],
n["pocet"],
n["poznamka"],
n["pred_org"],
n["evidencni"],
n["pristupnost"],
]
feat.setAttributes(atributy)
target_list.append(feat)
except Exception as ex:
QgsMessageLog.logMessage(
f"Chyba při tvorbě feature: {ex}",
"AMČR", Qgis.MessageLevel.Warning
)
# --- ADDING TO QGIS INTERFACE ---
proj = QgsProject.instance()
added = 0
@@ -955,12 +1281,12 @@ def load_amcr_data(canvas, bb, filters=None,
(feats_pt, vl_point, "Body"),
]
for f, l, n in layers_to_process:
for f, L, n in layers_to_process:
if f:
l.dataProvider().addFeatures(f)
l.updateExtents()
l.setName(f"AMCR_{archeologicky_zaznam}_{n}")
proj.addMapLayer(l)
L.dataProvider().addFeatures(f)
L.updateExtents()
L.setName(f"AMCR_{archeologicky_zaznam_esc}_{n}")
proj.addMapLayer(L)
added += len(f)
if network_error:
@@ -978,7 +1304,7 @@ def load_amcr_data(canvas, bb, filters=None,
elif added > 0:
iface.messageBar().pushMessage(
"AMCR",
f"Hotovo. Záznamů: {len(docs)} (s geom: {actions_with_geom}). "
f"Hotovo. Záznamů: {len(docs)} (s geom: {entries_with_geom}). "
f"Vykresleno: {added} prvků.",
level=Qgis.MessageLevel.Success
)
+12 -1
View File
@@ -90,6 +90,7 @@ class AmcrViewer:
"""
# Define paths for action-specific icons
icon_akce_path = os.path.join(self.plugin_dir, 'akce.png')
icon_pas_path = os.path.join(self.plugin_dir, 'sn.png')
icon_lokality_path = os.path.join(self.plugin_dir, 'lokality.png')
icon_amcr_help_path = os.path.join(self.plugin_dir, 'amcr-help.png')
@@ -109,6 +110,16 @@ class AmcrViewer:
)
self.plugin_menu.addAction(self.action_download_akce)
self.action_download_pas = self.add_action(
icon_path=icon_pas_path,
text=self.tr(u'Stáhnout data samostatných nálezů | AMČR Viewer'),
callback=lambda checked=False: self.run_download('samostatny_nalez'),
parent=self.iface.mainWindow(),
add_to_menu=False,
add_to_toolbar=False
)
self.plugin_menu.addAction(self.action_download_pas)
self.action_download_lokality = self.add_action(
icon_path=icon_lokality_path,
text=self.tr(u'Stáhnout data lokalit | AMČR Viewer'),
@@ -120,7 +131,7 @@ class AmcrViewer:
self.plugin_menu.addAction(self.action_download_lokality)
self.action_login_dialog = self.add_action(
icon_path=icon_akce_path,
icon_path=icon_pas_path,
text=self.tr(u'Přihlásit se | AMČR Viewer'),
callback=lambda checked=False: self.login(),
parent=self.iface.mainWindow(),
File diff suppressed because it is too large. Load diff
+11 -4
View File
@@ -8,7 +8,7 @@ name=AMČR Viewer
qgisMinimumVersion=3.44.0
qgisMaximumVersion=4.99.0
description=Viewing and downloading the AMČR data.
version=2.0.2
version=2.1.0
author=David Spáčil
email=spacil@arub.cz
@@ -23,9 +23,16 @@ repository=https://github.com/ARUP-CAS/aiscr-qgis-amcr-viewer
hasProcessingProvider=no
# Uncomment the following line and add your changelog:
changelog=
Aktualizace na verzi 2.0.0 výrazně mění chování pluginu. Před updatem je doporučeno přečíst si seznam změn níže.
Version 2.0.0 changes the plugin behavior dramatically. It is advised to check the changelog below.
Plný seznam změn v češtině je dostupný zde: https://github.com/ARUP-CAS/aiscr-qgis-amcr-viewer/releases/tag/v2.0.0
Plný seznam změn v češtině je dostupný zde: https://github.com/ARUP-CAS/aiscr-qgis-amcr-viewer/releases/tag/v2.1.0
v2.1.0 (2026-09-01)
* Added download of Individual finds (PAS), including a dedicated menu entry
* Added filtering by date
* Added filtering of project fieldwork events and a Project column in the attribute table
* Codelists are now built from the Digiarchiv API as well, not only from OAI-PMH; persons come from its facets by role (finder, fieldwork leader)
* Updated the bundled codelist file
* Filter dialog is now scrollable
* Empty API results are now diagnosable from the Messages panel: the query URL and API errors are logged
* Code clean-up
v2.0.2 (2026-06-13)
* Plugin-wide fixes and optimalizations (details https://github.com/ARUP-CAS/aiscr-qgis-amcr-viewer/pull/49)
v2.0.1 (2026-06-10)
Binary file not shown.

After

Width:  |  Height:  |  Size: 871 B

+23 -7
View File
@@ -9,7 +9,7 @@
id="svg1"
xml:space="preserve"
sodipodi:docname="icon.svg"
inkscape:version="1.4.3 (0d15f75, 2025-12-25)"
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
@@ -24,12 +24,12 @@
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
inkscape:zoom="22.627417"
inkscape:cx="6.8059028"
inkscape:cy="14.606174"
inkscape:cx="0.57452426"
inkscape:cy="15.556349"
inkscape:window-width="1920"
inkscape:window-height="1009"
inkscape:window-x="1912"
inkscape:window-y="-8"
inkscape:window-height="1131"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" /><defs
id="defs1"><clipPath
@@ -96,4 +96,20 @@
d="M 3.3486328 2.4970052 L 3.3486328 3.8323242 L 2.7424683 3.8323242 L 4.2085286 5.738151 L 5.674589 3.8323242 L 5.0684245 3.8323242 L 5.0684245 2.4970052 L 3.3486328 2.4970052 z "
inkscape:export-filename="path32.png"
inkscape:export-xdpi="3000"
inkscape:export-ydpi="3000" /></g></svg>
inkscape:export-ydpi="3000" /><rect
style="opacity:1;fill:#85140e;fill-opacity:1;stroke:none;stroke-width:0.414999;stroke-linecap:butt;stroke-linejoin:round;stroke-dasharray:none;paint-order:stroke fill markers"
id="rect1"
width="1.672105"
height="1.2511554"
x="-4.501821"
y="4.2814822" /><text
xml:space="preserve"
style="font-size:0.705556px;font-family:'Open Sans';-inkscape-font-specification:'Open Sans';text-align:start;writing-mode:lr-tb;direction:ltr;text-anchor:start;opacity:1;fill:#85140e;fill-opacity:1;stroke:none;stroke-width:0.414999;stroke-linecap:butt;stroke-linejoin:round;stroke-dasharray:none;paint-order:stroke fill markers"
x="-2.6360023"
y="5.1136947"
id="text1"><tspan
sodipodi:role="line"
id="tspan1"
style="font-size:0.705556px;fill:#85140e;fill-opacity:1;stroke-width:0.415"
x="-2.6360023"
y="5.1136947">lokality</tspan></text></g></svg>

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB