diff --git a/README.md b/README.md index f43895d..03871fc 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,165 @@ -# compare_filesize_drupal6 +# Drupal 6 – duże obrazy, lokalizacja tematów i bezpieczna optymalizacja +Rozbudowana wersja `compare_filesize_drupal6`. Działa **poza Drupalem**, w Pythonie 3.9+; potrzebuje dostępu do MySQL/MariaDB i katalogu plików. +## Co robi + +- Wyszukuje największe pliki i największe rozdzielczości (JPG, PNG, GIF – GIF wyłącznie do analizy). +- Wskazuje, **w jakim temacie, poście lub komentarzu** pojawia się obraz. Raport pokazuje `nid`, `cid`, tytuł, typ zawartości, URL do wpisu, URL do edycji i źródło powiązania. +- Szuka połączeń przez `upload.fid` i pola `*_fid` w tabelach CCK/ImageField/FileField; obsługuje aktualne rewizje (`vid`) i opcjonalne tabele załączników komentarzy (`cid` + `fid`). +- Szuka obrazów w `node_revisions.body` i `node_revisions.teaser`, `comments.comment`, opcjonalnie też w tekstowych polach CCK. Rozróżnia **pewne** dopasowania identyfikatora `fid` lub pełnej ścieżki od **możliwych** trafień po samej nazwie pliku. +- Generuje link do oryginalnego pliku (jeżeli publiczny) i pomocniczy link do Drupal Search (jeżeli moduł Search jest aktywny). +- Generuje CSV (polskie kolumny, Excel, UTF-8 BOM) i JSON. +- Symuluje dokładną optymalizację w **dry-run**, bez zapisu źródłowego pliku i bazy. +- Przy `--apply` zachowuje oryginał w oddzielnym katalogu, przygotowuje nowy obraz do atomowej podmiany, zapisuje `files.filesize`, a przy błędzie próbuje odtworzyć oryginał i wartości bazy. +- Wykrywa duplikaty SHA-256 i pliki na dysku, których nie ma w analizowanym zestawieniu z bazy. + +## Instalacja + +```bash +unzip compare_filesize_drupal6-extended.zip +cd compare_filesize_drupal6 +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +cp config.example.py config.py +``` + +W `config.py` ustaw: + +```python +DB_CONFIG = { + 'host': 'localhost', + 'port': 3306, + 'database': 'drupal6_db', + 'user': 'drupal_user', + 'password': 'twoje_haslo', + 'charset': 'utf8', +} +FILES_BASE_PATH = '/var/www/drupal/sites/default/files' +PUBLIC_FILES_PATH = 'sites/default/files' +DRUPAL_ROOT = '/var/www/drupal' +DRUPAL_BASE_URL = 'https://forum.example.org' +DB_TABLE_PREFIX = '' +``` + +**Ważne:** `FILES_BASE_PATH` to lokalizacja rzeczywistych plików, a `PUBLIC_FILES_PATH` to fragment zapisany zwykle w `files.filepath`. Jeśli pliki są np. w `/var/www/drupal/files`, zmień obie wartości. Można też podać je w CLI: `--public-files-path`, `--drupal-root`. Gdy Drupal pracuje bez clean URLs, dodaj `--no-clean-urls`. + +## 1. Znajdź tematy i komentarze z największymi obrazami + +```bash +python main.py --find-usage --top 50 --report-csv najwieksze-z-linkami.csv +``` + +Przykładowe wiersze (fikcyjne): + +```text + 1. 18.42 MB 4200x5900 fid=1234 scans/mapa.jpg + -> Archiwalne skany (forum, nid=812, pewne) + LINK: https://forum.example.org/node/812 + EDYCJA: https://forum.example.org/node/812/edit + SKAD: upload.fid, tresc wpisu (sciezka) + -> Zdjęcie w komentarzu (forum, nid=920, cid=42, pewne) + LINK: https://forum.example.org/node/920#comment-42 +``` + +CSV zawiera jedną linię dla każdego znalezionego miejsca. Gdy powiązania brak, w CSV zostaje plik, jego `fid`, rozmiar i propozycja wyszukiwania nazwą w Drupal Search. + +### Tylko skany lub wybrane pliki + +```bash +python main.py --find-usage --top 100 --path-contains skan --report-csv skany.csv +python main.py --find-usage --fid 1234 --report-csv jeden-obraz.csv +python main.py --find-usage --min-size-mb 5 --usage-scope all --report-csv wieksze-niz-5mb.csv +``` + +`--usage-scope top` szuka tylko w `--top N` największych obrazach; `optimizable` – w obrazach przekraczających limity; `all` – w całym filtrowanym zestawieniu. Przy bardzo dużych bazach `all` może oznaczać długi skan treści. `--no-text-scan` wyłącza szukanie w treściach i zostawia szybsze powiązania `fid`. `--scan-cck-text` rozszerza skan na tekstowe pola CCK. + +## 2. Dokładny dry-run optymalizacji + +```bash +python main.py \ + --find-usage --usage-scope optimizable \ + --optimize --dry-run --optimize-top 20 \ + --max-width 2400 --max-height 3200 --max-file-mb 2 \ + --report-csv plan.csv --report-json plan.json +``` + +Dostaniesz rzeczywisty przewidywany rozmiar po rekompresji (test wykonany na pliku tymczasowym). **Nie zmieni to oryginałów ani SQL.** `--top` dotyczy wyświetlania; `--optimize-top` ogranicza liczbę obrazów poddawanych testowi/zmniejszaniu. + +## 3. Zmniejsz np. pierwsze 10 obrazów i aktualizuj bazę + +**Najpierw zrób pełen backup bazy danych i katalogu plików** oraz przetestuj na kopii. Następnie: + +```bash +python main.py \ + --optimize --apply \ + --optimize-top 10 --max-changes 10 \ + --backup-dir /srv/backups/drupal6-images \ + --max-width 2400 --max-height 3200 --max-file-mb 2 \ + --report-csv wykonane.csv --report-json wykonane.json +``` + +Każda udana zmiana: + +1. Zapisuje oryginał w osobnym katalogu (automatycznie tworzony podfolder uruchomienia). +2. Zastępuje obraz tylko jeżeli nowy plik jest mniejszy o co najmniej `--min-gain-percent` (domyślnie 5%). +3. Aktualizuje `files.filesize` dla wszystkich `fid` wskazujących na tę samą ścieżkę. +4. W przypadku błędu SQL próbuje odtworzyć oryginalny plik i rozmiar w DB (także przy MyISAM – kompensacja aktualizacji). + +`--apply` jest wymagane do każdego zapisu. Samo `--optimize` lub `--update-db` nie wykonuje zmian. **Realna optymalizacja zawsze aktualizuje rozmiar w DB**, także bez `--update-db`. + +Jeżeli chcesz wyłącznie zsynchronizować rozmiary plików w istniejącym `files`: + +```bash +python main.py --update-db --dry-run +python main.py --update-db --apply +``` + +`--update-db` w połączeniu z `--optimize --apply` dodatkowo koryguje rozmiary tych plików, które *nie zostały* zoptymalizowane. + +## Zasady jakości i bezpieczeństwa + +- **JPEG:** zachowuje rozszerzenie i ścieżkę, skaluje proporcjonalnie filtrem LANCZOS, zapisuje z wysoką jakością 92/89/86/85 (`--jpeg-quality`, `--min-jpeg-quality`). Nie schodzi poniżej skonfigurowanej jakości, nawet gdy cel 2 MB nie zostanie osiągnięty. JPEG jest formatem stratnym – każda ponowna kompresja może zmienić piksele. +- **PNG:** zachowuje format oraz kanał przezroczystości, używa kompresji **bezstratnej**. Nie konwertuje PNG do JPEG i nie zmienia rozszerzeń, bo mogłoby to popsuć stare linki w Drupalu. Duży PNG ze skanem może pozostać duży, jeśli kompresja bezstratna nic nie da. Konwersja do JPEG wymagałaby osobnej, kontrolowanej migracji i aktualizacji odnośników. +- **Skan tekstowy:** domyślny limit **2400 × 3200 px** zamiast starego 1920 × 1080. Przed decyzją sprawdź czytelność drobnego druku w próbce i w razie potrzeby zwiększ limity lub pomiń plik. +- **GIF/animacje, nieobsługiwane formaty, niezgodności formatu i rozszerzenia:** pomijane przy zapisie. Symlinki i hardlinki też są pomijane. +- Kopia oryginału wymagana jest przy `--optimize --apply`, a lokalizacja kopii **musi być poza** `FILES_BASE_PATH` i poza katalogiem WWW Drupala (jeśli ustawiono `DRUPAL_ROOT`). +- Weryfikacja czasu modyfikacji i inode chroni przed zmianą pliku w trakcie skanowania; zalecane jest wykonywanie pracy w oknie serwisowym. +- Zachowuje ICC, DPI, EXIF (z prawidłową obsługą orientacji) i tekstowe metadane PNG w obsługiwanych formatach, ale nie gwarantuje zachowania każdego niestandardowego segmentu metadanych. +- Aktualizowany jest tylko `files.filesize` – nie są zmieniane ścieżki, `fid`, treści postów ani załączniki. Zmiana dużego obrazka może wymagać ręcznego odświeżenia wygenerowanych miniatur przez ImageCache. +- Kopia wykonana przez samo narzędzie obejmuje **obraz**, nie całą bazę. MyISAM nie zapewnia transakcji; procedura przywracania jest best-effort. Pełny backup DB i plików przed zmianami jest obowiązkowy. + +## Pozostałe opcje + +```bash +python main.py --find-duplicates +python main.py --show-deleted --exclude-folders imagecache styles +python main.py --find-usage --no-clean-urls --site-url https://example.org/drupal +python main.py --help +``` + +Uwaga do `--show-deleted`: pokazuje pliki na dysku **bez pasującego wpisu `files` w analizowanym zestawieniu**, nie zaś potwierdzenie, że plik naprawdę nie występuje w treści. Przy filtrach (`--path-contains`, `--min-size-mb`, `--fid`) plik odfiltrowany może zostać pokazany jako „spoza zestawienia”. Nic nie jest usuwane. + +### Ograniczenia wyszukiwania + +- Powiązania po `fid` są najbardziej jednoznaczne; wyszukiwanie po samej nazwie pliku bywa niejednoznaczne – w raporcie `pewnosc=mozliwe`. +- Tekst przeszukiwany jest w **aktualnych rewizjach** węzłów i komentarzach. Nie szuka w archiwalnych rewizjach ani w niestandardowych zewnętrznych modułach przechowywania treści bez włączonego skanu CCK. +- Link do nieopublikowanego wpisu lub komentarza może wymagać konta z prawami administracyjnymi. Raport to zaznacza. +- **Drupal 6 stronicuje komentarze**: `node/NID#comment-CID` może nie przewinąć do komentarza z dalszej strony. Jeżeli masz zainstalowany moduł [Permalink dla Drupal 6](https://www.drupal.org/project/permalink), dodaj `--comment-permalinks`, aby generować `comment/CID#comment-CID`, które kieruje na właściwą stronę komentarzy. Bez tego modułu nie używaj tej flagi. Niezależnie od typu linku raport pokazuje `nid` i `cid`. +- Nie ma gwarancji odnalezienia obrazów w niestandardowych relacjach tworzonych przez własne moduły, w CSS i w HTML poza bazą. +- W przypadku prywatnego systemu plików adres bezpośredni do pliku może nie działać; odnośnik do tematu nadal powinien wskazać miejsce. + +## Testy + +```bash +python -m unittest discover -s tests -v +``` + +Testy z lokalną, symulowaną bazą weryfikują powiązania Upload/CCK, obecne rewizje, komentarze, brak zmian w dry-run, aktualizację `files.filesize`, backup, przywracanie po błędzie SQL, format PNG i ścieżki katalogów. **Nie zastępują próby na kopii konkretnej instalacji Drupal 6**. + +### Źródła struktury bazy Drupal 6 + +- [Oficjalny migrator Drupal 6 Upload](https://api.drupal.org/api/drupal/core%21modules%21file%21src%21Plugin%21migrate%21source%21d6%21Upload.php/8.9.x) – `upload.fid`, `upload.nid`, `upload.vid`. +- [Oficjalny migrator komentarzy D6](https://api.drupal.org/api/drupal/core%21modules%21comment%21src%21Plugin%21migrate%21source%21d6%21Comment.php/class/Comment/main) – tabela `comments`, `cid`, `nid`, status komentarza. +- [CCK 6](https://api.druphelp.com/api/cck/content.module/6) – `content_type_*`, `content_field_*`. diff --git a/config.example.py b/config.example.py index ac57154..00e7446 100644 --- a/config.example.py +++ b/config.example.py @@ -1,10 +1,19 @@ -# config.py - +# Skopiuj do config.py i dostosuj. Nigdy nie dodawaj config.py do Git. DB_CONFIG = { 'host': 'localhost', + 'port': 3306, 'database': 'drupal6_db', 'user': 'drupal_user', 'password': 'secure_password', + # Stary MySQL/Drupal 6 czesto wymaga utf8 zamiast utf8mb4. + 'charset': 'utf8', } -FILES_BASE_PATH = '/ścieżka/do/drupala/sites/default/files/' +# Katalog, w ktorym fizycznie znajduja sie obrazy (moze byc inny w Drupal 6). +FILES_BASE_PATH = '/var/www/drupal/sites/default/files' +# Publiczny fragment sciezki do files/ zapisywany w tabeli files.filepath: +PUBLIC_FILES_PATH = 'sites/default/files' +# Katalog projektu Drupal: opcjonalny, pomaga dla sciezek pelnych. +DRUPAL_ROOT = '/var/www/drupal' +DRUPAL_BASE_URL = 'https://forum.example.org' +DB_TABLE_PREFIX = '' # np. 'drupal_' diff --git a/drupal_lookup.py b/drupal_lookup.py new file mode 100644 index 0000000..585527a --- /dev/null +++ b/drupal_lookup.py @@ -0,0 +1,325 @@ +"""Wyszukiwanie powiazan plikow z wpisami, tematami forum i komentarzami Drupal 6. + +Powiazania 'bezposrednie' to identyfikatory fid (Upload, CCK/FileField/ImageField). +Powiazania z tekstu to poszlaki: pelna sciezka (mocniejsze) lub sama nazwa. +""" +from __future__ import annotations + +import html +import os +import re +from collections import defaultdict +from urllib.parse import quote + + +IDENT = re.compile(r'^[A-Za-z_][A-Za-z0-9_]*$') + + +def sql_ident(value): + if not IDENT.fullmatch(value): + raise ValueError(f'Niedozwolona nazwa tabeli/kolumny: {value!r}') + return f'`{value}`' + + +def chunks(seq, size=250): + seq = list(seq) + for start in range(0, len(seq), size): + yield seq[start:start + size] + + +def node_url(base_url, nid, cid=None, *, clean_urls=True, comment_permalinks=False): + if cid is not None and comment_permalinks: + base = (base_url.rstrip('/') + '/') if base_url else '' + if clean_urls: + return f'{base}comment/{int(cid)}#comment-{int(cid)}' + return f'{base}?q=comment/{int(cid)}#comment-{int(cid)}' + if not base_url: + result = f'node/{int(nid)}' + elif clean_urls: + result = f'{base_url.rstrip("/")}/node/{int(nid)}' + else: + result = f'{base_url.rstrip("/")}/?q=node/{int(nid)}' + if cid is not None: + result += f'#comment-{int(cid)}' + return result + + +def search_url(base_url, filename, *, clean_urls=True): + if not base_url: + return '' + term = quote(filename, safe='') + if clean_urls: + return f'{base_url.rstrip("/")}/search/node/{term}' + return f'{base_url.rstrip("/")}/?q=search/node/{term}' + + +class ReferenceMatcher: + """Jedno przejscie przez tresc na liste interesujacych plikow. + + Dopasowanie tylko nazwy pliku ma pewnosc 'mozliwe', nigdy 'pewne'. + """ + + def __init__(self, images, basename_match=True): + variants = defaultdict(list) + for image in images: + fid = int(image['fid']) + file_path = (image.get('db_filepath') or '').replace('\\', '/').lstrip('/') + relative = (image.get('rel_path') or '').replace('\\', '/').lstrip('/') + for path in (file_path, relative): + if path and '/' in path: + for variant in (path, quote(path, safe='/')): + variants[variant.casefold()].append((fid, 'sciezka')) + name = os.path.basename(relative or file_path) + if basename_match and name and len(name) >= 5: + for variant in (name, quote(name, safe='')): + variants[variant.casefold()].append((fid, 'nazwa')) + self.variants = dict(variants) + # Najpierw dluzsze wzorce, np. 'uploads/scan.jpg' przed 'scan.jpg'. + self.pattern = None + if variants: + ordered = sorted(variants, key=lambda x: (-len(x), x)) + self.pattern = re.compile('|'.join(re.escape(x) for x in ordered), re.IGNORECASE) + + def match(self, text): + if not self.pattern or not text: + return {} + text = html.unescape(str(text)) + found = {} + for occurrence in self.pattern.finditer(text): + word = occurrence.group(0).casefold() + for fid, kind in self.variants.get(word, []): + if kind == 'nazwa': + before = text[occurrence.start() - 1] if occurrence.start() else '' + after = text[occurrence.end()] if occurrence.end() < len(text) else '' + if before and (before.isalnum() or before in '._-'): + continue + if after and (after.isalnum() or after in '._-'): + continue + # Preferuj trafienie przez pelna sciezke od samej nazwy. + if fid not in found or (found[fid] == 'nazwa' and kind == 'sciezka'): + found[fid] = kind + return found + + +class DrupalReferenceLookup: + def __init__(self, connection, *, table_prefix='', base_url='', clean_urls=True, comment_permalinks=False): + self.connection = connection + self.prefix = table_prefix + self.base_url = base_url + self.clean_urls = clean_urls + self.comment_permalinks = comment_permalinks + if table_prefix and not IDENT.fullmatch(table_prefix): + raise ValueError('Nieprawidlowy prefix tabel') + self.tables = set() + self.columns = {} + + def table(self, name): + return f'{self.prefix}{name}' + + def inspect_schema(self): + with self.connection.cursor() as cursor: + cursor.execute('SHOW TABLES') + self.tables = {str(next(iter(row.values()))) for row in cursor.fetchall()} + if self.table('node') not in self.tables: + raise RuntimeError('Nie znaleziono tabeli node; sprawdz --table-prefix') + for table in sorted(self.tables): + if self.prefix and not table.startswith(self.prefix): + continue + if not IDENT.fullmatch(table): + continue + cursor.execute(f'SHOW COLUMNS FROM {sql_ident(table)}') + self.columns[table] = {row['Field']: row['Type'].lower() for row in cursor.fetchall()} + + def _add(self, refs, fid, row, source, confidence='pewne', cid=None): + fid = int(fid) + nid = int(row['nid']) + cid = int(cid) if cid is not None else None + key = (nid, cid) + if key not in refs[fid]: + refs[fid][key] = { + 'nid': nid, + 'cid': cid, + 'title': row.get('title') or '(bez tytulu)', + 'node_type': row.get('node_type') or '', + 'published': int(row.get('node_status', 1)) == 1, + 'comment_published': (int(row['comment_status']) == 0 if row.get('comment_status') is not None else None), + 'url': node_url(self.base_url, nid, cid, clean_urls=self.clean_urls, + comment_permalinks=self.comment_permalinks), + 'edit_url': node_url(self.base_url, nid, clean_urls=self.clean_urls) + '/edit' if self.clean_urls else ( + f'{self.base_url.rstrip("/")}/?q=node/{nid}/edit' if self.base_url else f'node/{nid}/edit'), + 'sources': [], + 'confidence': confidence, + } + ref = refs[fid][key] + if source not in ref['sources']: + ref['sources'].append(source) + if confidence == 'pewne': + ref['confidence'] = 'pewne' + + def _direct_relations(self, refs, fids): + node = sql_ident(self.table('node')) + # Upload + kazda tabela z nid i fid lub XXX_fid (CCK, filefield...). + for table, cols in sorted(self.columns.items()): + if not table.startswith(self.prefix): + continue + if 'nid' not in cols or table == self.table('node'): + continue + fid_cols = [col for col in cols if col == 'fid' or col.endswith('_fid')] + if not fid_cols: + continue + if not (table == self.table('upload') or table.startswith(self.table('content_')) or 'fid' in cols or any(col.startswith('field_') for col in fid_cols)): + continue + try: + with self.connection.cursor() as cursor: + for fid_column in fid_cols: + revision_join = ' AND n.vid = t.vid' if 'vid' in cols else '' + for batch in chunks(fids): + placeholders = ','.join(['%s'] * len(batch)) + query = ( + f'SELECT n.nid AS nid, n.title AS title, n.type AS node_type, ' + f'n.status AS node_status, t.{sql_ident(fid_column)} AS fid, ' + f'{"t.cid" if "cid" in cols else "NULL"} AS cid ' + f'FROM {sql_ident(table)} t JOIN {node} n ON n.nid = t.nid' + f'{revision_join} WHERE t.{sql_ident(fid_column)} IN ({placeholders})' + ) + cursor.execute(query, tuple(batch)) + for row in cursor.fetchall(): + if row.get('fid') is not None: + source = f'{table}.{fid_column}' + self._add(refs, row['fid'], row, source, cid=row.get('cid')) + except Exception as exc: + print(f'[WARN] Nie udalo sie odczytac powiazan {table}: {exc}') + + def _comment_direct_relations(self, refs, fids): + # W niektorych instalacjach zalaczniki do komentarzy maja tylko cid+fid. + comments = self.table('comments') + if comments not in self.columns: + return + for table, cols in sorted(self.columns.items()): + if table in (comments, self.table('node')) or 'cid' not in cols or 'nid' in cols: + continue + fid_cols = [col for col in cols if col == 'fid' or col.endswith('_fid')] + if not fid_cols: + continue + # Nie interpretuj losowych tabel jako zalacznikow do komentarzy. + if 'comment' not in table: + continue + try: + with self.connection.cursor() as cursor: + for fid_column in fid_cols: + for batch in chunks(fids): + placeholders = ','.join(['%s'] * len(batch)) + query = ( + f'SELECT n.nid AS nid, n.title AS title, n.type AS node_type, ' + f'n.status AS node_status, c.status AS comment_status, ' + f'c.cid AS cid, t.{sql_ident(fid_column)} AS fid ' + f'FROM {sql_ident(table)} t ' + f'JOIN {sql_ident(comments)} c ON c.cid = t.cid ' + f'JOIN {sql_ident(self.table("node"))} n ON n.nid = c.nid ' + f'WHERE t.{sql_ident(fid_column)} IN ({placeholders})' + ) + cursor.execute(query, tuple(batch)) + for row in cursor.fetchall(): + if row.get('fid') is not None: + self._add(refs, row['fid'], row, f'{table}.{fid_column}', cid=row['cid']) + except Exception as exc: + print(f'[WARN] Nie udalo sie odczytac zalacznikow do komentarzy {table}: {exc}') + + def _stream_query(self, query): + # Kursor strumieniowy nie pobiera do RAM calego body z wielkiej bazy. + import pymysql + cursor = self.connection.cursor(pymysql.cursors.SSDictCursor) + try: + cursor.execute(query) + while True: + batch = cursor.fetchmany(200) + if not batch: + break + for row in batch: + yield row + finally: + cursor.close() + + def _scan_body(self, refs, matcher): + revision = self.table('node_revisions') + if revision not in self.columns or 'body' not in self.columns[revision]: + print('[WARN] Brak node_revisions.body - pomijam tekst wpisow') + return + teaser_field = ('r.teaser AS teaser' if 'teaser' in self.columns[revision] + else 'NULL AS teaser') + query = ( + f'SELECT n.nid, n.title, n.type AS node_type, n.status AS node_status, ' + f'r.body AS body, {teaser_field} FROM {sql_ident(self.table("node"))} n ' + f'JOIN {sql_ident(revision)} r ON r.vid = n.vid AND r.nid = n.nid ' + f'WHERE (r.body IS NOT NULL AND r.body <> \'\')' + + (" OR (r.teaser IS NOT NULL AND r.teaser <> '')" if 'teaser' in self.columns[revision] else '') + ) + for row in self._stream_query(query): + for field, source in (('body', 'tresc wpisu'), ('teaser', 'zajawka wpisu')): + for fid, kind in matcher.match(row.get(field)).items(): + confidence = 'pewne' if kind == 'sciezka' else 'mozliwe' + self._add(refs, fid, row, f'{source} ({kind})', confidence) + + def _scan_comments(self, refs, matcher): + table = self.table('comments') + if table not in self.columns or 'comment' not in self.columns[table]: + return + subject_field = ('c.subject AS comment_subject' if 'subject' in self.columns[table] + else 'NULL AS comment_subject') + query = ( + f'SELECT n.nid, n.title, n.type AS node_type, n.status AS node_status, ' + f'c.cid, c.status AS comment_status, c.comment, {subject_field} ' + f'FROM {sql_ident(table)} c ' + f'JOIN {sql_ident(self.table("node"))} n ON n.nid = c.nid ' + ) + for row in self._stream_query(query): + for field, source in (('comment', 'komentarz'), ('comment_subject', 'tytul komentarza')): + for fid, kind in matcher.match(row.get(field)).items(): + self._add(refs, fid, row, f'{source} ({kind})', + 'pewne' if kind == 'sciezka' else 'mozliwe', row['cid']) + + def _scan_cck_text(self, refs, matcher): + # Opcjonalne: tekst moze byc w polach niestandardowych CCK. + for table, cols in sorted(self.columns.items()): + if not table.startswith(self.table('content_')) or 'nid' not in cols: + continue + columns = [col for col, typ in cols.items() if ( + 'text' in typ or 'varchar' in typ + ) and col not in ('nid', 'vid')] + if not columns: + continue + revision_join = ' AND n.vid = t.vid' if 'vid' in cols else '' + for col in columns: + query = ( + f'SELECT n.nid, n.title, n.type AS node_type, n.status AS node_status, ' + f't.{sql_ident(col)} AS value FROM {sql_ident(table)} t ' + f'JOIN {sql_ident(self.table("node"))} n ON n.nid = t.nid' + f'{revision_join} WHERE t.{sql_ident(col)} IS NOT NULL' + ) + try: + for row in self._stream_query(query): + for fid, kind in matcher.match(row.get('value')).items(): + self._add(refs, fid, row, f'{table}.{col} ({kind})', 'pewne' if kind == 'sciezka' else 'mozliwe') + except Exception as exc: + print(f'[WARN] Nie udalo sie przeskanowac pola {table}.{col}: {exc}') + + def lookup(self, images, *, scan_text=True, scan_cck_text=False, basename_match=True): + if not images: + return {} + self.inspect_schema() + fids = sorted({int(img['fid']) for img in images}) + refs = defaultdict(dict) + self._direct_relations(refs, fids) + self._comment_direct_relations(refs, fids) + if scan_text: + matcher = ReferenceMatcher(images, basename_match=basename_match) + if matcher.pattern: + print('[INFO] Szukam odnosnikow w aktualnych tresciach wpisow i komentarzy...') + self._scan_body(refs, matcher) + self._scan_comments(refs, matcher) + if scan_cck_text: + self._scan_cck_text(refs, matcher) + return { + fid: sorted(per_location.values(), key=lambda ref: (ref['nid'], ref['cid'] or 0)) + for fid, per_location in refs.items() + } diff --git a/image_optimizer.py b/image_optimizer.py new file mode 100644 index 0000000..b616528 --- /dev/null +++ b/image_optimizer.py @@ -0,0 +1,153 @@ +"""Zachowawcza, bezstratna dla PNG / wysokiej jakosci dla JPEG, optymalizacja. + +Ten modul NIGDY nie nadpisuje pliku zrodlowego: tworzy jedynie propozycje +w pliku tymczasowym. main.py odpowiada za backup, podmiane i transakcje SQL. +""" +from __future__ import annotations + +import os +import tempfile +from pathlib import Path + +from PIL import Image, ImageOps, PngImagePlugin + + +class ImageOptimizationError(Exception): + pass + + +def _image_save_kwargs(source, image_format): + kwargs = {} + if source.info.get('icc_profile'): + kwargs['icc_profile'] = source.info['icc_profile'] + if source.info.get('dpi'): + kwargs['dpi'] = source.info['dpi'] + if image_format == 'PNG': + pnginfo = PngImagePlugin.PngInfo() + text_chunks = getattr(source, 'text', {}) or {} + for key, value in text_chunks.items(): + if isinstance(key, str) and isinstance(value, str): + pnginfo.add_text(key, value) + if text_chunks: + kwargs['pnginfo'] = pnginfo + return kwargs + + +def create_optimized_candidate( + path, + *, + max_width=2400, + max_height=3200, + max_bytes=2 * 1024 * 1024, + jpeg_quality=92, + min_jpeg_quality=85, + min_gain_percent=5.0, + temp_dir=None, +): + """Zwraca {status,reason,before,after,width,height,quality,temp_path}. + + Status: ready, skip, error. Wlasnosc pliku temp_path przekazywana wywolujacemu: + wywolujacy MUSI usunac plik, takze przy bledzie. + """ + orig_size = os.path.getsize(path) + result = { + 'status': 'skip', 'reason': '', 'before': orig_size, + 'after': orig_size, 'width': 0, 'height': 0, 'quality': None, + 'temp_path': None, + } + temp_path = None + try: + with Image.open(path) as source: + fmt = source.format + ext = Path(path).suffix.lower() + if fmt not in ('JPEG', 'PNG'): + result['reason'] = f'format {fmt or "nieznany"} nie jest bezpiecznie obslugiwany' + return result + if fmt == 'JPEG' and ext not in ('.jpg', '.jpeg'): + result['reason'] = 'format i rozszerzenie pliku nie sa zgodne' + return result + if fmt == 'PNG' and ext != '.png': + result['reason'] = 'format i rozszerzenie pliku nie sa zgodne' + return result + if getattr(source, 'is_animated', False) and getattr(source, 'n_frames', 1) > 1: + result['reason'] = 'animacja (nie modyfikujemy)' + return result + + original_width, original_height = source.size + result['width'], result['height'] = original_width, original_height + if original_width <= max_width and original_height <= max_height and orig_size <= max_bytes: + result['reason'] = 'obraz juz spelnia limity' + return result + + # EXIF Orientation ma znaczenie zwlaszcza dla pionowych skanow. + working = ImageOps.exif_transpose(source) + if (fmt == 'PNG' and working.mode == 'P' and + (working.width > max_width or working.height > max_height)): + # Skalowanie obrazu paletowego przed konwersja daje nearest-neighbor. + # Konwertuj najpierw, aby LANCZOS pracowal na pelnych kolorach. + working = working.convert('RGBA' if 'transparency' in working.info else 'RGB') + working.thumbnail((max_width, max_height), Image.Resampling.LANCZOS) + result['width'], result['height'] = working.size + kwargs = _image_save_kwargs(source, fmt) + + fd, temp_path = tempfile.mkstemp(suffix=ext, prefix='.d6-image-', dir=temp_dir) + os.close(fd) + + if fmt == 'JPEG': + if working.mode not in ('RGB', 'L', 'CMYK'): + if working.mode == 'RGBA' or 'transparency' in working.info: + result['reason'] = 'JPEG z kanalem alfa (nie modyfikujemy)' + return result + working = working.convert('RGB') + # Pobieraj EXIF juz po prawidlowej korekcji orientacji. + try: + exif = working.getexif() + if exif: + kwargs['exif'] = exif.tobytes() + except (ValueError, TypeError, OSError): + # Uszkodzone EXIF nie powinno blokowac zapisu obrazu. + pass + qualities = list(range(jpeg_quality, min_jpeg_quality - 1, -3)) + if min_jpeg_quality not in qualities: + qualities.append(min_jpeg_quality) + best_quality = None + best_size = orig_size + for quality in qualities: + working.save( + temp_path, format='JPEG', optimize=True, + progressive=True, subsampling=0, quality=quality, **kwargs, + ) + size = os.path.getsize(temp_path) + if size < best_size: + best_quality, best_size = quality, size + if size <= max_bytes and 100.0 * (orig_size - size) / max(1, orig_size) >= min_gain_percent: + break + # Jezeli nie udalo sie zejsc pod limit, zastosuj najmniejsza + # akceptowalna jakosc. Plik wynikowy bedzie ostatnim kandydatem. + if best_quality != quality: + working.save( + temp_path, format='JPEG', optimize=True, + progressive=True, subsampling=0, quality=best_quality or min_jpeg_quality, + **kwargs, + ) + result['quality'] = best_quality + else: + working.save(temp_path, format='PNG', optimize=True, compress_level=9, **kwargs) + + result['after'] = os.path.getsize(temp_path) + gain_percent = 100.0 * (orig_size - result['after']) / max(1, orig_size) + if result['after'] >= orig_size or gain_percent < min_gain_percent: + result['reason'] = f'za mala oszczednosc ({gain_percent:.1f}% < {min_gain_percent:.1f}%)' + return result + result['status'] = 'ready' + result['reason'] = 'osiagnieto limit' if result['after'] <= max_bytes else 'zmniejszono; nadal powyzej limitu' + result['temp_path'] = temp_path + temp_path = None # przekazujemy plik wywolujacemu + return result + except Exception as exc: + result['status'] = 'error' + result['reason'] = f'{type(exc).__name__}: {exc}' + return result + finally: + if temp_path and os.path.exists(temp_path): + os.unlink(temp_path) diff --git a/main.py b/main.py index 8540790..6e7c337 100644 --- a/main.py +++ b/main.py @@ -1,362 +1,776 @@ -import os +#!/usr/bin/env python3 +"""Audyt, wyszukiwanie wpisow i bezpieczna optymalizacja obrazow Drupal 6. + +Tryb domyslny jest wylacznie do odczytu. Zapisy sa mozliwe tylko z --apply. +""" +from __future__ import annotations + import argparse -import pymysql +import csv import hashlib +import importlib +import json +import os +import posixpath +import re +import shutil +import stat +import sys +import tempfile +from collections import defaultdict +from datetime import datetime, timezone +from pathlib import Path +from urllib.parse import quote + from PIL import Image -from config import DB_CONFIG, FILES_BASE_PATH -MAX_WIDTH = 1920 -MAX_HEIGHT = 1080 -MAX_FILESIZE_BYTES = 1 * 1024 * 1024 # 1MB +from drupal_lookup import DrupalReferenceLookup, node_url, search_url +from image_optimizer import create_optimized_candidate -def get_connection(): - return pymysql.connect( - host=DB_CONFIG['host'], - user=DB_CONFIG['user'], - password=DB_CONFIG['password'], - database=DB_CONFIG['database'], - charset='utf8mb4', - cursorclass=pymysql.cursors.DictCursor + +def build_parser(): + parser = argparse.ArgumentParser( + description='Drupal 6: duze pliki, linki do tematow/komentarzy, bezpieczna optymalizacja', + formatter_class=argparse.ArgumentDefaultsHelpFormatter, ) + analysis = parser.add_argument_group('Analiza i filtrowanie') + analysis.add_argument('--top', type=int, default=20, help='Ile najwiekszych plikow wyswietlic') + analysis.add_argument('--extensions', default='jpg,jpeg,png,gif', help='Rozszerzenia do analizy') + analysis.add_argument('--exclude-folders', nargs='*', default=[], help='Podfoldery pomijane w files/') + analysis.add_argument('--path-contains', default='', help='Filtr fragmentu sciezki, np. skan') + analysis.add_argument('--fid', type=int, action='append', help='Analizuj tylko podane fid (mozna powtorzyc)') + analysis.add_argument('--min-size-mb', type=float, default=0, help='Pokaz pliki od wskazanej liczby MB') + analysis.add_argument('--find-duplicates', action='store_true', help='Sprawdz duplikaty po SHA-256') + analysis.add_argument('--show-deleted', action='store_true', help='Pokaz pliki na dysku spoza tabeli files') + analysis.add_argument('--debug', action='store_true', help='Szczegolowy log') -def get_image_resolution(path): + lookup = parser.add_argument_group('Wskazanie tematu / posta / komentarza') + lookup.add_argument('--find-usage', '--where-used', dest='find_usage', action='store_true', + help='Szukaj powiazan plikow z wezłami i komentarzami') + lookup.add_argument('--usage-scope', choices=('top', 'optimizable', 'all'), default='top', + help='W ktorych plikach szukac powiazan') + lookup.add_argument('--site-url', help='Adres Drupal, np. https://forum.example.org') + lookup.add_argument('--no-clean-urls', action='store_true', help='Linki w formacie ?q=node/ID') + lookup.add_argument('--comment-permalinks', action='store_true', + help='Linki comment/CID (TYLKO z zainstalowanym backportem Permalink w Drupal 6)') + lookup.add_argument('--no-text-scan', action='store_true', help='Nie szukaj w tresci wpisow/komentarzy') + lookup.add_argument('--scan-cck-text', action='store_true', help='Szukaj takze w polach tekstowych CCK (wolniejsze)') + lookup.add_argument('--no-basename-match', action='store_true', help='W tekscie wymagaj sciezki, nie samej nazwy') + lookup.add_argument('--table-prefix', help='Prefix tabel Drupala, np. drupal_') + lookup.add_argument('--public-files-path', help='Sciezka publiczna w Drupal, np. sites/default/files') + lookup.add_argument('--drupal-root', help='Katalog glowny instalacji Drupal (pomaga rozwiazac sciezki)') + + change = parser.add_argument_group('Optymalizacja i kontrola zapisow') + change.add_argument('--optimize', action='store_true', help='Zaplanuj optymalizacje lub wykonaj ja z --apply') + change.add_argument('--update-db', action='store_true', help='Wyrownaj pole files.filesize (z --apply)') + mode = change.add_mutually_exclusive_group() + mode.add_argument('--apply', action='store_true', help='Zezwol na realny zapis obrazow/DB') + mode.add_argument('--dry-run', action='store_true', help='Jawny tryb podgladu (domyslny)') + change.add_argument('--backup-dir', help='Katalog dla kopii oryginalow (wymagany z --optimize --apply)') + change.add_argument('--max-changes', type=int, default=0, help='Maksymalna liczba realnie optymalizowanych plikow (0=bez limitu)') + change.add_argument('--optimize-top', type=int, default=0, help='Optymalizuj najwyzej N najwiekszych kandydatow (0=wszystkie)') + change.add_argument('--max-width', type=int, default=2400, help='Maksymalna szerokosc po optymalizacji') + change.add_argument('--max-height', type=int, default=3200, help='Maksymalna wysokosc po optymalizacji') + change.add_argument('--max-file-mb', type=float, default=2.0, help='Docelowy limit MB; nie kosztem duzej utraty jakosci') + change.add_argument('--jpeg-quality', type=int, default=92, help='Poczatkowa jakosc JPEG') + change.add_argument('--min-jpeg-quality', type=int, default=85, help='Minimalna dopuszczalna jakosc JPEG') + change.add_argument('--min-gain-percent', type=float, default=5.0, help='Minimalne zmniejszenie objetosci w procentach') + + report = parser.add_argument_group('Raporty') + report.add_argument('--report-csv', help='CSV z plikami i linkami (UTF-8 BOM, otwiera sie w Excelu)') + report.add_argument('--report-json', help='Raport JSON z lokalizacjami, rozmiarami i statusami') + return parser + + +def validate_args(args, parser): + if args.top <= 0 or args.min_size_mb < 0 or args.max_file_mb <= 0: + parser.error('--top > 0, --min-size-mb >= 0 i --max-file-mb > 0 sa wymagane') + if min(args.max_width, args.max_height) <= 0: + parser.error('--max-width i --max-height musza byc dodatnie') + if not (1 <= args.min_jpeg_quality <= args.jpeg_quality <= 95): + parser.error('Jakosc JPEG: 1 <= --min-jpeg-quality <= --jpeg-quality <= 95') + if not (0 <= args.min_gain_percent <= 100): + parser.error('--min-gain-percent musi byc w przedziale 0..100') + if args.max_changes < 0 or args.optimize_top < 0: + parser.error('--max-changes i --optimize-top nie moga byc ujemne') + if args.apply and not (args.optimize or args.update_db): + parser.error('--apply wymaga --optimize lub --update-db') + if args.apply and args.optimize and not args.backup_dir: + parser.error('Dla --optimize --apply podaj --backup-dir poza folderem z plikami') + if args.scan_cck_text and args.no_text_scan: + parser.error('--scan-cck-text wyklucza sie z --no-text-scan') + if args.site_url and not re.match(r'^https?://[^/]+', args.site_url, flags=re.I): + parser.error('--site-url musi byc adresem https://... lub http://...') + if args.table_prefix and not re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]*', args.table_prefix): + parser.error('Niedozwolony prefix tabel') + + +def load_config(): try: - with Image.open(path) as img: - return img.size # (width, height) + config = importlib.import_module('config') + except ModuleNotFoundError as exc: + if exc.name == 'config': + raise RuntimeError('Brak config.py - skopiuj config.example.py do config.py i ustaw dane') from exc + raise + if not hasattr(config, 'DB_CONFIG') or not hasattr(config, 'FILES_BASE_PATH'): + raise RuntimeError('config.py musi definiowac DB_CONFIG oraz FILES_BASE_PATH') + return config + + +def get_connection(config): + import pymysql + db = dict(config.DB_CONFIG) + connection_kwargs = { + 'host': db['host'], + 'user': db['user'], + 'password': db['password'], + 'database': db['database'], + 'charset': db.get('charset', 'utf8'), + 'port': int(db.get('port', 3306)), + 'cursorclass': pymysql.cursors.DictCursor, + 'autocommit': False, + 'connect_timeout': 15, + } + for optional in ('unix_socket', 'ssl'): + if optional in db: + connection_kwargs[optional] = db[optional] + return pymysql.connect(**connection_kwargs) + + +def _within(path, root): + path, root = os.path.realpath(path), os.path.realpath(root) + try: + return os.path.commonpath([path, root]) == root + except ValueError: + return False + + +def resolve_filepath(db_filepath, base_path, public_path='sites/default/files', drupal_root=None): + """Rozpoznaj Drupal filepath: relative/to/files albo sites/default/files/x.jpg. + + NIGDY nie zezwalaj na wyjscie poza FILES_BASE_PATH (takze przez symlink). + Zwraca (full_path, relative_to_files) lub (None, None). + """ + if not isinstance(db_filepath, str) or '\x00' in db_filepath or '://' in db_filepath: + return None, None + db_filepath = db_filepath.replace('\\', '/').strip() + base = os.path.realpath(base_path) + public = (public_path or '').replace('\\', '/').strip('/') + if not db_filepath: + return None, None + + candidates = [] + if os.path.isabs(db_filepath): + if _within(db_filepath, base): + candidates.append(db_filepath) + elif not (public and db_filepath.lstrip('/').startswith(public + '/')) and not ( + db_filepath.lstrip('/').startswith(os.path.basename(base) + '/') + ): + # /sites/default/files/x jest sciezka URL; /etc/passwd nie. + return None, None + + relative = db_filepath.lstrip('/') + if '..' in relative.split('/'): + return None, None + normalized = posixpath.normpath(relative) + if normalized in ('', '.', '..'): + return None, None + if public and normalized.startswith(public + '/'): + candidates.append(os.path.join(base, normalized[len(public) + 1:])) + # Czeste przy przenoszeniu Drupala: config podaje katalog "files", + # a DB przechowuje "files/name.jpg". + base_folder = os.path.basename(base) + if normalized.startswith(base_folder + '/'): + candidates.append(os.path.join(base, normalized[len(base_folder) + 1:])) + if drupal_root: + candidates.append(os.path.join(drupal_root, normalized)) + candidates.append(os.path.join(base, normalized)) + + # Zachowuj oryginalna sciezke: realpath ukrylby symlink i mogl ominac + # zabezpieczenie blokujace jego modyfikacje. _within sprawdza realpath. + allowed = [os.path.abspath(c) for c in candidates if _within(c, base)] + if not allowed: + return None, None + found = next((path for path in allowed if os.path.isfile(path)), allowed[0]) + return found, os.path.relpath(found, base).replace(os.sep, '/') + + +def sha256_file(path): + digest = hashlib.sha256() + with open(path, 'rb') as src: + for chunk in iter(lambda: src.read(1024 * 1024), b''): + digest.update(chunk) + return digest.hexdigest() + + +def file_resolution(path): + try: + with Image.open(path) as im: + return im.size except Exception: return (0, 0) -def hash_file(path): - hasher = hashlib.md5() - try: - with open(path, 'rb') as f: - for chunk in iter(lambda: f.read(8192), b""): - hasher.update(chunk) - return hasher.hexdigest() - except Exception: - return None -def resize_image(path): - try: - with Image.open(path) as img: - width, height = img.size - if width <= MAX_WIDTH and height <= MAX_HEIGHT and os.path.getsize(path) <= MAX_FILESIZE_BYTES: - return False # Nie trzeba zmieniać - - # Oblicz skalę proporcjonalnie, by zmieścić w max rozmiar i rozdzielczość - width_ratio = MAX_WIDTH / width - height_ratio = MAX_HEIGHT / height - scale_ratio = min(width_ratio, height_ratio, 1) - - new_width = int(width * scale_ratio) - new_height = int(height * scale_ratio) - - img = img.resize((new_width, new_height), Image.LANCZOS) - # Zapisz nadpisując, jakość 85% - img.save(path, optimize=True, quality=85) +def _excluded(rel_path, folders): + rel_path = rel_path.replace('\\', '/').lstrip('/') + for folder in folders: + item = folder.replace('\\', '/').strip('/') + if item and (rel_path == item or rel_path.startswith(item + '/')): return True - except Exception as e: - print(f"[ERROR] Nie udało się zmniejszyć {path}: {e}") - return False - -def fetch_used_filepaths(cursor): - """ - Pobiera unikalne ścieżki plików (filepath) ze wszystkich tabel, które mają kolumnę 'filepath'. - Zwraca set ścieżek względnych (z '/' zamiast '\\'). - """ - # Pobierz listę tabel w bazie - cursor.execute("SHOW TABLES") - tables = [row[f'Tables_in_{DB_CONFIG["database"]}'] for row in cursor.fetchall()] - - filepaths = set() - - for table in tables: - # Sprawdź kolumny danej tabeli - cursor.execute(f"SHOW COLUMNS FROM `{table}` LIKE 'filepath'") - if cursor.rowcount == 1: - # Tabela ma kolumnę 'filepath' — pobierz jej wartości - try: - cursor.execute(f"SELECT filepath FROM `{table}`") - rows = cursor.fetchall() - for row in rows: - path = row['filepath'].replace('\\', '/') - filepaths.add(path) - except Exception as e: - print(f"[WARN] Nie udało się pobrać danych z tabeli '{table}': {e}") - # Możesz tu też zdecydować, czy chcesz przerwać, czy tylko ostrzec - - return filepaths + return False -def file_exists_on_disk(base_path, rel_path): - # Normalizuj ścieżkę z bazy - rel_path_norm = rel_path.lstrip("/\\") # usuń początkowe ukośniki +def collect_images(connection, config, args): + base = os.path.realpath(config.FILES_BASE_PATH) + table_prefix = args.table_prefix if args.table_prefix is not None else getattr(config, 'DB_TABLE_PREFIX', '') + if table_prefix and not re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]*', table_prefix): + raise ValueError('Nieprawidlowy prefix tabel w config.py') + tables_files = f'`{table_prefix}files`' + with connection.cursor() as cursor: + cursor.execute(f'SELECT fid, filepath, filesize FROM {tables_files}') + file_rows = cursor.fetchall() - full_path = os.path.normpath(os.path.join(base_path, rel_path_norm)) - - # Debug print, żeby zweryfikować jak łączy się ścieżka - # print(f"Sprawdzam plik: {full_path}") - - return os.path.isfile(full_path), full_path - -def compare_files( - debug=False, - to_optimize=False, - update_db=False, - dry_run=False, - extensions='jpg,jpeg,png,gif', - exclude_folders=None, - find_duplicates=False, - show_deleted=False, - top=20, -): - allowed_exts = tuple(f".{ext.strip().lower()}" for ext in extensions.split(',')) - exclude_folders = exclude_folders or [] - exclude_folders = [folder.rstrip('/\\') for folder in exclude_folders] - - connection = get_connection() - cursor = connection.cursor() - - # Pobierz pliki z bazy - cursor.execute("SELECT fid, filepath, filesize FROM files") - files = cursor.fetchall() - - # Zbiór ścieżek plików używanych w systemie (dla duplikatów i wykrywania osieroconych) - used_filepaths = fetch_used_filepaths(cursor) - - stats = { - 'total': 0, - 'missing': 0, - 'mismatched': 0, - 'saved_bytes_potential': 0, - 'largest': [], - 'highest_res': [], - 'to_optimize': [], - 'duplicates': [], - 'deleted': [], - } - - images_info = [] - hash_map = {} - - # Przetwarzanie plików z bazy - for file in files: - rel_path = file['filepath'].replace('\\', '/') - # Sprawdź wykluczenia folderów - if any(rel_path.startswith(excl + '/') or rel_path == excl for excl in exclude_folders): - if debug: - print(f"[POMINIĘTO] {rel_path} (folder wykluczony)") + public = args.public_files_path if args.public_files_path is not None else getattr(config, 'PUBLIC_FILES_PATH', 'sites/default/files') + root = args.drupal_root if args.drupal_root is not None else getattr(config, 'DRUPAL_ROOT', None) + extensions = {'.' + x.strip().lower().lstrip('.') for x in args.extensions.split(',') if x.strip()} + groups = {} + missing = [] + for row in file_rows: + fid = int(row['fid']) + if args.fid and fid not in args.fid: continue - - if not rel_path.lower().endswith(allowed_exts): + db_path = str(row['filepath'] or '') + if not db_path.lower().endswith(tuple(extensions)): + continue + if args.path_contains and args.path_contains.casefold() not in db_path.casefold(): + continue + full_path, rel_path = resolve_filepath(db_path, base, public, root) + if not full_path: + missing.append({'fid': fid, 'db_filepath': db_path, 'reason': 'niebezpieczna lub nierozpoznana sciezka'}) + continue + if _excluded(rel_path, args.exclude_folders): continue - - fid = file['fid'] - db_size = file['filesize'] - full_path = os.path.join(FILES_BASE_PATH, rel_path) - - stats['total'] += 1 - if not os.path.isfile(full_path): - stats['missing'] += 1 - if debug: - print(f"[BRAK] {rel_path}") - print(f" → {full_path}") + missing.append({'fid': fid, 'db_filepath': db_path, 'path': full_path, 'reason': 'brak na dysku'}) + continue + stat_result = os.stat(full_path) + if stat_result.st_size < args.min_size_mb * 1024 * 1024: + continue + if full_path not in groups: + width, height = file_resolution(full_path) + groups[full_path] = { + 'path': full_path, + 'rel_path': rel_path, + 'actual_size': stat_result.st_size, + 'before_size': stat_result.st_size, + 'original_ino': stat_result.st_ino, + 'original_mtime_ns': stat_result.st_mtime_ns, + 'after_size': stat_result.st_size, + 'width': width, 'height': height, + 'db_rows': [], 'fids': [], 'references': [], + 'optimize_status': 'not_requested', 'optimization_reason': '', + 'quality': None, + } + img = groups[full_path] + img['db_rows'].append({'fid': fid, 'db_filepath': db_path, 'filesize': int(row['filesize'] or 0)}) + img['fids'].append(fid) + + images = list(groups.values()) + for image in images: + image['fid'] = image['fids'][0] + image['db_filepath'] = image['db_rows'][0]['db_filepath'] + image['db_size'] = image['db_rows'][0]['filesize'] + images.sort(key=lambda img: img['before_size'], reverse=True) + return images, missing, table_prefix + + +def is_candidate(image, args): + return ( + image['width'] > args.max_width or + image['height'] > args.max_height or + image['before_size'] > args.max_file_mb * 1024 * 1024 + ) + + +def _update_sizes(connection, table_prefix, image, filesize): + table = f'`{table_prefix}files`' + for db_row in image['db_rows']: + fid = db_row['fid'] + db_path = db_row['db_filepath'] + with connection.cursor() as cursor: + cursor.execute( + f'UPDATE {table} SET filesize = %s WHERE fid = %s AND filepath = %s', + (int(filesize), fid, db_path), + ) + # Dla rowcount=0 sprawdzamy, czy row nadal istnieje i ma oczekiwany rozmiar. + if cursor.rowcount == 0: + cursor.execute(f'SELECT filesize, filepath FROM {table} WHERE fid = %s', (fid,)) + row = cursor.fetchone() + if not row or row['filepath'] != db_path or int(row['filesize']) != int(filesize): + raise RuntimeError(f'Nie udalo sie zsynchronizowac files.fid={fid}') + + +def _restore_database_sizes(connection, table_prefix, image): + # Kompensacja takze dla starego MySQL/MyISAM (ROLLBACK moze nie dzialac). + table = f'`{table_prefix}files`' + with connection.cursor() as cursor: + for row in image['db_rows']: + cursor.execute( + f'UPDATE {table} SET filesize = %s WHERE fid = %s AND filepath = %s', + (row['filesize'], row['fid'], row['db_filepath']) + ) + connection.commit() + + +def _atomic_restore(backup_file, destination): + fd, restore_tmp = tempfile.mkstemp(prefix='.d6-restore-', dir=os.path.dirname(destination)) + os.close(fd) + try: + shutil.copy2(backup_file, restore_tmp) + os.replace(restore_tmp, destination) + finally: + if os.path.exists(restore_tmp): + os.unlink(restore_tmp) + + +def _safe_backup_dir(requested, base, drupal_root=None): + backup_root = os.path.realpath(os.path.abspath(requested)) + if _within(backup_root, base): + raise ValueError('Backup nie moze znajdowac sie wewnatrz FILES_BASE_PATH') + if drupal_root and _within(backup_root, drupal_root): + raise ValueError('Backup nie moze znajdowac sie wewnatrz katalogu WWW Drupala') + os.makedirs(backup_root, exist_ok=True) + run_id = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S%fZ') + f'-{os.getpid()}' + run_dir = os.path.join(backup_root, run_id) + os.mkdir(run_dir) + return run_dir + + +def apply_candidate(connection, table_prefix, image, candidate, *, backup_run_dir, base_dir): + """Backup + atomowe podmienienie pliku + SQL (rollback i restore przy bledzie).""" + source = image['path'] + tmp = candidate['temp_path'] + if not tmp: + raise RuntimeError('Brak pliku kandydata') + stat_before = os.stat(source) + if os.path.islink(source) or stat_before.st_nlink != 1: + raise RuntimeError('Plik jest dowiazaniem symbolicznym/twardym; pomijam') + if (stat_before.st_size != image['before_size'] or + stat_before.st_ino != image['original_ino'] or + stat_before.st_mtime_ns != image['original_mtime_ns']): + raise RuntimeError('Plik zostal zmieniony od poczatku skanowania') + + # Kopia w zewnetrznym folderze z zachowaniem struktury i uprawnien. + relative_path = os.path.relpath(source, base_dir) + backup_file = os.path.join(backup_run_dir, relative_path) + os.makedirs(os.path.dirname(backup_file), exist_ok=True) + if os.path.lexists(backup_file): + raise RuntimeError(f'Kopia zapasowa juz istnieje: {backup_file}') + shutil.copy2(source, backup_file) + if os.path.getsize(backup_file) != stat_before.st_size: + raise RuntimeError('Nieudane kopiowanie backupu (niezgodny rozmiar)') + + # Zachowanie uprawnien, czasu oraz wlasciciela jesli jest mozliwosc. + shutil.copystat(source, tmp) + if hasattr(os, 'chown'): + try: + os.chown(tmp, stat_before.st_uid, stat_before.st_gid) + except PermissionError: + # Uruchomienie z uprawnieniami wlasciciela daje i tak ten sam uid. + if os.stat(tmp).st_uid != stat_before.st_uid: + raise + + stat_now = os.stat(source) + if (stat_now.st_ino, stat_now.st_size, stat_now.st_mtime_ns) != ( + stat_before.st_ino, stat_before.st_size, stat_before.st_mtime_ns + ): + raise RuntimeError('Plik zostal zmodyfikowany rownolegle; przerwano zapis') + + os.replace(tmp, source) # w obrebie tego samego filesystemu + candidate['temp_path'] = None + try: + _update_sizes(connection, table_prefix, image, candidate['after']) + connection.commit() + except Exception as exc: + connection.rollback() + try: + _atomic_restore(backup_file, source) + except Exception as restore_error: + raise RuntimeError( + f'Blad DB ({exc}); AUTO-RESTORE NIEUDANY ({restore_error}), kopia: {backup_file}' + ) from exc + try: + _restore_database_sizes(connection, table_prefix, image) + except Exception as restore_db_error: + raise RuntimeError( + f'Plik przywrocony z backupu, ale nie udalo sie odtworzyc rozmiarow w DB: ' + f'{restore_db_error}; kopia: {backup_file}' + ) from exc + raise RuntimeError(f'Blad DB, przywrocono plik i rozmiary w bazie: {exc}') from exc + return backup_file + + +def process_changes(connection, config, args, images, table_prefix): + summary = {'candidates': 0, 'planned': 0, 'optimized': 0, 'saved_bytes': 0, + 'db_mismatches': 0, 'db_updated': 0, 'errors': 0, 'backups_dir': ''} + base = os.path.realpath(config.FILES_BASE_PATH) + if args.apply and args.optimize: + root = args.drupal_root if args.drupal_root is not None else getattr(config, 'DRUPAL_ROOT', None) + summary['backups_dir'] = _safe_backup_dir(args.backup_dir, base, root) + print(f'[BACKUP] Kopie oryginalow: {summary["backups_dir"]}') + + candidates = [img for img in images if is_candidate(img, args)] + summary['candidates'] = len(candidates) + if args.optimize_top: + candidates = candidates[:args.optimize_top] + candidate_ids = {img['path'] for img in candidates} + max_bytes = int(args.max_file_mb * 1024 * 1024) + + for image in images: + mismatched_rows = [db for db in image['db_rows'] if db['filesize'] != image['before_size']] + summary['db_mismatches'] += len(mismatched_rows) + if not args.optimize or image['path'] not in candidate_ids: + if args.update_db and mismatched_rows: + if args.apply: + try: + _update_sizes(connection, table_prefix, image, image['before_size']) + connection.commit() + summary['db_updated'] += len(mismatched_rows) + print(f'[DB-UPDATE] fid={image["fids"]} -> {image["before_size"]} B') + except Exception as exc: + connection.rollback() + try: + _restore_database_sizes(connection, table_prefix, image) + except Exception as restore_error: + print(f'[CRITICAL] Nie udalo sie przywrocic metadanych DB: {restore_error}') + summary['errors'] += 1 + print(f'[ERROR DB] {image["rel_path"]}: {exc}') + else: + print(f'[DB-PLAN] fid={image["fids"]}: dysk {image["before_size"]} B, DB: {[r["filesize"] for r in image["db_rows"]]}') continue - actual_size = os.path.getsize(full_path) - width, height = get_image_resolution(full_path) - file_hash = hash_file(full_path) - - images_info.append({ - 'fid': fid, - 'path': full_path, - 'rel_path': rel_path, - 'db_size': db_size, - 'actual_size': actual_size, - 'width': width, - 'height': height, - 'hash': file_hash, - }) - - # Rozmiar różniący się - if actual_size != db_size: - stats['mismatched'] += 1 - diff = db_size - actual_size - if diff > 0: - stats['saved_bytes_potential'] += diff - - if debug: - print(f"[ROZMIAR] {rel_path}") - print(f" → DB: {db_size} B, Dysk: {actual_size} B ({(db_size - actual_size)/1024:.1f} KB różnicy)") - - if update_db and not dry_run: - update_sql = "UPDATE files SET filesize = %s WHERE fid = %s" - cursor.execute(update_sql, (actual_size, fid)) - connection.commit() - elif update_db and dry_run: - delta_kb = (actual_size - db_size) / 1024 - print(f"[DRY-RUN] {rel_path}") - print(f" DB: {db_size} B | FS: {actual_size} B | Δ: {delta_kb:+.1f} KB") - - # Optymalizacja (np. zmniejszanie obrazów) - if to_optimize: - if (width > MAX_WIDTH or height > MAX_HEIGHT) or actual_size > MAX_FILESIZE_BYTES: - stats['to_optimize'].append({ - 'path': full_path, - 'size': actual_size, - 'res': f"{width}x{height}" - }) - if update_db and not dry_run: - resized = resize_image(full_path) - if resized: - new_size = os.path.getsize(full_path) - # Aktualizuj bazę po zmianie - update_sql = "UPDATE files SET filesize = %s WHERE fid = %s" - cursor.execute(update_sql, (new_size, fid)) + if os.path.islink(image['path']) or os.stat(image['path']).st_nlink != 1: + image['optimize_status'] = 'skip' + image['optimization_reason'] = 'dowiazanie symboliczne/twarde (pomijam)' + print(f'[SKIP] {image["rel_path"]}: {image["optimization_reason"]}') + continue + if args.apply and args.max_changes and summary['optimized'] >= args.max_changes: + image['optimize_status'] = 'limit' + image['optimization_reason'] = 'osiagnieto limit --max-changes' + continue + # W dry-run obliczamy prawdziwy rozmiar po zapisie na pliku tymczasowym. + result = create_optimized_candidate( + image['path'], max_width=args.max_width, max_height=args.max_height, + max_bytes=max_bytes, jpeg_quality=args.jpeg_quality, + min_jpeg_quality=args.min_jpeg_quality, + min_gain_percent=args.min_gain_percent, + temp_dir=os.path.dirname(image['path']) if args.apply else None, + ) + image['optimization_reason'] = result['reason'] + image['optimize_status'] = result['status'] + image['quality'] = result.get('quality') + image['preview_size'] = result['after'] + if result['status'] == 'error': + summary['errors'] += 1 + print(f'[ERROR] {image["rel_path"]}: {result["reason"]}') + continue + if result['status'] == 'skip': + if args.debug: + print(f'[SKIP] {image["rel_path"]}: {result["reason"]}') + # Opcjonalny sync DB dla plikow, ktorych optymalizacja sie nie udala. + if args.update_db and mismatched_rows: + if args.apply: + try: + _update_sizes(connection, table_prefix, image, image['before_size']) connection.commit() - if debug: - print(f"[ZMIENIONO] {rel_path}: zmniejszono do {new_size} B") + summary['db_updated'] += len(mismatched_rows) + except Exception as exc: + connection.rollback() + try: + _restore_database_sizes(connection, table_prefix, image) + except Exception as restore_error: + print(f'[CRITICAL] Nie udalo sie przywrocic metadanych DB: {restore_error}') + summary['errors'] += 1 + print(f'[ERROR DB] {image["rel_path"]}: {exc}') + else: + print(f'[DB-PLAN] fid={image["fids"]}: {image["before_size"]} B') + continue + summary['planned'] += 1 + gain = result['before'] - result['after'] + desc = ( + f'{image["rel_path"]} | {result["before"] / 1024**2:.2f} -> ' + f'{result["after"] / 1024**2:.2f} MB | ' + f'{result["width"]}x{result["height"]} | -{gain / 1024**2:.2f} MB' + ) + try: + if args.apply: + backup_file = apply_candidate(connection, table_prefix, image, result, + backup_run_dir=summary['backups_dir'], base_dir=base) + image['backup_file'] = backup_file + image['after_size'] = result['after'] + image['optimize_status'] = 'done' + summary['optimized'] += 1 + summary['saved_bytes'] += gain + summary['db_updated'] += len(image['db_rows']) + print(f'[ZMIENIONO] {desc} | backup: {backup_file}') + else: + image['optimize_status'] = 'planned' + image['after_size'] = result['after'] + summary['saved_bytes'] += gain + print(f'[DRY-RUN] {desc} (bez zapisu, DB bez zmian)') + except Exception as exc: + image['optimize_status'] = 'error' + image['optimization_reason'] = str(exc) + summary['errors'] += 1 + print(f'[ERROR] {image["rel_path"]}: {exc}') + finally: + if result.get('temp_path') and os.path.isfile(result['temp_path']): + os.unlink(result['temp_path']) + summary['apply'] = bool(args.apply) + return summary - elif update_db and dry_run: - print(f"[DRY-RUN] Zmniejszyłbym {rel_path}") - # Mapowanie hashy do detekcji duplikatów - if find_duplicates and file_hash: - if file_hash not in hash_map: - hash_map[file_hash] = [] - hash_map[file_hash].append({ - 'fid': fid, - 'rel_path': rel_path, - 'path': full_path, - 'db_size': db_size, - 'actual_size': actual_size, - 'width': width, - 'height': height, - }) +def find_references(connection, args, config, images, table_prefix): + if not args.find_usage: + return + if args.usage_scope == 'top': + selected = images[:args.top] + elif args.usage_scope == 'optimizable': + selected = [image for image in images if is_candidate(image, args)] + else: + selected = images - # Największe pliki - stats['largest'] = sorted(images_info, key=lambda x: x['actual_size'], reverse=True)[:top] + print(f'[INFO] Szukam tematu/posta dla {len(selected)} plikow (scope={args.usage_scope})') + if len(selected) > 1000: + print('[WARN] Duza liczba plikow do wyszukania; skan tekstu moze trwac dlugo i zuzyc duzo RAM') + lookup_files = [] + for image in selected: + for db in image['db_rows']: + lookup_files.append({'fid': db['fid'], 'db_filepath': db['db_filepath'], 'rel_path': image['rel_path']}) + if not lookup_files: + return + base_url = args.site_url if args.site_url is not None else getattr(config, 'DRUPAL_BASE_URL', '') + finder = DrupalReferenceLookup(connection, table_prefix=table_prefix, + base_url=base_url, clean_urls=not args.no_clean_urls, + comment_permalinks=args.comment_permalinks) + found = finder.lookup(lookup_files, scan_text=not args.no_text_scan, + scan_cck_text=args.scan_cck_text, basename_match=not args.no_basename_match) + for image in selected: + image['usage_checked'] = True + # Laczymy referencje do tego samego pliku z kilku rekordow fid. + combined = {} + for fid in image['fids']: + for ref in found.get(fid, []): + key = (ref['nid'], ref['cid']) + if key not in combined: + combined[key] = dict(ref) + combined[key]['sources'] = list(ref['sources']) + combined[key]['fids'] = [fid] + else: + if fid not in combined[key]['fids']: + combined[key]['fids'].append(fid) + for source in ref['sources']: + if source not in combined[key]['sources']: + combined[key]['sources'].append(source) + if ref['confidence'] == 'pewne': + combined[key]['confidence'] = 'pewne' + image['references'] = sorted(combined.values(), key=lambda r: (r['nid'], r['cid'] or 0)) + return - # Najwyższa rozdzielczość - stats['highest_res'] = sorted(images_info, key=lambda x: x['width'] * x['height'], reverse=True)[:top] - # Szukanie duplikatów - if find_duplicates: - duplicates = [] - for file_hash, files_list in hash_map.items(): - if len(files_list) > 1: - # Sprawdź które pliki są używane w systemie - used = [] - orphaned = [] - for f in files_list: - if f['rel_path'] in used_filepaths: - used.append(f) - else: - orphaned.append(f) - - duplicates.append({ - 'hash': file_hash, - 'used': used, - 'orphaned': orphaned, - }) - stats['duplicates'] = duplicates - - # Pokazywanie plików na dysku, których brak w bazie (usunięte w bazie) - if show_deleted: - disk_files = [] - for root, _, files in os.walk(FILES_BASE_PATH): - # Sprawdź czy folder jest wykluczony - rel_root = os.path.relpath(root, FILES_BASE_PATH).replace('\\','/') - if any(rel_root.startswith(excl) for excl in exclude_folders): +def disk_only_files(images, config, args): + base = os.path.realpath(config.FILES_BASE_PATH) + known = {img['rel_path'] for img in images} + allowed = {'.' + x.strip().lower().lstrip('.') for x in args.extensions.split(',') if x.strip()} + seen = [] + for root, directories, files in os.walk(base, followlinks=False): + directories[:] = [folder for folder in directories if not _excluded( + os.path.relpath(os.path.join(root, folder), base).replace(os.sep, '/'), args.exclude_folders + )] + for filename in files: + if os.path.splitext(filename)[1].lower() not in allowed: continue + full_path = os.path.join(root, filename) + if not _within(full_path, base): + continue + rel = os.path.relpath(full_path, base).replace(os.sep, '/') + if rel not in known and not _excluded(rel, args.exclude_folders): + seen.append(rel) + return seen - for f in files: - if not f.lower().endswith(allowed_exts): - continue - full_path = os.path.join(root, f) - rel_path = os.path.relpath(full_path, FILES_BASE_PATH).replace('\\', '/') - if rel_path not in used_filepaths: - disk_files.append(rel_path) - stats['deleted'] = disk_files - connection.close() - return stats +def image_url(base_url, public_files_path, rel_path): + if not base_url or not public_files_path: + return '' + return (f'{base_url.rstrip("/")}/' + + quote(public_files_path.strip('/') + '/' + rel_path.lstrip('/'), safe='/')) -def print_summary(stats, to_optimize=False, find_duplicates=False, show_deleted=False, top=20): - print(f"\n📊 Podsumowanie analizy:") - print(f" 🔢 Liczba plików: {stats['total']}") - print(f" ❌ Brakujące pliki: {stats['missing']}") - print(f" 🛠 Rozmiary różniące się: {stats['mismatched']}") - print(f" 💾 Potencjalne oszczędności po zmniejszeniu: {stats['saved_bytes_potential'] / (1024**2):.2f} MB") - print(f"\n🖼 Największe obrazy (top {top}):") - for img in stats['largest']: - size_kb = img['actual_size'] / 1024 - size_mb = size_kb / 1024 - print(f" - {img['path']} ({size_kb:.1f} KB / {size_mb:.2f} MB)") +def display_summary(images, missing, process_stats, args, config, *, deleted=None, duplicates=None): + print('\n=== PODSUMOWANIE ===') + print(f'Znalezione pliki: {len(images)} | Nieodnalezione: {len(missing)} | ' + f'Niezgodne rozmiary DB: {process_stats["db_mismatches"]}') + print(f'Kandydaci do zmniejszenia: {process_stats["candidates"]} | ' + f'Zmniejszone: {process_stats["optimized"]} | Bledy: {process_stats["errors"]}') + prefix = 'Przewidywane' if not args.apply else 'Uzyskane' + print(f'{prefix} oszczednosci: {process_stats["saved_bytes"] / 1024**2:.2f} MB') + if process_stats['backups_dir']: + print(f'Kopie zapasowe: {process_stats["backups_dir"]}') + print('\n=== NAJWIEKSZE OBRAZY ===') + base_url = args.site_url if args.site_url is not None else getattr(config, 'DRUPAL_BASE_URL', '') + for rank, img in enumerate(images[:args.top], 1): + print(f'{rank:3d}. {img["before_size"] / 1024**2:8.2f} MB ' + f'{img["width"]:5d}x{img["height"]:<5d} ' + f'fid={",".join(map(str, img["fids"]))} {img["rel_path"]}') + if base_url: + public_path = (args.public_files_path if args.public_files_path is not None + else getattr(config, 'PUBLIC_FILES_PATH', 'sites/default/files')) + print(f' PLIK (jesli publiczny): {image_url(base_url, public_path, img["rel_path"])}') + if args.find_usage and img.get('usage_checked'): + if img['references']: + for ref in img['references']: + label = f'{ref["title"]} ({ref["node_type"]}, nid={ref["nid"]}' + if ref['cid']: + label += f', cid={ref["cid"]}' + label += f', {ref["confidence"]})' + if not ref['published']: + label += ' [WPIS NIEOPUBLIKOWANY]' + if ref.get('comment_published') is False: + label += ' [KOMENTARZ NIEOPUBLIKOWANY]' + print(f' -> {label}') + print(f' LINK: {ref["url"]}') + print(f' EDYCJA: {ref["edit_url"]}') + print(f' SKAD: {", ".join(ref["sources"])}') + else: + print(' -> [BRAK POWIAZANIA] Nie znaleziono w obslugiwanych polach') + name = os.path.basename(img['rel_path']) + maybe_search_url = search_url(base_url, name, clean_urls=not args.no_clean_urls) + if maybe_search_url: + print(f' SZUKAJ (Drupal Search, jesli aktywny): {maybe_search_url}') + if args.find_usage and args.usage_scope != 'top': + shown = {img['path'] for img in images[:args.top]} + additional = [img for img in images if img['path'] not in shown and img['references']] + if additional: + print(f'\n=== POWIAZANIA POZA TOP {args.top} ===') + for img in additional: + print(f'{img["rel_path"]} ({img["before_size"] / 1024**2:.2f} MB)') + for ref in img['references']: + print(f' -> {ref["title"]}: {ref["url"]} [{ref["confidence"]}]') + print(f'\n=== NAJWYZSZA ROZDZIELCZOSC (TOP {args.top}) ===') + for img in sorted(images, key=lambda f: f['width'] * f['height'], reverse=True)[:args.top]: + print(f'{img["width"]:5d}x{img["height"]:<5d} {img["before_size"] / 1024**2:8.2f} MB {img["rel_path"]}') + if missing: + print(f'\n=== PLIKI W BAZIE BEZ PLIKU NA DYSKU (pierwsze 30) ===') + for item in missing[:30]: + print(f'fid={item["fid"]} {item["db_filepath"]}: {item["reason"]}') + if duplicates: + print(f'\n=== DUPLIKATY SHA-256: {len(duplicates)} grup ===') + for d in duplicates: + print(f'Hash {d["hash"][:16]}... | {d["size"] / 1024**2:.2f} MB') + for filepath in d['paths']: + print(f' {filepath}') + if deleted is not None: + print(f'\n=== NA DYSKU, ALE NIE W ZESTAWIENIU: {len(deleted)} (pierwsze 60) ===') + for rel in deleted[:60]: + print(f' {rel}') + if args.fid or args.path_contains or args.min_size_mb: + print(' [UWAGA] Przy aktywnych filtrach lista zawiera takze pliki odfiltrowane.') + if not args.apply: + print('\n[DRY-RUN] Nie zmieniono plikow ani bazy. Zapis wymaga --apply.') - print(f"\n📐 Najwyższe rozdzielczości (top {top}):") - for img in stats['highest_res']: - print(f" - {img['path']} ({img['width']}x{img['height']})") - if to_optimize and stats['to_optimize']: - print(f"\n⚡ Pliki do optymalizacji ({len(stats['to_optimize'])}):") - for opt in stats['to_optimize']: - size_kb = opt['size'] / 1024 - print(f" - {opt['path']} ({opt['res']}, {size_kb:.1f} KB)") +def duplicate_groups(images): + by_hash = defaultdict(list) + for image in images: + digest = sha256_file(image['path']) + by_hash[digest].append(image) + return [{'hash': digest, 'size': items[0]['before_size'], + 'paths': [img['rel_path'] for img in items]} + for digest, items in by_hash.items() if len(items) > 1] - if find_duplicates and stats['duplicates']: - print(f"\n🔍 Duplikaty ({len(stats['duplicates'])} grup):") - for group in stats['duplicates']: - print(f" Hash: {group['hash']}") - if group['used']: - print(" ▶ Używane w systemie:") - for f in group['used']: - print(f" - {f['rel_path']}") - if group['orphaned']: - print(" ⚠️ Osierocone (nieużywane):") - for f in group['orphaned']: - print(f" - {f['rel_path']}") - if show_deleted and stats['deleted']: - print(f"\n❌ Pliki na dysku nieznalezione w bazie (usunięte?):") - for f in stats['deleted']: - print(f" - {f}") +def write_csv(csv_path, images, config, args): + fieldnames = [ + 'fid', 'filepath_db', 'filepath', 'rozmiar_przed_B', 'rozmiar_po_lub_plan_B', + 'rozmiar_db_B', 'szerokosc', 'wysokosc', 'status_optymalizacji', + 'powod', 'nid', 'typ_wpisu', 'tytul', 'cid', 'link_do_wpisu', + 'link_edycji', 'link_do_pliku', 'zrodlo_powiazania', 'pewnosc', 'opublikowany', 'komentarz_opublikowany', 'szukaj_w_drupal', + ] + base_url = args.site_url if args.site_url is not None else getattr(config, 'DRUPAL_BASE_URL', '') + public = (args.public_files_path if args.public_files_path is not None + else getattr(config, 'PUBLIC_FILES_PATH', 'sites/default/files')) + with open(csv_path, 'w', encoding='utf-8-sig', newline='') as target: + writer = csv.DictWriter(target, fieldnames=fieldnames, delimiter=';') + writer.writeheader() + for image in images: + for db_row in image['db_rows']: + refs = [ref for ref in image['references'] + if not ref.get('fids') or db_row['fid'] in ref['fids']] or [None] + for ref in refs: + row = { + 'fid': db_row['fid'], 'filepath_db': db_row['db_filepath'], + 'filepath': image['rel_path'], 'rozmiar_przed_B': image['before_size'], + 'rozmiar_po_lub_plan_B': image['after_size'], + 'rozmiar_db_B': db_row['filesize'], 'szerokosc': image['width'], + 'wysokosc': image['height'], 'status_optymalizacji': image['optimize_status'], + 'powod': image['optimization_reason'], + 'link_do_pliku': image_url(base_url, public, image['rel_path']), + 'szukaj_w_drupal': search_url(base_url, os.path.basename(image['rel_path']), + clean_urls=not args.no_clean_urls), + } + if ref: + row.update({ + 'nid': ref['nid'], 'typ_wpisu': ref['node_type'], 'tytul': ref['title'], + 'cid': ref['cid'] or '', 'link_do_wpisu': ref['url'], + 'link_edycji': ref['edit_url'], 'zrodlo_powiazania': ', '.join(ref['sources']), + 'pewnosc': ref['confidence'], 'opublikowany': 'tak' if ref['published'] else 'nie', + 'komentarz_opublikowany': ('tak' if ref['comment_published'] else 'nie') + if ref.get('comment_published') is not None else '', + }) + writer.writerow(row) -def main(): - parser = argparse.ArgumentParser(description="Analiza plików w Drupal 6") - parser.add_argument('--debug', action='store_true', help='Tryb debugowania') - parser.add_argument('--update-db', action='store_true', help='Aktualizuj rozmiary plików w bazie') - parser.add_argument('--dry-run', action='store_true', help='Symuluj zmiany bez zapisywania') - parser.add_argument('--extensions', default='jpg,jpeg,png,gif', help='Rozszerzenia plików do analizy') - parser.add_argument('--exclude-folders', nargs='*', default=[], help='Foldery do wykluczenia (względem files/)') - parser.add_argument('--find-duplicates', action='store_true', help='Znajdź i pokaż duplikaty') - parser.add_argument('--show-deleted', action='store_true', help='Pokaż pliki na dysku usunięte z bazy') - parser.add_argument('--optimize', action='store_true', help='Zmniejsz obrazy powyżej limitów') - parser.add_argument('--top', type=int, default=20, help='Ilość największych i najwyższych do pokazania') - args = parser.parse_args() +def write_json(path, images, missing, stats, duplicates=None, deleted=None): + output = { + 'mode': 'apply' if stats.get('apply') else 'dry_run_or_read_only', + 'stats': stats, 'missing': missing, + 'images': images, 'duplicate_groups': duplicates or [], 'disk_only': deleted or [], + } + with open(path, 'w', encoding='utf-8') as target: + json.dump(output, target, ensure_ascii=False, indent=2) - stats = compare_files( - debug=args.debug, - to_optimize=args.optimize, - update_db=args.update_db, - dry_run=args.dry_run, - extensions=args.extensions, - exclude_folders=args.exclude_folders, - find_duplicates=args.find_duplicates, - show_deleted=args.show_deleted, - top=args.top, - ) - print_summary( - stats, - to_optimize=args.optimize, - find_duplicates=args.find_duplicates, - show_deleted=args.show_deleted, - top=args.top - ) +def main(argv=None): + parser = build_parser() + args = parser.parse_args(argv) + validate_args(args, parser) + try: + config = load_config() + if not os.path.isdir(config.FILES_BASE_PATH): + raise RuntimeError(f'Brak katalogu FILES_BASE_PATH: {config.FILES_BASE_PATH}') + with get_connection(config) as connection: + images, missing, prefix = collect_images(connection, config, args) + print(f'[INFO] Zidentyfikowano {len(images)} obrazow w bazie; ' + f'{len(missing)} wpisow bez pliku. Tryb: {"APPLY" if args.apply else "DRY-RUN"}.') + stats = process_changes(connection, config, args, images, prefix) + find_references(connection, args, config, images, prefix) + duplicates = duplicate_groups(images) if args.find_duplicates else None + deleted = disk_only_files(images, config, args) if args.show_deleted else None + display_summary(images, missing, stats, args, config, deleted=deleted, duplicates=duplicates) + if args.report_csv: + write_csv(args.report_csv, images, config, args) + print(f'[RAPORT] CSV: {args.report_csv}') + if args.report_json: + write_json(args.report_json, images, missing, stats, duplicates=duplicates, deleted=deleted) + print(f'[RAPORT] JSON: {args.report_json}') + return 1 if stats['errors'] else 0 + except Exception as exc: + print(f'[ERROR] {type(exc).__name__}: {exc}', file=sys.stderr) + if args.debug: + import traceback + traceback.print_exc() + return 2 + if __name__ == '__main__': - main() + sys.exit(main()) diff --git a/requirements.txt b/requirements.txt index 48f10fb..c6190f3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ -pymysql -Pillow +PyMySQL>=1.0,<2 +Pillow>=9.1 diff --git a/tests/test_drupal6.py b/tests/test_drupal6.py new file mode 100644 index 0000000..c3067f8 --- /dev/null +++ b/tests/test_drupal6.py @@ -0,0 +1,379 @@ +import io +import os +import re +import sys +import tempfile +import types +import unittest +from contextlib import redirect_stdout +from pathlib import Path +from unittest.mock import patch + +from PIL import Image + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import main +from drupal_lookup import DrupalReferenceLookup, ReferenceMatcher, node_url, search_url +from image_optimizer import create_optimized_candidate + + +class FakeCursor: + def __init__(self, conn): + self.conn = conn + self.results = [] + self.index = 0 + self.rowcount = 0 + + def __enter__(self): + return self + + def __exit__(self, *_): + return False + + def close(self): + pass + + def execute(self, sql, params=None): + params = params or () + self.index = 0 + self.rowcount = 0 + self.results = [] + if sql == 'SHOW TABLES': + self.results = [{'Tables_in_test': table} for table in self.conn.schemas] + elif sql.startswith('SHOW COLUMNS FROM'): + table = re.search(r'`([^`]+)`', sql).group(1) + self.results = [{'Field': name, 'Type': data_type} for name, data_type in self.conn.schemas[table].items()] + elif sql.startswith('SELECT fid, filepath, filesize FROM'): + self.results = [dict(row) for row in self.conn.files] + elif sql.startswith('SELECT filesize, filepath FROM'): + self.results = [dict(row) for row in self.conn.files if row['fid'] == params[0]] + elif sql.startswith('UPDATE `files` SET filesize'): + if self.conn.fail_updates: + self.conn.fail_updates -= 1 + raise RuntimeError('Simulated DB error') + size, fid, path = params + for row in self.conn.files: + if row['fid'] == fid and row['filepath'] == path: + if row['filesize'] != size: + row['filesize'] = size + self.rowcount = 1 + break + return + elif 'FROM `upload` t JOIN' in sql or 'FROM `content_field_scan` t JOIN' in sql: + table = 'upload' if 'FROM `upload`' in sql else 'content_field_scan' + field = 'fid' if table == 'upload' else 'field_scan_fid' + for entry in self.conn.relations.get(table, []): + node = self.conn.nodes.get(entry['nid']) + if not node or (entry.get('vid') and node['vid'] != entry['vid']): + continue + if entry[field] in params: + self.results.append({ + 'nid': node['nid'], 'title': node['title'], + 'node_type': node['type'], 'node_status': node['status'], + 'fid': entry[field], 'cid': entry.get('cid'), + }) + elif 'FROM `comment_upload` t' in sql: + for entry in self.conn.relations.get('comment_upload', []): + comment = next(c for c in self.conn.comments if c['cid'] == entry['cid']) + node = self.conn.nodes[comment['nid']] + if entry['fid'] in params: + self.results.append({'nid': node['nid'], 'title': node['title'], + 'node_type': node['type'], 'node_status': node['status'], + 'comment_status': comment['status'], + 'cid': comment['cid'], 'fid': entry['fid']}) + elif 'r.body AS body' in sql: + for node in self.conn.nodes.values(): + for revision in self.conn.revisions: + if revision['nid'] == node['nid'] and revision['vid'] == node['vid']: + self.results.append({ + 'nid': node['nid'], 'title': node['title'], 'node_type': node['type'], + 'node_status': node['status'], 'body': revision['body'], + }) + elif 'c.cid, c.status AS comment_status' in sql: + for comment in self.conn.comments: + node = self.conn.nodes[comment['nid']] + self.results.append({ + 'nid': node['nid'], 'title': node['title'], 'node_type': node['type'], + 'node_status': node['status'], 'cid': comment['cid'], + 'comment_status': comment['status'], 'comment': comment['comment'], + }) + else: + raise AssertionError(f'Unexpected fake SQL: {sql} / {params}') + self.rowcount = len(self.results) + + def fetchall(self): + return list(self.results) + + def fetchone(self): + return self.results[0] if self.results else None + + def fetchmany(self, count): + result = self.results[self.index:self.index + count] + self.index += len(result) + return result + + +class FakeConnection: + def __init__(self): + self.schemas = { + 'files': {'fid': 'int', 'filepath': 'varchar(255)', 'filesize': 'int'}, + 'node': {'nid': 'int', 'vid': 'int', 'title': 'varchar(255)', 'type': 'varchar(32)', 'status': 'tinyint'}, + 'node_revisions': {'nid': 'int', 'vid': 'int', 'body': 'longtext'}, + 'upload': {'fid': 'int', 'nid': 'int', 'vid': 'int'}, + 'content_field_scan': {'field_scan_fid': 'int', 'nid': 'int', 'vid': 'int'}, + 'comments': {'cid': 'int', 'nid': 'int', 'comment': 'longtext', 'status': 'tinyint'}, + 'comment_upload': {'cid': 'int', 'fid': 'int'}, + } + self.nodes = { + 11: {'nid': 11, 'vid': 101, 'title': 'Stary skan mapy', 'type': 'forum', 'status': 1}, + 12: {'nid': 12, 'vid': 102, 'title': 'Zdjecia z wakacji', 'type': 'story', 'status': 1}, + 13: {'nid': 13, 'vid': 103, 'title': 'Odpowiedz na forum', 'type': 'forum', 'status': 0}, + } + self.relations = { + 'upload': [{'nid': 11, 'vid': 101, 'fid': 7}, {'nid': 13, 'vid': 999, 'fid': 7}], + 'content_field_scan': [{'nid': 12, 'vid': 102, 'field_scan_fid': 8}], + 'comment_upload': [{'cid': 55, 'fid': 8}], + } + self.revisions = [ + {'nid': 11, 'vid': 101, 'body': ''}, + {'nid': 12, 'vid': 102, 'body': '

