HTML5: První krůčky s FileSystem API

FileSystem API řeší jeden ze zásadních problémů webových aplikací, kterým je nemožnost pracovat se soubory v uživatelově počítači. S tímto API, které nabízí zatím jen Chrome 9, může webová aplikace vytvářet, číst, procházet a zapisovat do bezpečně vymezené části uživatelova souborového systému.

Seriál: Webdesignérův průvodce po HTML5 (21 dílů)

  1. Webdesignérův průvodce po HTML5 – díl nultý 25.5.2010
  2. Webdesignérův průvodce po HTML5 – nová sémantika 1.6.2010
  3. Webdesignérův průvodce po HTML5 – nová sémantika II 8.6.2010
  4. Webdesignérův průvodce po HTML5 – pohyblivé obrázky 15.6.2010
  5. Webdesignérův průvodce po HTML5 – používáme pohyblivé obrázky 22.6.2010
  6. Webdesignérův průvodce po HTML5 – taháme data od návštěvníka 29.6.2010
  7. HTML5 Audio: rádio ve vašich stránkách 13.7.2010
  8. Webdesignérův průvodce po HTML5: Microdata 20.7.2010
  9. AppCache: webové aplikace i bez připojení 27.7.2010
  10. Webdesignérův průvodce po HTML5: WebStorage 3.8.2010
  11. Webdesignérův průvodce po HTML5: Multithreading s WebWorkers 10.8.2010
  12. Webdesignérův průvodce po HTML5: Databáze v prohlížečích 17.8.2010
  13. Webdesignérův průvodce po HTML5: Shrnutí a rozhrnutí 24.8.2010
  14. HTML5: ukládáme si data k elementům 6.12.2010
  15. Webdesignérův průvodce po HTML5: Táhni a srůstej 5.1.2011
  16. HTML5: První krůčky s FileSystem API 15.2.2011
  17. Mobilizujeme web v HTML5 4.4.2011
  18. Single Page Apps a řešení problémů s historií 1.6.2011
  19. Page Visibility API: Kouká na mě vůbec někdo? 10.8.2011
  20. Práce se soubory v prohlížeči, díl 1 15.8.2011
  21. Práce se soubory v prohlížeči, díl 2 5.9.2011

Tento text je volným překladem článku Exploring the FileSystem APIs z webu HTML5Rocks. Autorem je Eric Bidelman. Článek je zveřejněn pod licencí CC-BY 3.0, zdrojové kódy pod licencí Apache 2.0.

qrcode

K článku je ukázka

K článku není k dispozici zdrojový kód

První krůčky s FileSystem API

Seznámení

Fakt, že webové aplikace nemohou číst a zapisovat soubory či adresáře, je v určitém ohledu brzdou jejich rozvoje. S přesunem od offline k online se aplikace stávají neustále složitějšími a chybějící souborové API bylo překážkou v posunu vpřed. Naštěstí tomu již tak není, díky FileSystem API. S tímto API může webová aplikace vytvářet, číst, procházet a zapisovat do bezpečně vymezené části uživatelova souborového systému.

API je rozděleno do několika částí:

  • Čtení a manipulace se soubory: File/Blob, FileList, FileReader
  • Vytváření a zapisování: BlobBuilder, FileWriter
  • Adresáře a přístup k souborovému systému: DirectoryReader, FileEntry/DirectoryEntry, LocalFileSystem

Podpora prohlížečů a současná omezení

V době psaní tohoto článku je jediná fungující implementace FileSystem API pouze v Google Chrome 9+. Prozatím neexistuje možnost nastavení file/quota parametru přímo z prohlížeče. Proto je pro využívání API nutné spustit prohlížeč s parametrem --unlimited-quota-for-files (pokud vytváříte aplikaci nebo rozšíření pro Chrome Web Store, postačí manifest soubor pro unlimitedStorage). V současné době není pro takové aplikace úložný prostor nijak omezen, ale to se má změnit. Uživatelům by se mohl nakonec zobrazit dialog pro povolení, odmítnutí, nebo zvětšení úložného prostoru pro danou aplikaci.

