Compare commits

..

14 Commits

Author SHA1 Message Date
Terranom674
29990aee4f 0.9.7.24: Version anheben 2026-08-20 09:00:53 +02:00
Terranom674
9c551c8dfc 0.9.7.24: Piwigo ajaxload fuer WebDAV-Platzhalter bedienen 2026-08-20 09:00:36 +02:00
Terranom674
e5bc260622 Merge pull request #45 from Terranom674/fix/09723-visible-run-status
0.9.7.23: Abgleich sichtbar ausführen und Fehlerdetails anzeigen
2026-08-20 08:34:32 +02:00
Terranom674
5b883706c7 0.9.7.23: Version anheben 2026-08-20 08:33:27 +02:00
Terranom674
66f463422e 0.9.7.23: Konkrete Shadow-Tree-Fehler in Diagnose übernehmen 2026-08-20 08:32:51 +02:00
Terranom674
df9e9e6424 0.9.7.23: Verbindungsbezogenen Live-Status bereitstellen 2026-08-20 08:32:23 +02:00
Terranom674
26b602783f 0.9.7.23: Abgleich sichtbar und ohne Seitenreload ausführen 2026-08-20 08:32:04 +02:00
Terranom674
ae1b47c4c4 Merge pull request #44 from Terranom674/fix/09722-placeholder-dimension-fallback
0.9.7.22: Originalmaße robust aus Nextcloud übernehmen
2026-08-20 08:24:54 +02:00
Terranom674
6f2f39cd37 0.9.7.22: Version anheben 2026-08-20 08:23:51 +02:00
Terranom674
2794719ff3 0.9.7.22: Originalmaße bei fehlenden NC-Metadaten aus Bildkopf lesen 2026-08-20 08:23:11 +02:00
Terranom674
941d0cd7bb Merge pull request #43 from Terranom674/fix/09721-placeholder-original-dimensions
0.9.7.21: Originalmaße direkt in Platzhalter tragen
2026-08-20 08:13:55 +02:00
Terranom674
392038ce7e 0.9.7.21: Version anheben 2026-08-20 08:12:29 +02:00
Terranom674
3aa82b18f4 0.9.7.21: Originalmasse in WebDAV-Platzhalter schreiben 2026-08-20 08:11:49 +02:00
Terranom674
0a76919a21 Merge pull request #42 from Terranom674/fix/09720-restore-pre-ui-image-path
0.9.7.20: NC-Bildpfad auf funktionierenden Pre-UI-Stand zurück
2026-08-20 08:03:49 +02:00
6 changed files with 441 additions and 94 deletions

View File

