Książka kucharska offline

Jake Archibald
Jake Archibald

Dzięki rozwiązaniu Service Worker zrezygnowaliśmy ze rozwiązywania problemów offline i daliśmy programistom zajęcie się sami. Umożliwia kontrolowanie buforowania i sposobu obsługi żądań. Oznacza to, że możesz tworzyć własne wzory. Przyjrzyjmy się kilku możliwym wzorom osobno, ale w praktyce prawdopodobnie będziesz używać wielu z nich jednocześnie w zależności od adresu URL i kontekstu.

Demo niektórych z tych wzorów znajdziesz w artykule Trained-to-thrill (w języku angielskim) oraz w tym filmie, który pokazuje wpływ na skuteczność.

Maszyna do buforowania – kiedy przechowywać zasoby

Usługa workera umożliwia obsługę żądań niezależnie od pamięci podręcznej, więc pokażę je osobno. Po pierwsze: kiedy należy to robić w pamięci podręcznej?

Przy instalacji – jako zależność

Podczas instalacji jako zależność.
Podczas instalacji – jako zależność.

Usługa w tle wysyła Ci zdarzenie install. Możesz go użyć, aby przygotować elementy, które muszą być gotowe, zanim obsłużysz inne zdarzenia. Chociaż tak się dzieje, każda poprzednia wersja skryptu Service Worker nadal działa i wyświetla strony, więc czynności, które tu wykonujesz, nie mogą tego zakłócać.

Idealne rozwiązanie: CSS, obrazy, czcionki, JS, szablony... właściwie wszystko, co uważasz za statyczne w „wersji” witryny.

Są to elementy, które w razie niepowodzenia pobierania witryny sprawiłyby, że nie będzie działać. Jako część początkowego pobierania będzie używana równoważna aplikacja na danej platformie.

self.addEventListener('install', function (event) {
  event.waitUntil(
    caches.open('mysite-static-v3').then(function (cache) {
      return cache.addAll([
        '/css/whatever-v3.css',
        '/css/imgs/sprites-v6.png',
        '/css/fonts/whatever-v8.woff',
        '/js/all-min-v4.js',
        // etc.
      ]);
    }),
  );
});

event.waitUntil przyjmuje zobowiązanie do określenia długości i skuteczności instalacji. Jeśli obietnica zostanie odrzucona, instalacja zostanie uznana za niepowodzenie, a ten skrypt Service Worker zostanie porzucony (jeśli uruchomiona jest starsza wersja, pozostanie nienaruszona). caches.open() i cache.addAll() – obietnice zwrotu. Jeśli któregoś z zasobów nie uda się pobrać, wywołanie cache.addAll() zostanie odrzucone.

Na etapie trenowania-thrill używam go do buforowania zasobów statycznych.

Przy instalacji – nie jako zależność

Przy instalacji – nie jako zależność.
Podczas instalacji – nie jako zależność.

Jest to podobne do opcji opisanej powyżej, ale nie spowoduje opóźnienia w zakończeniu instalacji ani nie doprowadzi do jej przerwania w przypadku błędu w przypadku buforowania.

Idealny do: większych zasobów, które nie są potrzebne od razu, takich jak zasoby na późniejszych poziomach gry.

self.addEventListener('install', function (event) {
  event.waitUntil(
    caches.open('mygame-core-v1').then(function (cache) {
      cache
        .addAll
        // levels 11–20
        ();
      return cache
        .addAll
        // core assets and levels 1–10
        ();
    }),
  );
});

W tym przykładzie nie jest spełnione cache.addAll na poziomie 11–20 w porównaniu z event.waitUntil, więc nawet jeśli nie uda się osiągnąć tego poziomu, gra będzie nadal dostępna w trybie offline. Oczywiście musisz uwzględnić możliwość braku tych poziomów i ponownie utworzyć je w pamięci podręcznej, jeśli ich brak.

Podczas pobierania poziomów 11–20 może zostać przerwany proces obsługi zdarzeń przez usługę w tle, co oznacza, że nie będą one przechowywane w pamięci podręcznej. W przyszłości interfejs Web Periodic Background Synchronization API będzie obsługiwać takie przypadki oraz większe pliki do pobrania, np. filmy. Ten interfejs API jest obecnie obsługiwany tylko w odgałęzieniach Chromium.

Po włączeniu