Získání přístupu k souborovému systému

Webová aplikace může získat přístup do vymezeného souborového systému voláním window.requestFileSystem():

window.requestFileSystem(type, size, successCallback, opt_errorCallback)
type 
Určuje zda má být úložný prostor perzistentní. Možné hodnoty jsou window.TEMPORARY nebo window.PERSISTENT. Při použití TEMPORARY mohou být data odstraněna dle uvážení prohlížeče (např. když je potřeba více prostoru). Data uložená v úložném prostoru typu PERSISTENT mohou být odstraněna pouze na popud uživatele nebo aplikace.
size
Velikost (v bajtech), kterou požaduje aplikace.
successCallback
Callback, který se vyvolá v případě uspěšného získaní souborového systému.
opt_errorCallback
Volitelný callback pro zprácování chyb, či odmítnutí přístupu. Jeho argumentem je objekt FileError.

Při prvním volání requestFileSystem je pro danou aplikaci vytvořen úložný prostor. Je nutné vzít v potaz, že přístup k tomuto souborovému systému je omezený výlučně pro danou aplikaci. Není možné přistupovat k souborům jiné aplikace. Stejně tak není možné číst a zapisovat soubory do libovolného adresáře na uživatelově pevném disku (např. Obrázky, Dokumenty, atd.).

Příklad použití:

function onInitFs(fs) {
  console.log('Opened file system: ' + fs.name);
}

window.requestFileSystem(window.PERSISTENT, 5*1024*1024 /*5MB*/, onInitFs, errorHandler);

Specifikace FileSystemAPI definuje taktéž synchronní API – rozhraní LocalFileSystemSync, které je určeno pro použití v tzv. Web Workers. V tomto článku se ale synchronním API nebudeme dále zabývat.

Ve zbytku článku budeme používat tento handler pro zpracování chyb z asynchronních volání:

function errorHandler(e) {
  var msg = '';

  switch (e.code) {
    case FileError.QUOTA_EXCEEDED_ERR:
      msg = 'QUOTA_EXCEEDED_ERR';
      break;
    case FileError.NOT_FOUND_ERR:
      msg = 'NOT_FOUND_ERR';
      break;
    case FileError.SECURITY_ERR:
      msg = 'SECURITY_ERR';
      break;
    case FileError.INVALID_MODIFICATION_ERR:
      msg = 'INVALID_MODIFICATION_ERR';
      break;
    case FileError.INVALID_STATE_ERR:
      msg = 'INVALID_STATE_ERR';
      break;
    default:
      msg = 'Unknown Error';
      break;
  };

  console.log('Error: ' + msg);
}

Tento chybový callback je velice jednoduchý, ale nic nebrání jeho pozdějšímu rozšíření o uživatelsky přívětivější hlášení.

Práce se soubory

Soubory ve vyhrazeném prostoru jsou reprezentovány rozhraním FileEntry. FileEntry obsahuje běžné atributy (name, isFile, ...) a metody (remove, moveTo, copyTo, ...) jak jsme zvyklí z běžných souborových systémů.

Atributy a metody rozhraní FileEntry:

fileEntry.isFile === true
fileEntry.isDirectory === false
fileEntry.name
fileEntry.fullPath
...

fileEntry.getMetadata(successCallback, opt_errorCallback);
fileEntry.remove(successCallback, opt_errorCallback);
fileEntry.moveTo(dirEntry, opt_newName, opt_successCallback, opt_errorCallback);
fileEntry.copyTo(dirEntry, opt_newName, opt_successCallback, opt_errorCallback);
fileEntry.getParent(successCallback, opt_errorCallback);
fileEntry.toURI(opt_mimeType);  // Currently not implemented in Google Chrome 9.

fileEntry.file(successCallback, opt_errorCallback);
fileEntry.createWriter(successCallback, opt_errorCallback);
...