@@ -1,6 +1,134 @@
(function () { (function () {
'use strict'; 'use strict';
var statusEndpoint = 'plugins/bratonien_tools/nc-connector-status.php';
function ensureLiveStatus(detail, actions) {
var node = detail.querySelector('[data-nc-run-live]');
if (node) return node;
node = document.createElement('div');
node.setAttribute('data-nc-run-live', '1');
node.className = 'bratonien-base-note';
node.style.marginTop = '.6rem';
node.hidden = true;
actions.parentNode.insertBefore(node, actions.nextSibling);
return node;
}
function renderLiveStatus(node, data) {
var state = String(data && data.state || '');
var message = String(data && data.message || '');
var detail = String(data && data.error_detail || '');
node.hidden = false;
node.innerHTML = '';
var strong = document.createElement('strong');
if (state === 'queued') strong.textContent = 'Angefordert: ';
else if (state === 'running') strong.textContent = 'Läuft: ';
else if (state === 'ok' || state === 'success') strong.textContent = 'Erfolgreich: ';
else if (state === 'error') strong.textContent = 'Fehler: ';
else strong.textContent = 'Status: ';
node.appendChild(strong);
node.appendChild(document.createTextNode(message || state || 'Status wird ermittelt …'));
if (detail) {
var details = document.createElement('details');
details.style.marginTop = '.35rem';
var summary = document.createElement('summary');
summary.textContent = 'Technische Laufzeitdetails';
var pre = document.createElement('pre');
pre.style.whiteSpace = 'pre-wrap';
pre.style.wordBreak = 'break-word';
pre.textContent = detail;
details.appendChild(summary);
details.appendChild(pre);
node.appendChild(details);
}
}
function pollConnection(connectionId, node, button) {
var attempts = 0;
var maxAttempts = 180;
var timer = null;
function stop() {
if (timer) window.clearInterval(timer);
timer = null;
if (button) {
button.disabled = false;
button.textContent = 'Jetzt abgleichen';
}
}
function poll() {
attempts += 1;
fetch(statusEndpoint + '?connection_id=' + encodeURIComponent(connectionId) + '&_=' + Date.now(), {
credentials: 'same-origin',
cache: 'no-store',
headers: {'Accept': 'application/json'}
})
.then(function (response) {
if (!response.ok) throw new Error('HTTP ' + response.status);
return response.json();
})
.then(function (data) {
renderLiveStatus(node, data);
var state = String(data && data.state || '');
if (state === 'ok' || state === 'success' || state === 'error' || attempts >= maxAttempts) stop();
})
.catch(function (error) {
if (attempts >= maxAttempts) {
renderLiveStatus(node, {state: 'error', message: 'Status konnte nicht gelesen werden.', error_detail: error.message});
stop();
}
});
}
poll();
timer = window.setInterval(poll, 1000);
}
function bindRunNow(form, detail, liveNode) {
if (form.dataset.ncRunBound === '1') return;
form.dataset.ncRunBound = '1';
form.addEventListener('submit', function (event) {
event.preventDefault();
detail.open = true;
var button = form.querySelector('button[value="nc_connector_run_now"]');
var connectionInput = form.querySelector('input[name="connection_id"]');
if (!connectionInput) return;
if (button) {
button.disabled = true;
button.textContent = 'Abgleich wird gestartet …';
}
renderLiveStatus(liveNode, {state: 'queued', message: 'Abgleich wird angefordert …'});
fetch(form.action || window.location.href, {
method: 'POST',
credentials: 'same-origin',
cache: 'no-store',
body: new FormData(form)
})
.then(function (response) {
if (!response.ok) throw new Error('HTTP ' + response.status);
renderLiveStatus(liveNode, {state: 'queued', message: 'Abgleich wurde angefordert.'});
pollConnection(connectionInput.value, liveNode, button);
})
.catch(function (error) {
renderLiveStatus(liveNode, {state: 'error', message: 'Abgleich konnte nicht angefordert werden.', error_detail: error.message});
if (button) {
button.disabled = false;
button.textContent = 'Jetzt abgleichen';
}
});
});
}
function restoreRunNowButtons() { function restoreRunNowButtons() {
var section = document.getElementById('nc-connector'); var section = document.getElementById('nc-connector');
if (!section) return; if (!section) return;
@@ -18,36 +146,42 @@
var details = connectionCard.querySelectorAll(':scope > details'); var details = connectionCard.querySelectorAll(':scope > details');
details.forEach(function (detail) { details.forEach(function (detail) {
var actions = detail.querySelector('.bratonien-actions'); var actions = detail.querySelector('.bratonien-actions');
if (!actions || actions.querySelector('[value="nc_connector_run_now"]')) return; if (!actions) return;
var connectionInput = detail.querySelector('input[name="connection_id"]'); var connectionInput = detail.querySelector('input[name="connection_id"]');
var tokenInput = detail.querySelector('input[name="pwg_token"]'); var tokenInput = detail.querySelector('input[name="pwg_token"]');
if (!connectionInput || !tokenInput) return; if (!connectionInput || !tokenInput) return;
var form = document.createElement('form'); var form = actions.querySelector('form[data-nc-run-now]');
form.method = 'post'; if (!form) {
form = document.createElement('form');
form.method = 'post';
form.setAttribute('data-nc-run-now', '1');
var token = document.createElement('input'); var token = document.createElement('input');
token.type = 'hidden'; token.type = 'hidden';
token.name = 'pwg_token'; token.name = 'pwg_token';
token.value = tokenInput.value; token.value = tokenInput.value;
form.appendChild(token); form.appendChild(token);
var connection = document.createElement('input'); var connection = document.createElement('input');
connection.type = 'hidden'; connection.type = 'hidden';
connection.name = 'connection_id'; connection.name = 'connection_id';
connection.value = connectionInput.value; connection.value = connectionInput.value;
form.appendChild(connection); form.appendChild(connection);
var button = document.createElement('button'); var button = document.createElement('button');
button.className = 'buttonLike'; button.className = 'buttonLike';
button.type = 'submit'; button.type = 'submit';
button.name = 'bratonien_tool'; button.name = 'bratonien_tool';
button.value = 'nc_connector_run_now'; button.value = 'nc_connector_run_now';
button.textContent = 'Jetzt abgleichen'; button.textContent = 'Jetzt abgleichen';
form.appendChild(button); form.appendChild(button);
actions.insertBefore(form, actions.firstChild); actions.insertBefore(form, actions.firstChild);
}
bindRunNow(form, detail, ensureLiveStatus(detail, actions));
}); });
} }

View File

@@ -1,7 +1,7 @@
<?php <?php
/* /*
Plugin Name: Bratonien Tools Plugin Name: Bratonien Tools
Version: 0.9.7.20 Version: 0.9.7.24
Description: Erweiterbare Administrationswerkzeuge fuer die Bratonien-Piwigo-Installation. Description: Erweiterbare Administrationswerkzeuge fuer die Bratonien-Piwigo-Installation.
Plugin URI: https://github.com/Terranom674/Piwigo_Bratonien_Tools Plugin URI: https://github.com/Terranom674/Piwigo_Bratonien_Tools
Author: Bratonien Author: Bratonien

View File

@@ -24,6 +24,7 @@ if (!function_exists('is_admin') || !is_admin())
exit; exit;
} }
$requested_connection_id = isset($_GET['connection_id']) ? max(0, (int)$_GET['connection_id']) : 0;
$dir = PHPWG_ROOT_PATH.'_data/bratonien-tools/nc-connector-status'; $dir = PHPWG_ROOT_PATH.'_data/bratonien-tools/nc-connector-status';
$latest = array( $latest = array(
'state'=>'idle', 'state'=>'idle',
@@ -41,11 +42,16 @@ $latest = array(
'route_timestamp'=>0, 'route_timestamp'=>0,
'route_time_label'=>'Nicht verfügbar', 'route_time_label'=>'Nicht verfügbar',
'route_detail'=>'', 'route_detail'=>'',
'connection_id'=>$requested_connection_id,
); );
if (is_dir($dir)) if (is_dir($dir))
{ {
foreach (glob($dir.'/connection-*.json') ?: array() as $file) $files = $requested_connection_id > 0
? array($dir.'/connection-'.$requested_connection_id.'.json')
: (glob($dir.'/connection-*.json') ?: array());
foreach ($files as $file)
{ {
if (!is_readable($file)) if (!is_readable($file))
{ {
@@ -63,60 +69,58 @@ if (is_dir($dir))
} }
} }
$route_file = $dir.'/route-status.json'; if ($requested_connection_id === 0)
if (is_readable($route_file))
{ {
$route = json_decode((string)@file_get_contents($route_file), true); $route_file = $dir.'/route-status.json';
if (is_array($route)) if (is_readable($route_file))
{ {
$route_name = (string)($route['route'] ?? ''); $route = json_decode((string)@file_get_contents($route_file), true);
$route_timestamp = (int)($route['timestamp'] ?? 0); if (is_array($route))
$route_label = (string)($route['label'] ?? ''); {
$route_name = (string)($route['route'] ?? '');
$route_timestamp = (int)($route['timestamp'] ?? 0);
$route_label = (string)($route['label'] ?? '');
if ($route_name === 'webdav') if ($route_name === 'webdav')
{ {
$route_label = 'WEBDAV PRIMÄR'; $route_label = 'WEBDAV PRIMÄR';
} }
elseif ($route_name === 'legacy_fallback') elseif ($route_name === 'legacy_fallback')
{ {
$route_label = 'LEGACY-FALLBACK AKTIV'; $route_label = 'LEGACY-FALLBACK AKTIV';
} }
elseif ($route_name === 'failed') elseif ($route_name === 'failed')
{ {
$route_label = 'FEHLER - KEIN ERFOLGREICHER DATENWEG'; $route_label = 'FEHLER - KEIN ERFOLGREICHER DATENWEG';
} }
elseif ($route_label === '') elseif ($route_label === '')
{ {
$route_label = 'UNBEKANNTER DATENWEG'; $route_label = 'UNBEKANNTER DATENWEG';
} }
$route_detail = trim((string)($route['detail'] ?? '')); $route_detail = trim((string)($route['detail'] ?? ''));
$latest['route'] = $route_name; $latest['route'] = $route_name;
$latest['route_label'] = $route_label; $latest['route_label'] = $route_label;
$latest['route_timestamp'] = $route_timestamp; $latest['route_timestamp'] = $route_timestamp;
$latest['route_time_label'] = $route_timestamp > 0 ? date('d.m.Y H:i:s', $route_timestamp) : 'Nicht verfügbar'; $latest['route_time_label'] = $route_timestamp > 0 ? date('d.m.Y H:i:s', $route_timestamp) : 'Nicht verfügbar';
$latest['route_detail'] = $route_detail; $latest['route_detail'] = $route_detail;
$base_message = trim((string)($latest['message'] ?? '')); $base_message = trim((string)($latest['message'] ?? ''));
$message_parts = array($route_label); $message_parts = array($route_label);
if ($route_name !== 'webdav' && $route_detail !== '') if ($route_name !== 'webdav' && $route_detail !== '')
{ {
$message_parts[] = $route_detail; $message_parts[] = $route_detail;
}
if ($base_message !== '')
{
$message_parts[] = $base_message;
}
$latest['message'] = implode(' · ', $message_parts);
} }
if ($base_message !== '')
{
$message_parts[] = $base_message;
}
$latest['message'] = implode(' · ', $message_parts);
} }
} }
} }
if ((int)$latest['timestamp'] > 0)
{
$latest['last_run_label'] = date('d.m.Y H:i:s', (int)$latest['timestamp']);
}
$system_file = BRATONIEN_TOOLS_PATH.'include/nc_connector_system.inc.php'; $system_file = BRATONIEN_TOOLS_PATH.'include/nc_connector_system.inc.php';
if (is_readable($system_file)) if (is_readable($system_file))
{ {
@@ -129,4 +133,27 @@ if (is_readable($system_file))
} }
} }
$scheduler_file = PHPWG_ROOT_PATH.'_data/bratonien-tools/nc-connector-scheduler/state.json';
if ($requested_connection_id > 0 && is_readable($scheduler_file))
{
$scheduler = json_decode((string)@file_get_contents($scheduler_file), true);
if (
is_array($scheduler)
&& (int)($scheduler['connection_id'] ?? 0) === $requested_connection_id
&& in_array((string)($scheduler['state'] ?? ''), array('queued','running'), true)
&& (int)($scheduler['timestamp'] ?? 0) >= (int)($latest['timestamp'] ?? 0)
)
{
$latest['state'] = (string)$scheduler['state'];
$latest['message'] = (string)($scheduler['message'] ?? 'NC-Abgleich läuft.');
$latest['timestamp'] = (int)($scheduler['timestamp'] ?? time());
$latest['error_detail'] = '';
}
}
if ((int)$latest['timestamp'] > 0)
{
$latest['last_run_label'] = date('d.m.Y H:i:s', (int)$latest['timestamp']);
}
echo json_encode($latest, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); echo json_encode($latest, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);

View File

@@ -2,8 +2,12 @@
"""Build a placeholder-backed local source tree from Nextcloud WebDAV. """Build a placeholder-backed local source tree from Nextcloud WebDAV.
This creates only tiny placeholder files plus a metadata mapping; no Nextcloud This creates only tiny placeholder files plus a metadata mapping; no Nextcloud
original media is downloaded. The authenticated Nextcloud user is never used as original media is stored locally. The authenticated Nextcloud user is never used
an album name. as an album name.
Each placeholder carries the original image dimensions. Dimensions are read
from Nextcloud metadata first. If those metadata are unavailable, only the
beginning of the original file is read through WebDAV and parsed locally.
""" """
from __future__ import annotations from __future__ import annotations
@@ -27,8 +31,10 @@ from pathlib import Path, PurePosixPath
DAV = "DAV:" DAV = "DAV:"
OC = "http://owncloud.org/ns" OC = "http://owncloud.org/ns"
NC = "http://nextcloud.org/ns"
SUPPORTED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp"} SUPPORTED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
PLACEHOLDER = base64.b64decode("R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==") PLACEHOLDER = base64.b64decode("R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==")
DIMENSION_PROBE_BYTES = 4 * 1024 * 1024
def fail(message: str) -> None: def fail(message: str) -> None:
@@ -54,6 +60,101 @@ def safe_local_name(name: str) -> str:
return name return name
def parse_dimensions(prop: ET.Element) -> tuple[int, int]:
for property_name in ("file-metadata-size", "metadata-photos-size"):
raw = prop.findtext(f"{{{NC}}}{property_name}", default="").strip()
if not raw:
continue
try:
decoded = json.loads(raw)
except (TypeError, ValueError, json.JSONDecodeError):
continue
if isinstance(decoded, dict) and isinstance(decoded.get("value"), dict):
decoded = decoded["value"]
if not isinstance(decoded, dict):
continue
try:
width = int(decoded.get("width", 0) or 0)
height = int(decoded.get("height", 0) or 0)
except (TypeError, ValueError):
continue
if width > 0 and height > 0:
return width, height
return 0, 0
def parse_image_header_dimensions(data: bytes) -> tuple[int, int]:
if len(data) >= 24 and data.startswith(b"\x89PNG\r\n\x1a\n") and data[12:16] == b"IHDR":
return int.from_bytes(data[16:20], "big"), int.from_bytes(data[20:24], "big")
if len(data) >= 10 and data[:6] in (b"GIF87a", b"GIF89a"):
return int.from_bytes(data[6:8], "little"), int.from_bytes(data[8:10], "little")
if len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP":
chunk = data[12:16]
if chunk == b"VP8X" and len(data) >= 30:
width = 1 + int.from_bytes(data[24:27], "little")
height = 1 + int.from_bytes(data[27:30], "little")
return width, height
if chunk == b"VP8 " and len(data) >= 30:
payload = 20
if data[payload + 3:payload + 6] == b"\x9d\x01\x2a":
width = int.from_bytes(data[payload + 6:payload + 8], "little") & 0x3FFF
height = int.from_bytes(data[payload + 8:payload + 10], "little") & 0x3FFF
return width, height
if chunk == b"VP8L" and len(data) >= 25 and data[20] == 0x2F:
b1, b2, b3, b4 = data[21:25]
width = 1 + b1 + ((b2 & 0x3F) << 8)
height = 1 + ((b2 & 0xC0) >> 6) + (b3 << 2) + ((b4 & 0x0F) << 10)
return width, height
if len(data) >= 4 and data[:2] == b"\xff\xd8":
sof_markers = {
0xC0, 0xC1, 0xC2, 0xC3,
0xC5, 0xC6, 0xC7,
0xC9, 0xCA, 0xCB,
0xCD, 0xCE, 0xCF,
}
pos = 2
while pos + 4 <= len(data):
if data[pos] != 0xFF:
pos += 1
continue
while pos < len(data) and data[pos] == 0xFF:
pos += 1
if pos >= len(data):
break
marker = data[pos]
pos += 1
if marker in (0xD8, 0xD9) or 0xD0 <= marker <= 0xD7:
continue
if marker == 0xDA:
break
if pos + 2 > len(data):
break
segment_length = int.from_bytes(data[pos:pos + 2], "big")
if segment_length < 2:
break
if marker in sof_markers:
if pos + 7 > len(data):
break
height = int.from_bytes(data[pos + 3:pos + 5], "big")
width = int.from_bytes(data[pos + 5:pos + 7], "big")
return width, height
pos += segment_length
return 0, 0
def placeholder_bytes(width: int, height: int) -> bytes:
if not (1 <= width <= 65535 and 1 <= height <= 65535):
fail(f"unsupported image dimensions for placeholder: {width}x{height}")
data = bytearray(PLACEHOLDER)
data[6:8] = width.to_bytes(2, "little")
data[8:10] = height.to_bytes(2, "little")
return bytes(data)
@contextmanager @contextmanager
def pinned_resolution(host: str, ip: str): def pinned_resolution(host: str, ip: str):
host = host.strip("[]").lower() host = host.strip("[]").lower()
@@ -92,22 +193,50 @@ class WebDavClient:
self.auth_header = f"Basic {token}" self.auth_header = f"Basic {token}"
self.context = ssl.create_default_context() self.context = ssl.create_default_context()
def collection_url(self, relative: str) -> str: def file_url(self, relative: str) -> str:
user = urllib.parse.quote(self.user, safe="") user = urllib.parse.quote(self.user, safe="")
suffix = quote_path(relative) suffix = quote_path(relative)
url = f"{self.base_url}/remote.php/dav/files/{user}/" return f"{self.base_url}/remote.php/dav/files/{user}/{suffix}"
if suffix:
url += suffix + "/" def collection_url(self, relative: str) -> str:
url = self.file_url(relative)
if not url.endswith("/"):
url += "/"
return url return url
def probe_dimensions(self, relative: str) -> tuple[int, int]:
request = urllib.request.Request(self.file_url(relative), method="GET")
request.add_header("Authorization", self.auth_header)
request.add_header("Range", f"bytes=0-{DIMENSION_PROBE_BYTES - 1}")
try:
with pinned_resolution(self.host, self.connect_ip):
with urllib.request.urlopen(request, timeout=self.timeout, context=self.context) as response:
if response.status not in (200, 206):
fail(f"Nextcloud image header returned HTTP {response.status}")
data = response.read(DIMENSION_PROBE_BYTES)
except urllib.error.HTTPError as error:
if error.code in {401, 403}:
fail("Nextcloud rejected the WebDAV credentials or file access")
fail(f"Nextcloud image header request failed with HTTP {error.code}")
except urllib.error.URLError as error:
fail(f"Nextcloud WebDAV is unreachable while reading image dimensions: {error.reason}")
width, height = parse_image_header_dimensions(data)
if width < 1 or height < 1:
fail(f"original image dimensions could not be read from Nextcloud file header: {relative}")
return width, height
def list_collection(self, relative: str) -> tuple[dict[str, object], list[dict[str, object]]]: def list_collection(self, relative: str) -> tuple[dict[str, object], list[dict[str, object]]]:
relative = validate_relative(relative) relative = validate_relative(relative)
url = self.collection_url(relative) url = self.collection_url(relative)
body = ( body = (
'<?xml version="1.0"?>' '<?xml version="1.0"?>'
'<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">' '<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" '
'xmlns:nc="http://nextcloud.org/ns">'
'<d:prop><d:displayname/><d:resourcetype/><d:getcontenttype/>' '<d:prop><d:displayname/><d:resourcetype/><d:getcontenttype/>'
'<d:getcontentlength/><d:getetag/><oc:fileid/></d:prop></d:propfind>' '<d:getcontentlength/><d:getetag/><oc:fileid/>'
'<nc:file-metadata-size/><nc:metadata-photos-size/>'
'</d:prop></d:propfind>'
).encode("utf-8") ).encode("utf-8")
request = urllib.request.Request(url, data=body, method="PROPFIND") request = urllib.request.Request(url, data=body, method="PROPFIND")
request.add_header("Authorization", self.auth_header) request.add_header("Authorization", self.auth_header)
@@ -151,6 +280,7 @@ class WebDavClient:
fileid_text = prop.findtext(f"{{{OC}}}fileid", default="").strip() fileid_text = prop.findtext(f"{{{OC}}}fileid", default="").strip()
resource_type = prop.find(f"{{{DAV}}}resourcetype") resource_type = prop.find(f"{{{DAV}}}resourcetype")
is_dir = resource_type is not None and resource_type.find(f"{{{DAV}}}collection") is not None is_dir = resource_type is not None and resource_type.find(f"{{{DAV}}}collection") is not None
width, height = parse_dimensions(prop)
item = { item = {
"display_name": display, "display_name": display,
"fileid": int(fileid_text) if fileid_text.isdigit() else 0, "fileid": int(fileid_text) if fileid_text.isdigit() else 0,
@@ -158,6 +288,8 @@ class WebDavClient:
"content_type": prop.findtext(f"{{{DAV}}}getcontenttype", default=""), "content_type": prop.findtext(f"{{{DAV}}}getcontenttype", default=""),
"size": int(prop.findtext(f"{{{DAV}}}getcontentlength", default="0") or 0), "size": int(prop.findtext(f"{{{DAV}}}getcontentlength", default="0") or 0),
"etag": prop.findtext(f"{{{DAV}}}getetag", default="").strip('"'), "etag": prop.findtext(f"{{{DAV}}}getetag", default="").strip('"'),
"width": width,
"height": height,
} }
if href_path == base_path: if href_path == base_path:
current = item current = item
@@ -169,18 +301,17 @@ class WebDavClient:
return current, children return current, children
def link_placeholder(seed: Path, target: Path) -> None: def write_placeholder(target: Path, width: int, height: int) -> None:
target.parent.mkdir(parents=True, exist_ok=True) target.parent.mkdir(parents=True, exist_ok=True)
try: target.write_bytes(placeholder_bytes(width, height))
os.link(seed, target) os.chmod(target, 0o644)
except OSError:
target.write_bytes(PLACEHOLDER)
def build_root(client: WebDavClient, remote_root: str, local_root: Path, seed: Path, mapping: dict[str, dict[str, object]]) -> tuple[int, int, int]: def build_root(client: WebDavClient, remote_root: str, local_root: Path, mapping: dict[str, dict[str, object]]) -> tuple[int, int, int, int]:
files = 0 files = 0
folders = 0 folders = 0
skipped = 0 skipped = 0
probed = 0
stack: list[tuple[str, Path]] = [(validate_relative(remote_root), local_root)] stack: list[tuple[str, Path]] = [(validate_relative(remote_root), local_root)]
visited: set[str] = set() visited: set[str] = set()
@@ -210,7 +341,16 @@ def build_root(client: WebDavClient, remote_root: str, local_root: Path, seed: P
if extension not in SUPPORTED_IMAGE_EXTENSIONS: if extension not in SUPPORTED_IMAGE_EXTENSIONS:
skipped += 1 skipped += 1
continue continue
link_placeholder(seed, child_local)
width = int(child.get("width", 0) or 0)
height = int(child.get("height", 0) or 0)
dimension_source = "metadata"
if width < 1 or height < 1:
width, height = client.probe_dimensions(child_remote)
dimension_source = "header"
probed += 1
write_placeholder(child_local, width, height)
files += 1 files += 1
mapping[str(child_local)] = { mapping[str(child_local)] = {
"kind": "file", "kind": "file",
@@ -220,8 +360,11 @@ def build_root(client: WebDavClient, remote_root: str, local_root: Path, seed: P
"content_type": str(child.get("content_type", "")), "content_type": str(child.get("content_type", "")),
"size": int(child.get("size", 0)), "size": int(child.get("size", 0)),
"etag": str(child.get("etag", "")), "etag": str(child.get("etag", "")),
"width": width,
"height": height,
"dimension_source": dimension_source,
} }
return files, folders, skipped return files, folders, skipped, probed
def atomic_json(path: Path, payload: object) -> None: def atomic_json(path: Path, payload: object) -> None:
@@ -265,10 +408,7 @@ def main() -> int:
source_dir = args.source_dir.resolve() source_dir = args.source_dir.resolve()
staging = source_dir.with_name(f".{source_dir.name}.next") staging = source_dir.with_name(f".{source_dir.name}.next")
previous = source_dir.with_name(f".{source_dir.name}.previous") previous = source_dir.with_name(f".{source_dir.name}.previous")
seed = source_dir.parent / ".bratonien-webdav-placeholder.gif"
source_dir.parent.mkdir(parents=True, exist_ok=True) source_dir.parent.mkdir(parents=True, exist_ok=True)
seed.write_bytes(PLACEHOLDER)
os.chmod(seed, 0o644)
if staging.exists(): if staging.exists():
shutil.rmtree(staging) shutil.rmtree(staging)
staging.mkdir(parents=True) staging.mkdir(parents=True)
@@ -276,7 +416,7 @@ def main() -> int:
client = WebDavClient(args.base_url, args.user, password, max(1, args.timeout), args.connect_ip) client = WebDavClient(args.base_url, args.user, password, max(1, args.timeout), args.connect_ip)
mapping: dict[str, dict[str, object]] = {} mapping: dict[str, dict[str, object]] = {}
manifest: list[str] = [] manifest: list[str] = []
total_files = total_folders = total_skipped = 0 total_files = total_folders = total_skipped = total_probed = 0
used_fileids: set[int] = set() used_fileids: set[int] = set()
for remote_root_raw in args.root: for remote_root_raw in args.root:
@@ -288,10 +428,11 @@ def main() -> int:
used_fileids.add(fileid) used_fileids.add(fileid)
local_name = f"root-{fileid}" local_name = f"root-{fileid}"
local_root = staging / local_name local_root = staging / local_name
files, folders, skipped = build_root(client, remote_root, local_root, seed, mapping) files, folders, skipped, probed = build_root(client, remote_root, local_root, mapping)
total_files += files total_files += files
total_folders += folders total_folders += folders
total_skipped += skipped total_skipped += skipped
total_probed += probed
if remote_root == "": if remote_root == "":
for child in sorted(root_children, key=lambda item: str(item.get("display_name", "")).casefold()): for child in sorted(root_children, key=lambda item: str(item.get("display_name", "")).casefold()):
@@ -333,7 +474,7 @@ def main() -> int:
atomic_text(args.manifest, "\n".join(manifest) + "\n") atomic_text(args.manifest, "\n".join(manifest) + "\n")
atomic_json(args.mapping, { atomic_json(args.mapping, {
"version": 1, "version": 3,
"base_url": args.base_url.rstrip("/"), "base_url": args.base_url.rstrip("/"),
"connect_ip": client.connect_ip, "connect_ip": client.connect_ip,
"user": args.user, "user": args.user,
@@ -344,6 +485,7 @@ def main() -> int:
"files": total_files, "files": total_files,
"folders": total_folders, "folders": total_folders,
"skipped": total_skipped, "skipped": total_skipped,
"dimension_header_probes": total_probed,
"connect_ip": client.connect_ip, "connect_ip": client.connect_ip,
"source_dir": str(source_dir), "source_dir": str(source_dir),
"manifest": str(args.manifest), "manifest": str(args.manifest),

View File

@@ -78,7 +78,9 @@ done < "$WEBDAV_ROOTS_FILE"
WEBDAV_CONNECT_IP="$(php "$SCRIPT_DIR/lib/resolve-nextcloud-target.php" "$WEBDAV_BASE_URL")" WEBDAV_CONNECT_IP="$(php "$SCRIPT_DIR/lib/resolve-nextcloud-target.php" "$WEBDAV_BASE_URL")"
[[ -n "$WEBDAV_CONNECT_IP" ]] || { write_status error "Nextcloud-Zieladresse konnte nicht ermittelt werden"; exit 1; } [[ -n "$WEBDAV_CONNECT_IP" ]] || { write_status error "Nextcloud-Zieladresse konnte nicht ermittelt werden"; exit 1; }
python3 "$SCRIPT_DIR/lib/build_webdav_placeholder_source.py" \ PLACEHOLDER_OUTPUT=""
PLACEHOLDER_EXIT=0
if PLACEHOLDER_OUTPUT="$(python3 "$SCRIPT_DIR/lib/build_webdav_placeholder_source.py" \
--base-url "$WEBDAV_BASE_URL" \ --base-url "$WEBDAV_BASE_URL" \
--connect-ip "$WEBDAV_CONNECT_IP" \ --connect-ip "$WEBDAV_CONNECT_IP" \
--user "$WEBDAV_USER" \ --user "$WEBDAV_USER" \
@@ -86,12 +88,42 @@ python3 "$SCRIPT_DIR/lib/build_webdav_placeholder_source.py" \
"${ROOT_ARGS[@]}" \ "${ROOT_ARGS[@]}" \
--source-dir "$WEBDAV_SOURCE_DIR" \ --source-dir "$WEBDAV_SOURCE_DIR" \
--manifest "$MANIFEST" \ --manifest "$MANIFEST" \
--mapping "$WEBDAV_MAPPING_FILE" --mapping "$WEBDAV_MAPPING_FILE" 2>&1)"; then
PLACEHOLDER_EXIT=0
else
PLACEHOLDER_EXIT=$?
fi
[[ -z "$PLACEHOLDER_OUTPUT" ]] || printf '%s\n' "$PLACEHOLDER_OUTPUT"
if [[ "$PLACEHOLDER_EXIT" -ne 0 ]]; then
DETAIL="Exit-Code: $PLACEHOLDER_EXIT"
if [[ -n "$PLACEHOLDER_OUTPUT" ]]; then
DETAIL+="; Ausgabe: $(printf '%s\n' "$PLACEHOLDER_OUTPUT" | tail -n 20 | tr '\n' ' ' | sed 's/[[:space:]]\+/ /g; s/^[[:space:]]//; s/[[:space:]]$//')"
fi
trap - ERR
write_status error "WebDAV-Shadow-Tree fehlgeschlagen" "$DETAIL"
exit "$PLACEHOLDER_EXIT"
fi
python3 "$SCRIPT_DIR/lib/shadow_tree.py" \ SHADOW_OUTPUT=""
SHADOW_EXIT=0
if SHADOW_OUTPUT="$(python3 "$SCRIPT_DIR/lib/shadow_tree.py" \
--manifest "$MANIFEST" \ --manifest "$MANIFEST" \
--destination "$GALLERY_ROOT" \ --destination "$GALLERY_ROOT" \
--state "$SHADOW_MAP_FILE" --state "$SHADOW_MAP_FILE" 2>&1)"; then
SHADOW_EXIT=0
else
SHADOW_EXIT=$?
fi
[[ -z "$SHADOW_OUTPUT" ]] || printf '%s\n' "$SHADOW_OUTPUT"
if [[ "$SHADOW_EXIT" -ne 0 ]]; then
DETAIL="Exit-Code: $SHADOW_EXIT"
if [[ -n "$SHADOW_OUTPUT" ]]; then
DETAIL+="; Ausgabe: $(printf '%s\n' "$SHADOW_OUTPUT" | tail -n 20 | tr '\n' ' ' | sed 's/[[:space:]]\+/ /g; s/^[[:space:]]//; s/[[:space:]]$//')"
fi
trap - ERR
write_status error "WebDAV-Shadow-Tree fehlgeschlagen" "$DETAIL"
exit "$SHADOW_EXIT"
fi
trap - ERR trap - ERR

View File

@@ -52,6 +52,18 @@ if (!pwg_db_num_rows($access_result)) bratonien_tools_webdav_image_abort(403, 'K
$source = bratonien_tools_webdav_image_source_info($image_id); $source = bratonien_tools_webdav_image_source_info($image_id);
if (!$source) bratonien_tools_webdav_image_abort(404, 'Keine WebDAV-Quelle für dieses Bild gefunden.'); if (!$source) bratonien_tools_webdav_image_abort(404, 'Keine WebDAV-Quelle für dieses Bild gefunden.');
if (!empty($_GET['ajaxload']))
{
$preview = !empty($_GET['preview']);
$final_url = bratonien_tools_webdav_image_url($image_id, $preview);
if (!$final_url) bratonien_tools_webdav_image_abort(404, 'Keine WebDAV-Bild-URL verfügbar.');
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store, max-age=0');
echo json_encode(array('url'=>$final_url), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
exit;
}
if (!empty($_GET['preview'])) if (!empty($_GET['preview']))
{ {
$preview = bratonien_tools_webdav_preview_path($source); $preview = bratonien_tools_webdav_preview_path($source);
@@ -121,7 +133,7 @@ $options = array(
CURLOPT_USERPWD => $user.':'.$password, CURLOPT_USERPWD => $user.':'.$password,
CURLOPT_RETURNTRANSFER => false, CURLOPT_RETURNTRANSFER => false,
CURLOPT_FAILONERROR => false, CURLOPT_FAILONERROR => false,
CURLOPT_USERAGENT => 'Bratonien-Tools-WebDAV-Image/0.9.7.16', CURLOPT_USERAGENT => 'Bratonien-Tools-WebDAV-Image/0.9.7.24',
CURLOPT_HEADERFUNCTION => function($ch, $line) CURLOPT_HEADERFUNCTION => function($ch, $line)
{ {
$length = strlen($line); $length = strlen($line);