bericht/api/note.php
Eduard Wisch a8d8291c3a API 1.5.1: Thumbnails, Aufnahmezeit, Seitenfelder, Textnotizen
Die API-Seite zur Baustelle-PWA holt nach, was der Editor mit 1.5.0 bekommen hat.

photo.php kann jetzt size=thumb&w=<px> und rendert ueber bericht_attachment_thumb()
(GD, Cache, EXIF-Rotation) statt die Originaldatei auszuliefern. size=small konnte nur
ein von Dolibarr vorgefertigtes thumbs/<name>_small.<ext> liefern - fuer Uploads ueber
orders.php?action=upload_photo gibt es das nicht, dort laeuft kein vignette(). Die PWA
lud dadurch fuer jede Kachel das komplette Foto: gemessen 9,8 KB statt 235 KB je Kachel.
Antwort mit ETag aus der mtime, Folgeaufrufe enden mit 304.

orders.php?action=photos liefert taken_at (EXIF-Aufnahmezeit ueber bericht_file_taken_at,
sonst Dateidatum) und sortiert danach statt nach filemtime.

reports.php gibt je Seite title und composite_path aus. title konnte ueber pages.php
gesetzt werden, kam aber nie zurueck. composite_path ist das im Editor gebaute
Seitenbild - ohne das zeigen Clients nur das Rohbild ohne Anmerkungen, bei
Raster-Layouts eines von bis zu sechs Bildern und bei title_only gar keins.

Neu: api/note.php - Textnotizen zum Auftrag, aufgebaut wie die Sprachnotiz. Die Notiz
liegt als notiz_<betreff>_<datum>.txt im Auftragsverzeichnis und ist damit auch im
Dolibarr-Auftrag und in der Anhaenge-Spalte des Editors sichtbar. Liste, Lesen, Anlegen,
Aendern, Loeschen. Der file-Parameter wird gegen ^notiz_[A-Za-z0-9_.-]*\.txt$ geprueft;
gegen Pfad-Ausbrueche getestet.

ROADMAP.md wiederhergestellt - sie war mit eb37a4b geloescht worden, weil alle Punkte
abgehakt waren. Sie bleibt ab jetzt liegen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 18:11:42 +02:00

179 lines
6.9 KiB
PHP