Ukažme si několik příkladů běžného použití rozhraní FileEntry.

Vytvoření souboru

Pomocí metody getFile() rozhraní DirectoryEntry je možné vyhledat či vytvořit nový soubor. Po získání přistupu k souborovému systému obdrží successCallback objekt FileEntry obsahující položku DirectoryEntry (fs.root) ukazující na kořen souborového systému.

Následující kód vytvoří prázdný soubor nazvaný „log.txt“ v kořenu aplikačního souborového systému:

function onInitFs(fs) {

  fs.root.getFile('log.txt', {create: true, exclusive: true}, function(fileEntry) {

    // fileEntry.isFile === true
    // fileEntry.name == 'log.txt'
    // fileEntry.fullPath == '/log.txt'

  }, errorHandler);

}

window.requestFileSystem(window.PERSISTENT, 1024*1024, onInitFs, errorHandler);

V momentě úspěšného požadavku na souborový systém je do success callbacku předán objekt FileSystem. V těle callbacku je možné volat fs.root.getFile() se jménem souboru k vytvoření. Cesta může být jak absolutní tak relativní, ale musí být validní. Například je chybou pokoušet se vytvořit soubor, jehož nadřazený adresář neexistuje. Druhým argumentem volání getFile() je literál určující chování v případě, že soubor neexistuje. V tomto příkladu (create: true) bude vytvořen nový soubor, pokud zatím neexistuje. V případě, že již existuje, vyvolá chybu (exclusive: true). Pokud by soubor existoval a bylo nastaveno create : false, tak jej jednoduše vrátí. V obou případech nedojde k přepsáno obsahu souboru, jelikož tímto voláním se získá pouze rozhraní pro práci s daným souborem.

Čtení souboru

Následující kód získá soubor se jménem „log.txt“, přečte jeho obsah pomocí objektu FileReader a přidá jej na stránku do nové textové oblasti <textarea>. V případě že soubor log.txt neexistuje, je vyvolána chyba.

function onInitFs(fs) {

  fs.root.getFile('log.txt', {}, function(fileEntry) {

    // Get a File object representing the file,
    // then use FileReader to read its contents.
    fileEntry.file(function(file) {
       var reader = new FileReader();

       reader.onloadend = function(e) {
         var txtArea = document.createElement('textarea');
         txtArea.value = this.result;
         document.body.appendChild(txtArea);
       };

       reader.readAsText(file);
    }, errorHandler);

  }, errorHandler);

}

window.requestFileSystem(window.PERSISTENT, 1024*1024, onInitFs, errorHandler);

Zápis do souboru

Následující kód vytvoří prázdný soubor „log.txt“ (pokud již neexistuje) a vyplní jej textem ‚Lorem Ipsum‘.

function onInitFs(fs) {

  fs.root.getFile('log.txt', {create: true}, function(fileEntry) {

    // Create a FileWriter object for our FileEntry (log.txt).
    fileEntry.createWriter(function(fileWriter) {

      fileWriter.onwriteend = function(e) {
        console.log('Write completed.');
      };

      fileWriter.onerror = function(e) {
        console.log('Write failed: ' + e.toString());
      };

      // Create a new Blob and write it to log.txt.
      var bb = new BlobBuilder();
      bb.append('Lorem Ipsum');
      fileWriter.write(bb.getBlob('text/plain'));

    }, errorHandler);

  }, errorHandler);

}

window.requestFileSystem(window.PERSISTENT, 1024*1024, onInitFs, errorHandler);

V tomto případě voláme na FileEntry metodu createWriter(), která vrátí objekt FileWriter. V těle success callbacku je vytvořena obsluha událostí error a writeend. Data jsou zapsána do souboru vytvořením blobu, přidáním textu a předáním blobu do volání FileWriter.write().

Přidání dat na konec souboru

Následující kód přidá text ‚Hello World‘ na konec logovacího souboru. Pokud soubor neexistuje, je vyvolána chyba.