Włączony.
Przy aktywacji.

Idealny do: czyszczenia i migracji.

Gdy nowy skrypt service worker zostanie zainstalowany, a poprzednia wersja nie jest używana, nowy skrypt zostanie aktywowany i wywoła zdarzenie activate. Ponieważ stara wersja nie jest już potrzebna, warto przeprowadzić migrację schematu w IndexedDB, a także usunąć nieużywane pamięci podręczne.

self.addEventListener('activate', function (event) {
  event.waitUntil(
    caches.keys().then(function (cacheNames) {
      return Promise.all(
        cacheNames
          .filter(function (cacheName) {
            // Return true if you want to remove this cache,
            // but remember that caches are shared across
            // the whole origin
          })
          .map(function (cacheName) {
            return caches.delete(cacheName);
          }),
      );
    }),
  );
});

Podczas aktywacji inne zdarzenia, takie jak fetch, są umieszczane w kole, więc długa aktywacja może potencjalnie blokować wczytywanie stron. Postaraj się, aby aktywacja była jak najprostsza i używaj jej tylko do czynności, których nie można wykonać, gdy stara wersja była aktywna.

Na stronie trained-to-thrill używam tego do usuwania starych pamięci podręcznych.

Po interakcji użytkownika

przy interakcji użytkownika.
Przy interakcji użytkownika

Idealne rozwiązanie: gdy nie można udostępnić offline całej witryny, a chcesz umożliwić użytkownikowi wybranie treści, które mają być dostępne offline. Przykład: film w YouTube, artykuł w Wikipedii lub konkretna galeria na Flickrze.

Udostępnij użytkownikowi przycisk „Przeczytaj później” lub „Zapisz do użytku offline”. Po kliknięciu pobierasz z sieci potrzebne informacje i przechowujesz je w pamięci podręcznej.

document.querySelector('.cache-article').addEventListener('click', function (event) {
  event.preventDefault();

  var id = this.dataset.articleId;
  caches.open('mysite-article-' + id).then(function (cache) {
    fetch('/get-article-urls?id=' + id)
      .then(function (response) {
        // /get-article-urls returns a JSON-encoded array of
        // resource URLs that a given article depends on
        return response.json();
      })
      .then(function (urls) {
        cache.addAll(urls);
      });
  });
});

Interfejs caches API jest dostępny na stronach i w skryptach service worker, co oznacza, że możesz dodawać elementy do pamięci podręcznej bezpośrednio ze strony.

Odpowiedź w sieci

Odpowiedź sieci.
Odpowiedź sieci.

Idealne rozwiązanie do: często aktualizowanych zasobów, takich jak skrzynka odbiorcza użytkownika lub treść artykułu. Są one też przydatne w przypadku treści nieistotnych, takich jak awatary, ale należy zachować ostrożność.

Jeśli żądanie nie pasuje do niczego w pamięci podręcznej, pobierz je z sieci, prześlij na stronę i dopisz do pamięci podręcznej.

W przypadku wielu adresów URL, np. awatarów, uważaj, aby nie nadwyrężać miejsca na dane pochodzenia. Jeśli użytkownik musi zwolnić miejsce na dysku, nie możesz być głównym kandydatem. Usuń z pamięci podręcznej elementy, których już nie potrzebujesz.

self.addEventListener('fetch', function (event) {
  event.respondWith(
    caches.open('mysite-dynamic').then(function (cache) {
      return cache.match(event.request).then(function (response) {
        return (
          response ||
          fetch(event.request).then(function (response) {
            cache.put(event.request, response.clone());
            return response;
          })
        );
      });
    }),
  );
});

Aby umożliwić wydajne wykorzystanie pamięci, możesz odczytać treść odpowiedzi/żądania tylko raz. Powyższy kod używa .clone() do tworzenia dodatkowych kopii, które można czytać osobno.

W przypadku trained-to-thrill używam tego do buforowania obrazów z Flickr.

Nieaktualny podczas ponownego sprawdzania ważności

Stale-while-revalidate.
Nieużywaj w trakcie ponownej weryfikacji.

Idealne rozwiązanie: często aktualizowane zasoby, w przypadku których posiadanie najnowszej wersji nie jest konieczne. Do tej kategorii mogą należeć również awatary.

Jeśli w pamięci podręcznej jest dostępna wersja, użyj jej, ale pobierz aktualizację na przyszłość.