Wspomnienia.

'}, + {'nid': 13, 'vid': 103, 'body': 'Zobacz mapa.jpg tutaj.'}, + {'nid': 13, 'vid': 999, 'body': 'NIEAKTUALNE /sites/default/files/scans/mapa.jpg'}, + ] + self.comments = [ + {'cid': 55, 'nid': 12, 'comment': 'Znalazlem /sites/default/files/scans/mapa.jpg', 'status': 1}, + ] + self.files = [] + self.fail_updates = 0 + self.commits = 0 + self.rollbacks = 0 + + def __enter__(self): + return self + + def __exit__(self, *_): + return False + + def cursor(self, *_): + return FakeCursor(self) + + def commit(self): + self.commits += 1 + + def rollback(self): + self.rollbacks += 1 + + +class TestLinks(unittest.TestCase): + def test_links(self): + self.assertEqual(node_url('https://example.org/forum/', 42), 'https://example.org/forum/node/42') + self.assertEqual(node_url('https://example.org/forum', 42, 66), 'https://example.org/forum/node/42#comment-66') + self.assertEqual(node_url('https://example.org/forum', 42, clean_urls=False), 'https://example.org/forum/?q=node/42') + self.assertIn('skan%20mapa.jpg', search_url('https://example.org', 'skan mapa.jpg')) + self.assertEqual(node_url('https://example.org', 42, 66, comment_permalinks=True), + 'https://example.org/comment/66#comment-66') + + def test_matcher_precision(self): + matcher = ReferenceMatcher([ + {'fid': 7, 'db_filepath': 'sites/default/files/scans/mapa.jpg', 'rel_path': 'scans/mapa.jpg'}, + ]) + self.assertEqual(matcher.match('')[7], 'sciezka') + self.assertEqual(matcher.match('mapa.jpg')[7], 'nazwa') + self.assertFalse(matcher.match('supermapa.jpg')) + self.assertFalse(ReferenceMatcher([ + {'fid': 7, 'db_filepath': 'sites/default/files/scans/mapa.jpg', 'rel_path': 'scans/mapa.jpg'} + ], basename_match=False).match('mapa.jpg')) + + def test_schema_references_current_only_and_comment_status(self): + conn = FakeConnection() + lookup = DrupalReferenceLookup(conn, base_url='https://forum.example.org') + files = [ + {'fid': 7, 'db_filepath': 'sites/default/files/scans/mapa.jpg', 'rel_path': 'scans/mapa.jpg'}, + {'fid': 8, 'db_filepath': 'sites/default/files/inne.jpg', 'rel_path': 'inne.jpg'}, + ] + fake_pymysql = types.SimpleNamespace(cursors=types.SimpleNamespace(SSDictCursor=object)) + with patch.dict(sys.modules, {'pymysql': fake_pymysql}): + with redirect_stdout(io.StringIO()): + result = lookup.lookup(files) + self.assertEqual({x['nid'] for x in result[7]}, {11, 12, 13}) + self.assertEqual({x['nid'] for x in result[8]}, {12}) + attached_to_comment = [x for x in result[8] if x['cid'] == 55][0] + self.assertIn('comment_upload.fid', attached_to_comment['sources']) + direct = [x for x in result[7] if x['nid'] == 11][0] + self.assertIn('upload.fid', direct['sources']) + self.assertIn('tresc wpisu (sciezka)', direct['sources']) + self.assertEqual(direct['confidence'], 'pewne') + self.assertTrue(direct['url'].endswith('/node/11')) + comment = [x for x in result[7] if x['cid'] == 55][0] + self.assertFalse(comment['comment_published']) + self.assertEqual(comment['url'], 'https://forum.example.org/node/12#comment-55') + possible = [x for x in result[7] if x['nid'] == 13][0] + self.assertFalse(possible['published']) + self.assertEqual(possible['confidence'], 'mozliwe') + self.assertNotIn('upload.fid', possible['sources']) # nieaktualna rewizja + + +class TestFiles(unittest.TestCase): + def setUp(self): + self.workdir = tempfile.TemporaryDirectory() + self.addCleanup(self.workdir.cleanup) + self.root = Path(self.workdir.name) + self.site_root = self.root / 'drupal' + self.folder = self.site_root / 'sites' / 'default' / 'files' + (self.folder / 'scans').mkdir(parents=True) + self.image = self.folder / 'scans' / 'mapa.jpg' + src = Image.effect_noise((950, 1250), 115).convert('RGB') + src.save(self.image, 'JPEG', quality=99) + self.conn = FakeConnection() + self.conn.files = [{ + 'fid': 7, 'filepath': 'sites/default/files/scans/mapa.jpg', 'filesize': 12345, + }] + self.cfg = types.SimpleNamespace( + FILES_BASE_PATH=str(self.folder), PUBLIC_FILES_PATH='sites/default/files', + DRUPAL_ROOT=str(self.site_root), DRUPAL_BASE_URL='https://forum.example.org', + ) + + def args(self, *overrides): + return main.build_parser().parse_args(list(overrides)) + + def test_resolve_prevent_traversal(self): + valid, rel = main.resolve_filepath('sites/default/files/scans/mapa.jpg', str(self.folder)) + self.assertEqual(valid, str(self.image)) + self.assertEqual(rel, 'scans/mapa.jpg') + bad, _ = main.resolve_filepath('../../etc/shadow', str(self.folder)) + self.assertIsNone(bad) + outside, _ = main.resolve_filepath('/etc/passwd', str(self.folder)) + self.assertIsNone(outside) + + def test_backup_must_not_be_in_webroot(self): + with self.assertRaises(ValueError): + main._safe_backup_dir(str(self.site_root / 'backup'), + str(self.folder), str(self.site_root)) + + def test_dry_run_no_changes_and_preview(self): + args = self.args('--optimize', '--update-db', '--dry-run', '--max-width', '600', + '--max-height', '800', '--max-file-mb', '0.5') + images, missing, prefix = main.collect_images(self.conn, self.cfg, args) + self.assertFalse(missing) + before = self.image.read_bytes() + with redirect_stdout(io.StringIO()): + stats = main.process_changes(self.conn, self.cfg, args, images, prefix) + self.assertEqual(self.image.read_bytes(), before) + self.assertEqual(self.conn.files[0]['filesize'], 12345) + self.assertEqual(stats['optimized'], 0) + self.assertEqual(stats['planned'], 1) + self.assertEqual(images[0]['optimize_status'], 'planned') + self.assertLess(images[0]['after_size'], images[0]['before_size']) + + def test_apply_backup_db_and_restore_on_sql_failure(self): + args = self.args('--optimize', '--apply', '--max-width', '600', '--max-height', '800', + '--max-file-mb', '0.5', '--backup-dir', str(self.root / 'backups')) + before = self.image.read_bytes() + images, _, prefix = main.collect_images(self.conn, self.cfg, args) + with redirect_stdout(io.StringIO()): + stats = main.process_changes(self.conn, self.cfg, args, images, prefix) + self.assertEqual(stats['optimized'], 1) + self.assertEqual(stats['errors'], 0) + self.assertLess(self.image.stat().st_size, len(before)) + self.assertEqual(self.conn.files[0]['filesize'], self.image.stat().st_size) + self.assertEqual(Path(images[0]['backup_file']).read_bytes(), before) + with Image.open(self.image) as img: + self.assertLessEqual(img.width, 600) + self.assertLessEqual(img.height, 800) + + # Ponownie uruchomienie z bladem w DB: plik i metadata wracaja. + self.conn.fail_updates = 1 + self.conn.files[0]['filesize'] = 12345 + second_before = self.image.read_bytes() + args2 = self.args('--optimize', '--apply', '--max-width', '400', '--max-height', '550', + '--max-file-mb', '0.3', '--backup-dir', str(self.root / 'backups')) + images2, _, prefix = main.collect_images(self.conn, self.cfg, args2) + with redirect_stdout(io.StringIO()): + stats2 = main.process_changes(self.conn, self.cfg, args2, images2, prefix) + self.assertEqual(stats2['errors'], 1) + self.assertEqual(self.image.read_bytes(), second_before) + self.assertEqual(self.conn.files[0]['filesize'], 12345) + + def test_symlink_is_not_modified(self): + link = self.folder / 'scans' / 'alias.jpg' + link.symlink_to(self.image) + self.conn.files[0]['filepath'] = 'sites/default/files/scans/alias.jpg' + args = self.args('--optimize', '--dry-run', '--max-width', '500') + images, _, prefix = main.collect_images(self.conn, self.cfg, args) + self.assertEqual(images[0]['path'], str(link)) + with redirect_stdout(io.StringIO()): + stats = main.process_changes(self.conn, self.cfg, args, images, prefix) + self.assertEqual(images[0]['optimize_status'], 'skip') + self.assertEqual(stats['planned'], 0) + self.assertTrue(link.is_symlink()) + + def test_exif_orientation_after_optimization(self): + original = self.folder / 'scan-orientation.jpg' + img = Image.new('RGB', (240, 120), 'orange') + exif = Image.Exif() + exif[274] = 6 + img.save(original, 'JPEG', quality=100, exif=exif) + optimized = create_optimized_candidate(str(original), max_width=150, max_height=200, + max_bytes=100, min_gain_percent=0) + self.assertEqual(optimized['status'], 'ready', optimized['reason']) + try: + with Image.open(optimized['temp_path']) as out: + self.assertEqual(out.size, (100, 200)) + self.assertNotEqual(out.getexif().get(274), 6) + finally: + os.unlink(optimized['temp_path']) + + def test_png_with_alpha_preserved(self): + filename = self.folder / 'scans' / 'alpha.png' + img = Image.new('RGBA', (300, 900), (10, 100, 50, 130)) + img.save(filename) + result = create_optimized_candidate(str(filename), max_width=200, max_height=500, + max_bytes=200, min_gain_percent=0) + self.assertEqual(result['status'], 'ready', result['reason']) + try: + with Image.open(result['temp_path']) as output: + self.assertEqual(output.mode, 'RGBA') + self.assertEqual(output.size, (167, 500)) + self.assertEqual(output.getpixel((1, 1))[3], 130) + finally: + os.unlink(result['temp_path']) + + def test_no_optimization_for_small_or_gif(self): + tiny = self.folder / 'tiny.png' + Image.new('RGB', (30, 30), 'red').save(tiny) + self.assertEqual(create_optimized_candidate(str(tiny))['status'], 'skip') + gif = self.folder / 'animated.gif' + Image.new('RGB', (300, 500), 'red').save(gif) + self.assertEqual(create_optimized_candidate(str(gif), max_width=100)['status'], 'skip') + + def test_cli_end_to_end_read_only_generates_reports(self): + csv_path = self.root / 'end.csv' + json_path = self.root / 'end.json' + with patch.object(main, 'load_config', return_value=self.cfg), \ + patch.object(main, 'get_connection', return_value=self.conn), \ + redirect_stdout(io.StringIO()): + status = main.main(['--find-usage', '--no-text-scan', '--top', '10', + '--report-csv', str(csv_path), + '--report-json', str(json_path)]) + self.assertEqual(status, 0) + self.assertIn('https://forum.example.org/node/11', csv_path.read_text(encoding='utf-8-sig')) + self.assertIn('"images"', json_path.read_text(encoding='utf-8')) + self.assertEqual(self.conn.files[0]['filesize'], 12345) + + def test_csv_has_direct_link(self): + args = self.args('--find-usage', '--site-url', 'https://forum.example.org', '--report-csv', str(self.root / 'out.csv')) + images, _, prefix = main.collect_images(self.conn, self.cfg, args) + images[0]['references'] = [ + {'nid': 11, 'cid': None, 'title': 'Przypiety temat', 'node_type': 'forum', + 'published': True, 'comment_published': None, 'sources': ['upload.fid'], + 'confidence': 'pewne', 'url': 'https://forum.example.org/node/11', + 'edit_url': 'https://forum.example.org/node/11/edit'} + ] + main.write_csv(args.report_csv, images, self.cfg, args) + text = Path(args.report_csv).read_text(encoding='utf-8-sig') + self.assertIn('https://forum.example.org/node/11', text) + self.assertIn('Przypiety temat', text) + + +if __name__ == '__main__': + unittest.main()