function onInitFs(fs) {

  fs.root.getFile('log.txt', {create: false}, function(fileEntry) {

    // Create a FileWriter object for our FileEntry (log.txt).
    fileEntry.createWriter(function(fileWriter) {

      fileWriter.seek(fileWriter.length); // Start write position at EOF.

      // Create a new Blob and write it to log.txt.
      var bb = new BlobBuilder();
      bb.append('Hello World');
      fileWriter.write(bb.getBlob('text/plain'));

    }, errorHandler);

  }, errorHandler);

}

window.requestFileSystem(window.PERSISTENT, 1024*1024, onInitFs, errorHandler);

Vytvoření kopií vybraných souborů

Následující kód umožní vybrat uživateli více souborů najednou použitím <input type="file" multiple /> a poté vytvořit jejich kopie v souborovém systému aplikace.

<input type="file" id="myfile" multiple />
document.querySelector('#myfile').onchange = function(e) {
  var files = this.files;

  window.requestFileSystem(window.TEMPORARY, 1024*1024, function(fs) {
    // Duplicate each file the user selected to the app's fs.
    for (var i = 0, file; file = files[i]; ++i) {

      // Capture current iteration's file in local scope for the getFile() callback.
      (function(f) {
        fs.root.getFile(file.name, {create: true, exclusive: true}, function(fileEntry) {
          fileEntry.createWriter(function(fileWriter) {
            fileWriter.write(f); // Note: write() can take a File or Blob object.
          }, errorHandler);
        }, errorHandler);
      })(file);

    }
  }, errorHandler);

};

Pro dosažení stejného výsledku lze místo input tagu použít i HTML5 Drag and Drop.

Jak je poznamenáno v komentáři, FileWriter.write() může zapisovat jak Blob, tak File. Je to možné díky tomu, že File je potomkem Blobu.

Smazání souboru

Následující kód smaže soubor „log.txt“.

window.requestFileSystem(window.PERSISTENT, 1024*1024, function(fs) {
  fs.root.getFile('log.txt', {create: false}, function(fileEntry) {

    fileEntry.remove(function() {
      console.log('File removed.');
    }, errorHandler);

  }, errorHandler);
}, errorHandler);

Práce s adresáři

Adresáře jsou reprezentovány rozhraním DirectoryEntry, které sdílí mnoho vlastností s FileEntry (obě rozhraní dědí z rozhraní Entry). Directory entry obsahuje navíc metody pro práci s adresáři.

Atributy a metody DirectoryEntry:

dirEntry.isDirectory === true
// See the section on FileEntry for other inherited properties/methods.
...

var dirReader = dirEntry.createReader(successCallback, opt_errorCallback);
dirEntry.getFile(path, opt_flags, opt_successCallback, opt_errorCallback);
dirEntry.getDirectory(path, opt_flags, opt_successCallback, opt_errorCallback);
dirEntry.removeRecursively(successCallback, opt_errorCallback);
...

Vytváření adresářů

Pro čtení nebo vytvoření adresáře slouží metoda getDirectory(). Je možno zadat název nebo cestu.

Např. následující kód vytvoří adresář se jménem „MyPictures“ v kořenovém adresáři:

window.requestFileSystem(window.PERSISTENT, 1024*1024, function(fs) {
  fs.root.getDirectory('MyPictures', {create: true}, function(dirEntry) {
    ...
  }, errorHandler);
}, errorHandler);

Podadresáře

Vytváření podadresářů se ničím neliší od vytváření adresářů. Nicméně API při pokusu o vytvoření adresáře vyvolá chybu, pokud neexistuje jeho nadřazený adresář. Řešením je postupné vytváření jednotlivých adresářů, což je s asynchronním API poněkud složitější.

Následující kód vytvoří adresářovou hierarchii (music/genres/jazz) v kořenu souborového systému aplikace rekurzivním vytvářením každého z podadresářů po tom co je vytvořen adresář nadřazený.