self.addEventListener('fetch', function (event) {
  event.respondWith(
    caches.open('mysite-dynamic').then(function (cache) {
      return cache.match(event.request).then(function (response) {
        var fetchPromise = fetch(event.request).then(function (networkResponse) {
          cache.put(event.request, networkResponse.clone());
          return networkResponse;
        });
        return response || fetchPromise;
      });
    }),
  );
});

Jest to bardzo podobne do metody stale-Tymczasem-revalidate przez HTTP.

Przy wiadomości push

Na wiadomości push.
W wiadomości push.

Interfejs Push API to kolejna funkcja opracowana na podstawie skryptu service worker. Dzięki temu usługa workera może zostać uruchomiona w odpowiedzi na wiadomość z usługi przesyłania wiadomości w systemie operacyjnym. Dzieje się tak nawet wtedy, gdy użytkownik nie ma otwartej karty na Twojej stronie. Wybudzany jest tylko Service Worker. Na stronie wyświetli się prośba o przyznanie uprawnień.

Idealne do: treści związanych z powiadomieniem, takich jak wiadomość na czacie, najnowsze informacje lub e-mail. Dotyczy to również treści, które zmieniają się rzadko i korzystają z natychmiastowej synchronizacji, takich jak aktualizacja listy zadań czy zmiana w kalendarzu.

Najczęstszym końcowym działaniem jest powiadomienie, które po kliknięciu otwiera odpowiednią stronę. W takim przypadku bardzo ważne jest zaktualizowanie pamięci podręcznej przed tym działaniem. Użytkownik jest oczywiście online w momencie otrzymania wiadomości push, ale może nie być online, gdy w końcu wejdzie w interakcję z powiadomieniem. Dlatego ważne jest, aby treści były dostępne offline.

Ten kod aktualizuje pamięć podręczną przed wyświetleniem powiadomienia:

self.addEventListener('push', function (event) {
  if (event.data.text() == 'new-email') {
    event.waitUntil(
      caches
        .open('mysite-dynamic')
        .then(function (cache) {
          return fetch('/inbox.json').then(function (response) {
            cache.put('/inbox.json', response.clone());
            return response.json();
          });
        })
        .then(function (emails) {
          registration.showNotification('New email', {
            body: 'From ' + emails[0].from.name,
            tag: 'new-email',
          });
        }),
    );
  }
});

self.addEventListener('notificationclick', function (event) {
  if (event.notification.tag == 'new-email') {
    // Assume that all of the resources needed to render
    // /inbox/ have previously been cached, e.g. as part
    // of the install handler.
    new WindowClient('/inbox/');
  }
});

Synchronizacja w tle

Synchronizacja w tle.
W trybie synchronizacji w tle.

Synchronizacja w tle to kolejna funkcja oparta na serwisie Worker. Dzięki niemu możesz zażądać synchronizacji danych w tle jednorazowo lub w odstępach (wyjątkowo heurystycznych). Dzieje się tak nawet wtedy, gdy użytkownik nie ma otwartej karty z Twoją witryną. Tylko mechanizm Service Worker został obudzony. Użytkownik otrzyma prośbę o przyznanie uprawnień.

Idealne rozwiązanie: niepilne aktualizacje, zwłaszcza te, które pojawiają się tak regularnie, że wysyłanie powiadomienia push po każdej aktualizacji byłoby dla użytkowników zbyt częste, np. o aktualnościach w mediach społecznościowych lub artykułach informacyjnych.

self.addEventListener('sync', function (event) {
  if (event.id == 'update-leaderboard') {
    event.waitUntil(
      caches.open('mygame-dynamic').then(function (cache) {
        return cache.add('/leaderboard.json');
      }),
    );
  }
});

Pamięć podręczna z trwałością

Źródło ma pewną ilość wolnego miejsca, z którego może korzystać według własnego uznania. Wolne miejsce jest współużytkowane przez całą pamięć masową punktu początkowego: (lokalną) pamięć masową, IndexedDB, dostęp do systemu plików i oczywiście pamięci podręczne.

Kwota, którą otrzymasz, nie jest określona. Zależy to od urządzenia i warunków przechowywania. Aby sprawdzić, ile masz punktów:

