fixes
This commit is contained in:
1 parent
bb941cff43
commit
6a821c7512
7 files changed
+1768
-326
No files matched your search
@@ -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_*`.
|
||||
+12
-3
@@ -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_'
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
+2
-2
@@ -1,2 +1,2 @@
|
||||
pymysql
|
||||
Pillow
|
||||
PyMySQL>=1.0,<2
|
||||
Pillow>=9.1
|
||||
@@ -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': '<img src="/sites/default/files/scans/mapa.jpg">'},
|
||||
{'nid': 12, 'vid': 102, 'body': '<p>Wspomnienia.</p>'},
|
||||
{'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('<img src="/sites/default/files/scans/mapa.jpg">')[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()
|
||||
Reference in new issue
Block a user