var path = 'music/genres/jazz/';

function createDir(rootDirEntry, folders) {
  // Throw out './' or '/' and move on to prevent something like '/foo/.//bar'.
  if (folders[0] == '.' || folders[0] == '') {
    folders = folders.slice(1);
  }
  rootDirEntry.getDirectory(folders[0], {create: true}, function(dirEntry) {
    // Recursively add the new subfolder (if we still have another to create).
    if (folders.length) {
      createDir(dirEntry, folders.slice(1));
    }
  }, errorHandler);
};

function onInitFs(fs) {
  createDir(fs.root, path.split('/')); // fs.root is a DirectoryEntry.
}

window.requestFileSystem(window.PERSISTENT, 1024*1024, onInitFs, errorHandler);

Poté co jsou adresáře „/music/genres/jazz“ na místě, je možné předat celou cestu do getDirectory() a vytvářet v daném umístění další podadresáře nebo soubory. Např.:

window.requestFileSystem(window.PERSISTENT, 1024*1024, function(fs) {
  fs.root.getFile('/music/genres/jazz/song.mp3', {create: true}, function(fileEntry) {
    ...
  }, errorHandler);
}, errorHandler);

Čtení obsahu adresářů

Pro čtení obsahu adresáře je potřeba vytvořit DirectoryReader a zavolat jeho metodu readEntries(). Není zaručeno že volání této metody vrátí všechny položky adresáře. Tím pádem je nutno volat DirectoryReader.readEntries() opakovaně do doby, kdy už nevrací žádné další výsledky. Tento přístup je použit v následujícím příkladě:

<ul id="filelist"></ul>
function toArray(list) {
  return Array.prototype.slice.call(list || [], 0);
}

function listResults(entries) {
  // Document fragments can improve performance since they're only appended
  // to the DOM once. Only one browser reflow occurs.
  var fragment = document.createDocumentFragment();

  entries.forEach(function(entry, i) {
    var img = entry.isDirectory ? '<img src="folder-icon.gif">' :
                                  '<img src="file-icon.gif">';
    var li = document.createElement('li');
    li.innerHTML = [img, '<span>', entry.name, '</span>'].join('');
    fragment.appendChild(li);
  });

  document.querySelector('#filelist').appendChild(fragment);
}

function onInitFs(fs) {

  var dirReader = fs.root.createReader();
  var entries = [];

  // Call the reader.readEntries() until no more results are returned.
  var readEntries = function() {
     dirReader.readEntries (function(results) {
      if (!results.length) {
        listResults(entries.sort());
      } else {
        entries = entries.concat(toArray(results));
        readEntries();
      }
    }, errorHandler);
  };

  readEntries(); // Start reading dirs.

}

window.requestFileSystem(window.PERSISTENT, 1024*1024, onInitFs, errorHandler);

Odstranění adresáře

Metoda remove() rozhraní DirectoryEntry se chová stejně jako na FileEntry. Jediným rozdílem je, že pokus o smazání neprázdného adresáře skončí chybou.

Následující kód odstraní prázdný adresář „jazz“ z „/music/genres/“:

window.requestFileSystem(window.PERSISTENT, 1024*1024, function(fs) {
  fs.root.getDirectory('music/genres/jazz', {}, function(dirEntry) {

    dirEntry.remove(function() {
      console.log('Directory removed.');
    }, errorHandler);

  }, errorHandler);
}, errorHandler);

Odstranění neprázdného adresáře

Pro smazání neprázdného adresáře slouží metoda removeRecursively(). Tato metoda odstraní adresář a jeho obsah rekurzivně.

Následující kód rekurzivně odstraní adresář „music“ a všechny v něm obsažené soubory a adresáře:

window.requestFileSystem(window.PERSISTENT, 1024*1024, function(fs) {
  fs.root.getDirectory('/misc/../music', {}, function(dirEntry) {

    dirEntry.removeRecursively(function() {
      console.log('Directory removed.');
    }, errorHandler);

  }, errorHandler);
}, errorHandler);