if (navigator.storage && navigator.storage.estimate) {
  const quota = await navigator.storage.estimate();
  // quota.usage -> Number of bytes used.
  // quota.quota -> Maximum number of bytes available.
  const percentageUsed = (quota.usage / quota.quota) * 100;
  console.log(`You've used ${percentageUsed}% of the available storage.`);
  const remaining = quota.quota - quota.usage;
  console.log(`You can write up to ${remaining} more bytes.`);
}

Jednak podobnie jak w przypadku innych danych w przeglądarce, przeglądarka może je usunąć, jeśli urządzenie ma za mało miejsca. Niestety przeglądarka nie potrafi odróżnić filmów, które chcesz zachować za wszelką cenę, od gry, która Cię nie interesuje.

Aby to obejść, użyj interfejsu StorageManager:

// From a page:
navigator.storage.persist()
.then(function(persisted) {
  if (persisted) {
    // Hurrah, your data is here to stay!
  } else {
   // So sad, your data may get chucked. Sorry.
});

Oczywiście użytkownik musi udzielić zgody. Aby to zrobić, użyj interfejsu Permissions API.

Ważne jest, aby użytkownik był częścią tego procesu, ponieważ teraz to on ma kontrolować usuwanie. Jeśli na urządzeniu zaczyna brakować miejsca, a wyczyszczenie mniej ważnych danych nie rozwiązuje problemu, użytkownik musi zdecydować, które z nich zachować, a które usunąć.

Aby to działało, systemy operacyjne muszą traktować „trwałe” źródła jako odpowiednik aplikacji działających na określonej platformie, a nie traktować przeglądarkę jako pojedynczy element.

Sugerowane odpowiedzi – odpowiadanie na prośby

Nie ma znaczenia, ile danych jest przechowywanych w pamięci podręcznej. Worker usługi nie będzie korzystać z pamięci podręcznej, dopóki nie określisz, kiedy i jak ma to robić. Oto kilka wzorów obsługi żądań:

Tylko pamięć podręczna

Tylko pamięć podręczna.
Tylko pamięć podręczna.

Idealne rozwiązanie: wszystko, co uważasz za statyczne w przypadku danej „wersji” witryny. Te dane powinny być zapisane w pamięci podręcznej w zdarzeniu instalacji, więc możesz na nich polegać.

self.addEventListener('fetch', function (event) {
  // If a match isn't found in the cache, the response
  // will look like a connection error
  event.respondWith(caches.match(event.request));
});

…chociaż nie musisz często zajmować się tym przypadkiem, Pamięć podręczna, czyli Fallback do sieci, poradzi sobie z tym.

Tylko sieć

Tylko sieć.
Tylko sieć.

Idealne do: rzeczy, które nie mają odpowiednika offline, np. pingi analityczne czy żądania inne niż GET.

self.addEventListener('fetch', function (event) {
  event.respondWith(fetch(event.request));
  // or simply don't call event.respondWith, which
  // will result in default browser behavior
});

…chociaż nie musisz często zajmować się tym przypadkiem, Pamięć podręczna, czyli Fallback do sieci, poradzi sobie z tym.

Pamięć podręczna, powrót do sieci

Pamięć podręczna, wracam do sieci.
Buforowanie, z powrotem do sieci.

Idealny do: tworzenia aplikacji offline. W takich przypadkach w taki sposób będziesz obsługiwać większość próśb. Inne wzorce będą wyjątkami zależnymi od przychodzącego żądania.

self.addEventListener('fetch', function (event) {
  event.respondWith(
    caches.match(event.request).then(function (response) {
      return response || fetch(event.request);
    }),
  );
});

Dzięki temu możesz uzyskać zachowanie „tylko pamięci podręcznej” w przypadku elementów w pamięci podręcznej i zachowanie „tylko sieci” w przypadku elementów, które nie są przechowywane w pamięci podręcznej (co obejmuje wszystkie żądania inne niż GET, ponieważ nie można ich przechowywać w pamięci podręcznej).

Wyścig pamięci podręcznej i sieci

wyścig w pamięci podręcznej i sieci;
Wyścig pamięci podręcznej i sieci.

Idealny do: małych zasobów, w przypadku których zależy Ci na wydajności na urządzeniach z wolnym dostępem do dysku.

W przypadku niektórych kombinacji starszych dysków twardych, skanera antywirusowego i szybszych połączeń z internetem pobieranie zasobów z sieci może być szybsze niż z dysku. Pamiętaj jednak, że korzystanie z sieci, gdy użytkownik ma treści na urządzeniu, może powodować marnowanie danych.

// Promise.race is no good to us because it rejects if
// a promise rejects before fulfilling. Let's make a proper
// race function:
function promiseAny(promises) {
  return new Promise((resolve, reject) => {
    // make sure promises are all promises
    promises = promises.map((p) => Promise.resolve(p));
    // resolve this promise as soon as one resolves
    promises.forEach((p) => p.then(resolve));
    // reject if all promises reject
    promises.reduce((a, b) => a.catch(() => b)).catch(() => reject(Error('All failed')));
  });
}

self.addEventListener('fetch', function (event) {
  event.respondWith(promiseAny([caches.match(event.request), fetch(event.request)]));
});

Sieć przełącza się z powrotem na pamięć podręczną

Sieć używa pamięci podręcznej.
Sieć używa pamięci podręcznej.

Idealne rozwiązanie: szybkie rozwiązanie dla zasobów, które są często aktualizowane, poza „wersją” witryny. np. artykuły, awatary, osi czasu w mediach społecznościowych i tabele wyników gier.

Oznacza to, że użytkownicy online otrzymują najnowsze treści, ale użytkownicy offline otrzymują starsze wersje z pamięci podręcznej. Jeśli żądanie sieci zakończy się powodzeniem, prawdopodobnie zaktualizujesz wpis w pamięci podręcznej.

Ta metoda ma jednak swoje wady. Jeśli użytkownik ma przerywane lub wolne połączenie, musi poczekać, aż sieć przestanie działać, aby uzyskać na urządzeniu treści w idealnej jakości. Sprawdzenie witryny może zająć dużo czasu i jest frustrujące dla użytkowników. Aby znaleźć lepsze rozwiązanie, zobacz następny wzór: Pamięć podręczna, a potem sieć.

self.addEventListener('fetch', function (event) {
  event.respondWith(
    fetch(event.request).catch(function () {
      return caches.match(event.request);
    }),
  );
});

Pamięć podręczna, a potem sieć

Buforuj, a potem sieć.
Pamięć podręczna, a następnie sieć.

Idealne rozwiązanie: treści, które są często aktualizowane. Np. artykuły, osi czasu w mediach społecznościowych i gry. Tabele wyników

Wymaga to wysłania przez stronę 2 żądań: jednego do pamięci podręcznej i drugiego do sieci. Chodzi o to, aby najpierw wyświetlić dane z pamięci podręcznej, a następnie zaktualizować stronę, gdy pojawią się dane sieciowe.

Czasami możesz po prostu zastąpić bieżące dane nowymi (np. tabelą wyników w grze), ale może to zakłócać wyświetlanie większych treści. Ogólnie rzecz biorąc, nie „usuwaj” elementów, które użytkownik może czytać lub z którymi może wchodzić w interakcję.

Twitter dodaje nowe treści nad starymi treściami i dostosowuje pozycję przewijania, aby użytkownik nie przerywał oglądania. Jest to możliwe, ponieważ Twitter zachowuje w większości liniowy układ treści. Skopiowałem(-am) ten wzorzec w celu trenowania-to-thrill, aby treści pojawiały się na ekranie jak najszybciej, a jednocześnie od razu wyświetlały się aktualne treści.

Kod na stronie:

var networkDataReceived = false;

startSpinner();

// fetch fresh data
var networkUpdate = fetch('/data.json')
  .then(function (response) {
    return response.json();
  })
  .then(function (data) {
    networkDataReceived = true;
    updatePage(data);
  });

// fetch cached data
caches
  .match('/data.json')
  .then(function (response) {
    if (!response) throw Error('No data');
    return response.json();
  })
  .then(function (data) {
    // don't overwrite newer network data
    if (!networkDataReceived) {
      updatePage(data);
    }
  })
  .catch(function () {
    // we didn't get cached data, the network is our last hope:
    return networkUpdate;
  })
  .catch(showErrorMessage)
  .then(stopSpinner);

Kod w skrypcie service worker:

Zawsze warto przejść do sieci i zaktualizować pamięć podręczną.

self.addEventListener('fetch', function (event) {
  event.respondWith(
    caches.open('mysite-dynamic').then(function (cache) {
      return fetch(event.request).then(function (response) {
        cache.put(event.request, response.clone());
        return response;
      });
    }),
  );
});

W ramach szkolenia training-to-thrill udało mi się obejść ten problem za pomocą XHR zamiast pobierania i nadużywając nagłówka Accept, aby poinformować skrypt Service Worker, skąd ma pobrać wynik (kod strony, kod Service Worker).

Ogólna kreacja zastępcza

Podstawowa wersja.
Ogólna kreacja zastępcza.

Jeśli nie uda Ci się wyświetlić czegoś z pamięci podręcznej lub sieci, możesz podać ogólne dane.

Idealne rozwiązanie: obrazy dodatkowe, takie jak awatary, nieudane żądania POST i strona „Niedostępne w trybie offline”.

self.addEventListener('fetch', function (event) {
  event.respondWith(
    // Try the cache
    caches
      .match(event.request)
      .then(function (response) {
        // Fall back to network
        return response || fetch(event.request);
      })
      .catch(function () {
        // If both fail, show a generic fallback:
        return caches.match('/offline.html');
        // However, in reality you'd have many different
        // fallbacks, depending on URL and headers.
        // Eg, a fallback silhouette image for avatars.
      }),
  );
});

Element, do którego nastąpiło przełączenie, jest prawdopodobnie zależnością instalacji.

Jeśli strona publikuje e-maila, skrypt service worker może zapisać go w „skrzynce nadawczej” IndexedDB i odpowiedzieć, informując stronę, że wysyłanie nie powiodło się, ale dane zostały zachowane.

Szablony po stronie instancji roboczej usługi

Szablony po stronie ServiceWorker.
Szablony po stronie Service Worker

Idealne w przypadku: stron, które nie mogą zapisywać odpowiedzi serwera w pamięci podręcznej.

Renderowanie stron na serwerze przyspiesza działanie, ale może to oznaczać, że w pamięci podręcznej będą się znajdować dane stanu, które nie mają sensu, np. „Zalogowany jako…”. Jeśli stroną steruje skrypt service worker, możesz zamiast tego poprosić o dane JSON wraz z szablonem i zrenderować ten szablon.

importScripts('templating-engine.js');

self.addEventListener('fetch', function (event) {
  var requestURL = new URL(event.request.url);

  event.respondWith(
    Promise.all([
      caches.match('/article-template.html').then(function (response) {
        return response.text();
      }),
      caches.match(requestURL.path + '.json').then(function (response) {
        return response.json();
      }),
    ]).then(function (responses) {
      var template = responses[0];
      var data = responses[1];

      return new Response(renderTemplate(template, data), {
        headers: {
          'Content-Type': 'text/html',
        },
      });
    }),
  );
});

Łączę wszystko w całość

Nie musisz ograniczać się do jednej z tych metod. W zależności od adresu URL żądania prawdopodobnie będziesz używać wielu z nich. Na przykład w przypadku ciągu znaków training-to-thrill:

Zapoznaj się z prośbą i zdecyduj, co zrobić:

self.addEventListener('fetch', function (event) {
  // Parse the URL:
  var requestURL = new URL(event.request.url);

  // Handle requests to a particular host specifically
  if (requestURL.hostname == 'api.example.com') {
    event.respondWith(/* some combination of patterns */);
    return;
  }
  // Routing for local URLs
  if (requestURL.origin == location.origin) {
    // Handle article URLs
    if (/^\/article\//.test(requestURL.pathname)) {
      event.respondWith(/* some other combination of patterns */);
      return;
    }
    if (/\.webp$/.test(requestURL.pathname)) {
      event.respondWith(/* some other combination of patterns */);
      return;
    }
    if (request.method == 'POST') {
      event.respondWith(/* some other combination of patterns */);
      return;
    }
    if (/cheese/.test(requestURL.pathname)) {
      event.respondWith(
        new Response('Flagrant cheese error', {
          status: 512,
        }),
      );
      return;
    }
  }

  // A sensible default pattern
  event.respondWith(
    caches.match(event.request).then(function (response) {
      return response || fetch(event.request);
    }),
  );
});

… rozumiesz, o co chodzi.

Środki

…za piękne ikony:

Dziękuję też Jeffowi Posnickowi za to, że zanim kliknąłem „Opublikuj”, znalazł wiele rażących błędów.

Więcej informacji