<?php
/* Textnotizen zu einem Auftrag — getippt statt diktiert.
*
* GET /api/note.php?order_id=<id> — Liste aller Notizen
* GET /api/note.php?order_id=<id>&file=<name> — eine Notiz mit Text
* POST /api/note.php?order_id=<id> — anlegen { subject, text }
* POST /api/note.php?order_id=<id>&file=<name> — ändern { subject, text }
* POST /api/note.php?order_id=<id>&file=<name>&delete=1 — löschen
*
* Gegenstück zu voice.php: die Notiz liegt als Datei im Auftrags-Verzeichnis und taucht
* damit in der Dokumentliste der PWA und in der Anhänge-Spalte des Bericht-Editors auf.
*
* Dateiformat (bewusst als Klartext lesbar, auch wenn jemand die .txt direkt öffnet):
*
* Betreff: Zählernummern Keller
* Erfasst: 22.08.2026 17:51 · data
* Geändert: 22.08.2026 18:20 · data
*
* <freier Text>
*
* Der Kopf geht bis zur ersten Leerzeile. Der Betreff steht zusätzlich im Dateinamen,
* damit man die Notiz in der Dokumentliste wiederfindet.
*/
require_once __DIR__.'/_inc.php';
api_authenticate();
global $db, $user, $conf;
require_once DOL_DOCUMENT_ROOT.'/commande/class/commande.class.php';
require_once DOL_DOCUMENT_ROOT.'/core/lib/files.lib.php';
$order_id = (int) ($_GET['order_id'] ?? 0);
if (!$order_id) api_fail('order_id fehlt');
$cmd = new Commande($db);
if ($cmd->fetch($order_id) <= 0) api_fail('Auftrag nicht gefunden', 404);
$dir = $conf->commande->multidir_output[$cmd->entity].'/'.dol_sanitizeFileName($cmd->ref);
/**
* Prüft den übergebenen Dateinamen. Nur Notiz-Dateien im Auftragsordner sind erlaubt —
* ohne das könnte man über ../ jede Datei unter DOL_DATA_ROOT überschreiben.
*/
function note_safe_path($dir, $name)
{
$base = basename((string) $name);
if ($base === '' || $base !== $name) return null;
if (!preg_match('/^notiz_[A-Za-z0-9_.\-]*\.txt$/', $base)) return null;
return $dir.'/'.$base;
}
/** Kopf + Text einer Notizdatei auseinandernehmen. */
function note_parse($raw)
{
$meta = array('subject' => '', 'created' => '', 'modified' => '');
$lines = preg_split('/\r\n|\r|\n/', (string) $raw);
$i = 0;
for (; $i < count($lines); $i++) {
$line = $lines[$i];
if (trim($line) === '') { $i++; break; }
if (preg_match('/^Betreff:\s*(.*)$/u', $line, $m)) { $meta['subject'] = trim($m[1]); continue; }
if (preg_match('/^Erfasst:\s*(.*)$/u', $line, $m)) { $meta['created'] = trim($m[1]); continue; }
if (preg_match('/^Geändert:\s*(.*)$/u', $line, $m)) { $meta['modified'] = trim($m[1]); continue; }
// Keine Kopfzeile — die Datei hat keinen Kopf, alles ist Text
$i = 0;
break;
}
$meta['text'] = implode("\n", array_slice($lines, $i));
return $meta;
}
/** Kopf + Text wieder zu einer Datei zusammensetzen. */
function note_build($subject, $text, $created, $modified)
{
$head = 'Betreff: '.$subject."\n".'Erfasst: '.$created."\n";
if ($modified !== '') $head .= 'Geändert: '.$modified."\n";
return $head."\n".rtrim((string) $text)."\n";
}
/* ---------- Liste ---------- */
if ($_SERVER['REQUEST_METHOD'] === 'GET' && empty($_GET['file'])) {
$out = array();
foreach (glob($dir.'/notiz_*.txt', GLOB_NOSORT) ?: array() as $f) {
if (!is_file($f)) continue;
$meta = note_parse(@file_get_contents($f));
$text = trim($meta['text']);
$out[] = array(
'file' => basename($f),
'subject' => $meta['subject'],
'created' => $meta['created'],
'modified' => $meta['modified'],
// Kurzfassung für die Liste, damit die App nicht jede Datei einzeln holen muss
'preview' => dol_trunc(preg_replace('/\s+/u', ' ', $text), 140, 'right', 'UTF-8', 1),
'date' => (int) @filemtime($f),
'size' => (int) @filesize($f),
'relpath' => str_replace(DOL_DATA_ROOT.'/', '', $f),
);
}
usort($out, function ($a, $b) { return $b['date'] <=> $a['date']; });
api_ok(array('notes' => $out, 'count' => count($out)));
}
/* ---------- Einzelne Notiz lesen ---------- */
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$path = note_safe_path($dir, $_GET['file'] ?? '');
if (!$path || !is_file($path)) api_fail('Notiz nicht gefunden', 404);
$meta = note_parse(@file_get_contents($path));
api_ok(array('note' => array(
'file' => basename($path),
'subject' => $meta['subject'],
'text' => $meta['text'],
'created' => $meta['created'],
'modified' => $meta['modified'],
'relpath' => str_replace(DOL_DATA_ROOT.'/', '', $path),
)));
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') api_fail('Methode nicht unterstützt', 405);
if (!$user->hasRight('bericht', 'write')) api_fail('Permission denied', 403);
/* ---------- Löschen ---------- */
if (!empty($_GET['delete'])) {
if (!$user->hasRight('bericht', 'delete')) api_fail('Permission denied', 403);
$path = note_safe_path($dir, $_GET['file'] ?? '');
if (!$path || !is_file($path)) api_fail('Notiz nicht gefunden', 404);
if (!@unlink($path)) api_fail('Löschen fehlgeschlagen', 500);
api_ok();
}
$in = api_input();
$subject = trim((string) ($in['subject'] ?? ''));
$text = (string) ($in['text'] ?? '');
if ($subject === '' && trim($text) === '') api_fail('Notiz ist leer');
if (strlen($text) > 200000) api_fail('Notiz ist zu lang');
$stamp = dol_print_date(dol_now(), '%d.%m.%Y %H:%M').' · '.$user->login;
/* ---------- Ändern ---------- */
if (!empty($_GET['file'])) {
$path = note_safe_path($dir, $_GET['file']);
if (!$path || !is_file($path)) api_fail('Notiz nicht gefunden', 404);
$old = note_parse(@file_get_contents($path));
$created = $old['created'] !== '' ? $old['created'] : $stamp;
if (@file_put_contents($path, note_build($subject, $text, $created, $stamp)) === false) {
api_fail('Speichern fehlgeschlagen', 500);
}
api_ok(array(
'file' => basename($path),
'subject' => $subject,
'relpath' => str_replace(DOL_DATA_ROOT.'/', '', $path),
));
}
/* ---------- Neu anlegen ---------- */
if (!is_dir($dir)) dol_mkdir($dir);
// Betreff in einen dateinamenstauglichen Teil überführen — der Name ist das Einzige,
// was in der Dokumentliste zu sehen ist.
$subject_part = '';
if ($subject !== '') {
$s = dol_string_nospecial(dol_sanitizeFileName($subject), '_');
$s = trim(preg_replace('/_+/', '_', $s), '_');
if ($s !== '') $subject_part = '_'.dol_trunc($s, 60, 'right', 'UTF-8', 1);
}
$filename = 'notiz'.$subject_part.'_'.dol_print_date(dol_now(), '%Y%m%d_%H%M%S').'.txt';
$target = $dir.'/'.$filename;
if (@file_put_contents($target, note_build($subject, $text, $stamp, '')) === false) {
api_fail('Speichern fehlgeschlagen', 500);
}
@chmod($target, 0664);
api_ok(array(
'file' => $filename,
'subject' => $subject,
'relpath' => str_replace(DOL_DATA_ROOT.'/', '', $target),
'size' => (int) filesize($target),
));