Kopírování, přejmenování a přesunutí

FileEntry a DirectoryEntry sdílí společné operace.

Kopírování položky

Obě rozhraní mají metodu copyTo() pro vytvoření kopie. Pro adresář tato metoda provede automaticky rekurzivní kopii.

Následující kód zkopíruje soubor „me.png“ z jednoho adresáře do druhého:

function copy(cwd, src, dest) {
  cwd.getFile(src, {}, function(fileEntry) {

    cwd.getDirectory(dest, {}, function(dirEntry) {
      dirEntry.copyTo(dirEntry);
    }, errorHandler);

  }, errorHandler);
}

window.requestFileSystem(window.PERSISTENT, 1024*1024, function(fs) {
  copy(fs.root, '/folder1/me.png', 'folder2/mypics/');
}, errorHandler);

Přesunutí nebo přejmenování položky

Metoda moveTo() umožňuje přesunout nebo přejmenovat soubor či adresář. Prvním argumentem je adresář do kterého se má přesunout a druhým nepovinným argumentem je nové jméno. Pokud není zadáno nové jméno, použije se jméno původní.

V následujícím příkladě dojde k přejmenování souboru „me.png“ na „you.png“ v původním umístění.

function rename(cwd, src, newName) {
  cwd.getFile(src, {}, function(fileEntry) {
    fileEntry.moveTo(cwd, newName);
  }, errorHandler);
}

window.requestFileSystem(window.PERSISTENT, 1024*1024, function(fs) {
  rename(fs.root, 'me.png', 'you.png');
}, errorHandler);

Následující příklad přesune soubor „me.png“ (umístěný v kořenovém adresáři) do adresáře „newfolder“.

function move(src, dirName) {
  fs.root.getFile(src, {}, function(fileEntry) {

    fs.root.getDirectory(dirName, {}, function(dirEntry) {
      fileEntry.moveTo(dirEntry);
    }, errorHandler);

  }, errorHandler);
}

window.requestFileSystem(window.PERSISTENT, 1024*1024, function(fs) {
  move('/me.png', 'newfolder/');
}, errorHandler);

Když to dáme vše dohromady…

Ukázkové příklady naleznete v originálním článku; zde je, kvůli možnostem redakčního systému, nemůžeme zveřejnit.

Případy použití

V HTML5 existuje mnoho možností uložení dat, ale FileSystem API se odlišuje tím, že je zacíleno na splnění těch požadavků použití na straně klienta, které nejsou vhodné pro databáze. Obecně je vhodné pro aplikace, které se musí vyrovnat s rozsáhlými binárními daty anebo sdílením dat s aplikacemi mimo prostředí prohlížeče.

Specifikace uvádí několik možností použití:

1. Perzistentní uploader

  • Vybraný soubor nebo adresář je zkopírován do lokálního uložiště a uploaduje se postupně po částech.
  • Upload může být restartován v případě pádu prohlížeče, přerušení internetového připojení, apod.

2. Hry, hudební přehrávače nebo jiné aplikace se spoustou multimediálního obsahu

  • Po stáhnutí jednoho či více velkých archivů je rozbalí lokálně do adresářové struktury.
  • Není potřeba více variant archivů pro jednotlivé OS.
  • Soubory, které budou brzy potřeba, mohou být stahovány na pozadí. Tím pádem přechod do další úrovně nebo zpřístupnění nových vlastností nevyžaduje čekání na stažení.
  • Soubory jsou používány přímo z lokální cache, přimým čtením nebo odkazováním pomocí lokálních URI na obrázky či videa, loadery WebGL obsahu, apod.
  • Soubory mohou mít libovolný binární formát.
  • Na straně serveru bude mít komprimovaný archiv často menší velikost než skupina samostatně komprimovaných souborů. Taktéž jeden archiv místo 1000 malých souborů vyžaduje méně přístupů na souborový systém, ačkoliv celková velikost může být totožná.

3. Foto editor či zvukový editor s offline přístupem nebo lokální cache pro zrychlení

  • Data jsou potenciálně velké velikosti a jsou určena jak ke čtení, tak pro zápis.
  • Může být potřeba přepsat pouze část souboru (např. přepis ID3/EXIF záznamu).
  • Může být užitečné organizovat projektové soubory do hiearchie adresářů.
  • Editované soubory mohou být přístupné z aplikací na straně klienta [iTunes, Picasa].

4. Offline přehrávač videí

  • Stahování velkých souborů (>1GB) pro pozdější přehrávání.
  • Potřeba efektivního přesunování v souboru a streamování.
  • Musí být schopen dodat URI na video tag.
  • Musí umožnit přístup k částečně staženým souborům, např. pro shlédnutí první části DVD i když ještě není staženo celé DVD.

5. Offline Webový e-mailový klient

  • Stahování příloh a jejích lokální uložení.
  • Cachování příloh pro pozdější upload.
  • Musí být schopen odkazovat na cachovanou přílohu a náhledy obrázků.
  • Měl by být schopen spustit správce stahování stejně jako by komunikoval přímo se serverem.
  • Měl by umět uploadovat e-mail s přílohami jako multipart post, místo odesílání po jednotlivých souborech.

Referenční specifikace

Komentáře: 54

Přehled komentářů

ixi.80h bezpecnost
Martin Re: bezpecnost
Joe Re: bezpecnost
oxymoron Re: bezpecnost
Jerry Moc to HTML5 nechápu
lol Re: Moc to HTML5 nechápu
tik Re: Moc to HTML5 nechápu
lol Re: Moc to HTML5 nechápu
Martin Malý Re: Moc to HTML5 nechápu
tik Re: Moc to HTML5 nechápu
koroptev Re: Moc to HTML5 nechápu
tom-tom Re: Moc to HTML5 nechápu
Martin Malý Re: Moc to HTML5 nechápu
pas Re: Moc to HTML5 nechápu
tom-tom Re: Moc to HTML5 nechápu
František Kučera Re: Moc to HTML5 nechápu
Martin Malý Re: Moc to HTML5 nechápu
tom-tom Re: Moc to HTML5 nechápu
Martin Malý Re: Moc to HTML5 nechápu
František Kučera Re: Moc to HTML5 nechápu
ondra.novacisko.cz Re: Moc to HTML5 nechápu
František Kučera Re: Moc to HTML5 nechápu
josefrichter Re: Moc to HTML5 nechápu
Pavel Šimerda (pavlix) Re: Moc to HTML5 nechápu
VM děkuji nechci
imploder Re: děkuji nechci
František Kučera Re: děkuji nechci
imploder Re: děkuji nechci
VM Re: děkuji nechci
imploder Re: děkuji nechci
Martin Malý Re: děkuji nechci
imploder Re: děkuji nechci
Oxymoron Re: děkuji nechci
imploder Re: děkuji nechci
pas Re: děkuji nechci
Oxymoron Re: děkuji nechci
pas Re: děkuji nechci
Martin Malý Re: děkuji nechci
pas Re: děkuji nechci
Oxymoron Re: děkuji nechci
imploder Re: děkuji nechci
Oxymoron Re: děkuji nechci
imploder Re: děkuji nechci
Oxymoron Re: děkuji nechci
Oxymoron Re: děkuji nechci
koroptev kruh se uzavrel
imploder Soubory vybrané uživatelem
Oxymoron Re: Soubory vybrané uživatelem
Nox Re: Soubory vybrané uživatelem
Oxymoron Re: Soubory vybrané uživatelem
Nox Re: Soubory vybrané uživatelem
Oxymoron Re: Soubory vybrané uživatelem
petr_p URI pro přístup z HTML
Oxymoron Re: URI pro přístup z HTML
Zdroj: https://www.zdrojak.cz/?p=3429