From 9ec0a61156192484ca90a8dc88b7c23b26129755 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Sat, 2 Sep 2017 15:10:44 +0200 Subject: [PATCH 01/77] Performances: reorder links when they're written instead of read relates to #891 --- application/LinkDB.php | 17 ++++++++--------- application/Updater.php | 8 ++++++++ tests/LinkFilterTest.php | 13 ++++++++++++- tests/utils/ReferenceLinkDB.php | 23 +++++++++++++++++++++++ 4 files changed, 51 insertions(+), 10 deletions(-) diff --git a/application/LinkDB.php b/application/LinkDB.php index 22c1f0a..eace625 100644 --- a/application/LinkDB.php +++ b/application/LinkDB.php @@ -289,13 +289,15 @@ You use the community supported version of the original Shaarli project, by Seba return; } + $this->urls = []; + $this->ids = []; $this->links = FileUtils::readFlatDB($this->datastore, []); $toremove = array(); foreach ($this->links as $key => &$link) { if (! $this->loggedIn && $link['private'] != 0) { // Transition for not upgraded databases. - $toremove[] = $key; + unset($this->links[$key]); continue; } @@ -329,14 +331,10 @@ You use the community supported version of the original Shaarli project, by Seba } $link['shorturl'] = smallHash($link['linkdate']); } - } - // If user is not logged in, filter private links. - foreach ($toremove as $offset) { - unset($this->links[$offset]); + $this->urls[$link['url']] = $key; + $this->ids[$link['id']] = $key; } - - $this->reorder(); } /** @@ -346,6 +344,7 @@ You use the community supported version of the original Shaarli project, by Seba */ private function write() { + $this->reorder(); FileUtils::writeFlatDB($this->datastore, $this->links); } @@ -528,8 +527,8 @@ You use the community supported version of the original Shaarli project, by Seba return $a['created'] < $b['created'] ? 1 * $order : -1 * $order; }); - $this->urls = array(); - $this->ids = array(); + $this->urls = []; + $this->ids = []; foreach ($this->links as $key => $link) { $this->urls[$link['url']] = $key; $this->ids[$link['id']] = $key; diff --git a/application/Updater.php b/application/Updater.php index 40a1590..0702158 100644 --- a/application/Updater.php +++ b/application/Updater.php @@ -436,6 +436,14 @@ class Updater } return true; } + + /** + * Save the datastore -> the link order is now applied when links are saved. + */ + public function updateMethodReorderDatastore() + { + $this->linkDB->save($this->conf->get('resource.page_cache')); + } } /** diff --git a/tests/LinkFilterTest.php b/tests/LinkFilterTest.php index d796d3a..9cd6dbd 100644 --- a/tests/LinkFilterTest.php +++ b/tests/LinkFilterTest.php @@ -7,6 +7,10 @@ require_once 'application/LinkFilter.php'; */ class LinkFilterTest extends PHPUnit_Framework_TestCase { + /** + * @var string Test datastore path. + */ + protected static $testDatastore = 'sandbox/datastore.php'; /** * @var LinkFilter instance. */ @@ -17,13 +21,20 @@ class LinkFilterTest extends PHPUnit_Framework_TestCase */ protected static $refDB; + /** + * @var LinkDB instance + */ + protected static $linkDB; + /** * Instanciate linkFilter with ReferenceLinkDB data. */ public static function setUpBeforeClass() { self::$refDB = new ReferenceLinkDB(); - self::$linkFilter = new LinkFilter(self::$refDB->getLinks()); + self::$refDB->write(self::$testDatastore); + self::$linkDB = new LinkDB(self::$testDatastore, true, false); + self::$linkFilter = new LinkFilter(self::$linkDB); } /** diff --git a/tests/utils/ReferenceLinkDB.php b/tests/utils/ReferenceLinkDB.php index f09eebc..e887aa7 100644 --- a/tests/utils/ReferenceLinkDB.php +++ b/tests/utils/ReferenceLinkDB.php @@ -141,12 +141,34 @@ class ReferenceLinkDB */ public function write($filename) { + $this->reorder(); file_put_contents( $filename, '_links))).' */ ?>' ); } + /** + * Reorder links by creation date (newest first). + * + * Also update the urls and ids mapping arrays. + * + * @param string $order ASC|DESC + */ + public function reorder($order = 'DESC') + { + // backward compatibility: ignore reorder if the the `created` field doesn't exist + if (! isset(array_values($this->_links)[0]['created'])) { + return; + } + + $order = $order === 'ASC' ? -1 : 1; + // Reorder array by dates. + usort($this->_links, function($a, $b) use ($order) { + return $a['created'] < $b['created'] ? 1 * $order : -1 * $order; + }); + } + /** * Returns the number of links in the reference data */ @@ -187,6 +209,7 @@ class ReferenceLinkDB public function getLinks() { + $this->reorder(); return $this->_links; } From 1a216faecb5c114afbf36ecbac8ec3f795309eba Mon Sep 17 00:00:00 2001 From: VirtualTam Date: Sun, 9 Apr 2017 14:50:13 +0200 Subject: [PATCH 02/77] docker: switch to Alpine Linux for the master image Relates to https://github.com/shaarli/Shaarli/issues/843 Changed: - switch base image from Debian:Jessie to Alpine:3.6 - switch to PHP 7.1 - switch from supervisord to s6 to manage services See: - https://alpinelinux.org/ - https://wiki.alpinelinux.org/wiki/Nginx_with_PHP - http://www.skarnet.org/software/s6/ - http://www.skarnet.org/software/s6/s6-svscan.html - http://www.skarnet.org/software/s6/s6-svc.html - http://www.skarnet.org/software/s6/s6-svstat.html Signed-off-by: VirtualTam --- Makefile | 2 +- docker/alpine/Dockerfile.master | 47 +++++++++++++++++++ docker/{production => alpine}/IMAGE.md | 0 .../{production/stable => alpine}/nginx.conf | 5 +- docker/alpine/php-fpm.conf | 16 +++++++ docker/alpine/services.d/.s6-svscan/finish | 2 + docker/alpine/services.d/nginx/run | 2 + docker/alpine/services.d/php-fpm/run | 2 + docker/production/Dockerfile | 37 --------------- docker/production/supervised.conf | 13 ----- docker/{production => }/stable/Dockerfile | 0 docker/{production => }/stable/IMAGE.md | 0 docker/{production => stable}/nginx.conf | 0 .../{production => }/stable/supervised.conf | 0 14 files changed, 73 insertions(+), 53 deletions(-) create mode 100644 docker/alpine/Dockerfile.master rename docker/{production => alpine}/IMAGE.md (100%) rename docker/{production/stable => alpine}/nginx.conf (94%) create mode 100644 docker/alpine/php-fpm.conf create mode 100755 docker/alpine/services.d/.s6-svscan/finish create mode 100755 docker/alpine/services.d/nginx/run create mode 100755 docker/alpine/services.d/php-fpm/run delete mode 100644 docker/production/Dockerfile delete mode 100644 docker/production/supervised.conf rename docker/{production => }/stable/Dockerfile (100%) rename docker/{production => }/stable/IMAGE.md (100%) rename docker/{production => stable}/nginx.conf (100%) rename docker/{production => }/stable/supervised.conf (100%) diff --git a/Makefile b/Makefile index a3696ec..656c27b 100644 --- a/Makefile +++ b/Makefile @@ -115,7 +115,7 @@ check_permissions: @echo "----------------------" @echo "Check file permissions" @echo "----------------------" - @for file in `git ls-files`; do \ + @for file in `git ls-files | grep -v docker`; do \ if [ -x $$file ]; then \ errors=true; \ echo "$${file} is executable"; \ diff --git a/docker/alpine/Dockerfile.master b/docker/alpine/Dockerfile.master new file mode 100644 index 0000000..58f7c6e --- /dev/null +++ b/docker/alpine/Dockerfile.master @@ -0,0 +1,47 @@ +FROM alpine:3.6 +MAINTAINER Shaarli Community + +RUN apk --update --no-cache add \ + ca-certificates \ + curl \ + nginx \ + php7 \ + php7-ctype \ + php7-curl \ + php7-fpm \ + php7-gd \ + php7-iconv \ + php7-intl \ + php7-json \ + php7-mbstring \ + php7-openssl \ + php7-phar \ + php7-session \ + php7-xml \ + php7-zlib \ + s6 + +COPY nginx.conf /etc/nginx/nginx.conf +COPY php-fpm.conf /etc/php7/php-fpm.conf +COPY services.d /etc/services.d + +RUN curl -sS https://getcomposer.org/installer | php7 -- --install-dir=/usr/local/bin --filename=composer \ + && rm -rf /etc/php7/php-fpm.d/www.conf \ + && sed -i 's/post_max_size.*/post_max_size = 10M/' /etc/php7/php.ini \ + && sed -i 's/upload_max_filesize.*/upload_max_filesize = 10M/' /etc/php7/php.ini + + +WORKDIR /var/www +RUN curl -L https://github.com/shaarli/Shaarli/archive/master.tar.gz | tar xzf - \ + && mv Shaarli-master shaarli \ + && cd shaarli \ + && composer --prefer-dist --no-dev install \ + && rm -rf ~/.composer \ + && chown -R nginx:nginx . + +VOLUME /var/www/shaarli/data + +EXPOSE 80 + +ENTRYPOINT ["/bin/s6-svscan", "/etc/services.d"] +CMD [] diff --git a/docker/production/IMAGE.md b/docker/alpine/IMAGE.md similarity index 100% rename from docker/production/IMAGE.md rename to docker/alpine/IMAGE.md diff --git a/docker/production/stable/nginx.conf b/docker/alpine/nginx.conf similarity index 94% rename from docker/production/stable/nginx.conf rename to docker/alpine/nginx.conf index e8754d9..07fba33 100644 --- a/docker/production/stable/nginx.conf +++ b/docker/alpine/nginx.conf @@ -1,6 +1,7 @@ -user www-data www-data; +user nginx nginx; daemon off; worker_processes 4; +pid /var/run/nginx.pid; events { worker_connections 768; @@ -59,7 +60,7 @@ http { fastcgi_split_path_info ^(.+\.php)(/.+)$; # filter and proxy PHP requests to PHP-FPM - fastcgi_pass unix:/var/run/php5-fpm.sock; + fastcgi_pass unix:/var/run/php-fpm.sock; fastcgi_index index.php; include fastcgi.conf; } diff --git a/docker/alpine/php-fpm.conf b/docker/alpine/php-fpm.conf new file mode 100644 index 0000000..0843c16 --- /dev/null +++ b/docker/alpine/php-fpm.conf @@ -0,0 +1,16 @@ +[global] +daemonize = no + +[www] +user = nginx +group = nginx +listen.owner = nginx +listen.group = nginx +catch_workers_output = yes +listen = /var/run/php-fpm.sock +pm = dynamic +pm.max_children = 20 +pm.start_servers = 1 +pm.min_spare_servers = 1 +pm.max_spare_servers = 3 +pm.max_requests = 2048 diff --git a/docker/alpine/services.d/.s6-svscan/finish b/docker/alpine/services.d/.s6-svscan/finish new file mode 100755 index 0000000..1dadeea --- /dev/null +++ b/docker/alpine/services.d/.s6-svscan/finish @@ -0,0 +1,2 @@ +#!/bin/sh +/bin/true diff --git a/docker/alpine/services.d/nginx/run b/docker/alpine/services.d/nginx/run new file mode 100755 index 0000000..21e7b0d --- /dev/null +++ b/docker/alpine/services.d/nginx/run @@ -0,0 +1,2 @@ +#!/bin/execlineb -P +nginx diff --git a/docker/alpine/services.d/php-fpm/run b/docker/alpine/services.d/php-fpm/run new file mode 100755 index 0000000..21dd010 --- /dev/null +++ b/docker/alpine/services.d/php-fpm/run @@ -0,0 +1,2 @@ +#!/bin/execlineb -P +php-fpm7 -F diff --git a/docker/production/Dockerfile b/docker/production/Dockerfile deleted file mode 100644 index d050911..0000000 --- a/docker/production/Dockerfile +++ /dev/null @@ -1,37 +0,0 @@ -FROM debian:jessie -MAINTAINER Shaarli Community - -ENV TERM dumb -RUN apt-get update \ - && apt-get install --no-install-recommends -y \ - ca-certificates \ - curl \ - nginx-light \ - php5-curl \ - php5-fpm \ - php5-gd \ - php5-intl \ - supervisor \ - && apt-get clean - -RUN sed -i 's/post_max_size.*/post_max_size = 10M/' /etc/php5/fpm/php.ini -RUN sed -i 's/upload_max_filesize.*/upload_max_filesize = 10M/' /etc/php5/fpm/php.ini -COPY nginx.conf /etc/nginx/nginx.conf -COPY supervised.conf /etc/supervisor/conf.d/supervised.conf - -ADD https://getcomposer.org/composer.phar /usr/local/bin/composer -RUN chmod 755 /usr/local/bin/composer - -WORKDIR /var/www -RUN curl -L https://github.com/shaarli/Shaarli/archive/master.tar.gz | tar xzf - \ - && mv Shaarli-master shaarli \ - && cd shaarli \ - && composer --prefer-dist --no-dev install -RUN rm -rf html \ - && chown -R www-data:www-data . - -VOLUME /var/www/shaarli/data - -EXPOSE 80 - -CMD ["/usr/bin/supervisord", "-n", "-c", "/etc/supervisor/supervisord.conf"] diff --git a/docker/production/supervised.conf b/docker/production/supervised.conf deleted file mode 100644 index 5acd979..0000000 --- a/docker/production/supervised.conf +++ /dev/null @@ -1,13 +0,0 @@ -[program:php5-fpm] -command=/usr/sbin/php5-fpm -F -priority=5 -autostart=true -autorestart=true - -[program:nginx] -command=/usr/sbin/nginx -priority=10 -autostart=true -autorestart=true -stdout_events_enabled=true -stderr_events_enabled=true diff --git a/docker/production/stable/Dockerfile b/docker/stable/Dockerfile similarity index 100% rename from docker/production/stable/Dockerfile rename to docker/stable/Dockerfile diff --git a/docker/production/stable/IMAGE.md b/docker/stable/IMAGE.md similarity index 100% rename from docker/production/stable/IMAGE.md rename to docker/stable/IMAGE.md diff --git a/docker/production/nginx.conf b/docker/stable/nginx.conf similarity index 100% rename from docker/production/nginx.conf rename to docker/stable/nginx.conf diff --git a/docker/production/stable/supervised.conf b/docker/stable/supervised.conf similarity index 100% rename from docker/production/stable/supervised.conf rename to docker/stable/supervised.conf From e3a3cc0da85925d08df29a2146b54b4159d5a14b Mon Sep 17 00:00:00 2001 From: VirtualTam Date: Tue, 3 Oct 2017 20:07:46 +0200 Subject: [PATCH 03/77] docker: rename resources for the stable image Signed-off-by: VirtualTam --- docker/{stable/Dockerfile => debian/Dockerfile.stable} | 0 docker/{stable => debian}/IMAGE.md | 0 docker/{stable => debian}/nginx.conf | 0 docker/{stable => debian}/supervised.conf | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename docker/{stable/Dockerfile => debian/Dockerfile.stable} (100%) rename docker/{stable => debian}/IMAGE.md (100%) rename docker/{stable => debian}/nginx.conf (100%) rename docker/{stable => debian}/supervised.conf (100%) diff --git a/docker/stable/Dockerfile b/docker/debian/Dockerfile.stable similarity index 100% rename from docker/stable/Dockerfile rename to docker/debian/Dockerfile.stable diff --git a/docker/stable/IMAGE.md b/docker/debian/IMAGE.md similarity index 100% rename from docker/stable/IMAGE.md rename to docker/debian/IMAGE.md diff --git a/docker/stable/nginx.conf b/docker/debian/nginx.conf similarity index 100% rename from docker/stable/nginx.conf rename to docker/debian/nginx.conf diff --git a/docker/stable/supervised.conf b/docker/debian/supervised.conf similarity index 100% rename from docker/stable/supervised.conf rename to docker/debian/supervised.conf From 78865393a687a4c057109fa51f22934ad078d482 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Sat, 7 Oct 2017 12:27:50 +0200 Subject: [PATCH 04/77] Badge version --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 100ff46..c105002 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ _It is designed to be personal (single-user), fast and handy._ [![](https://img.shields.io/badge/stable-v0.8.4-blue.svg)](https://github.com/shaarli/Shaarli/releases/tag/v0.8.4) [![](https://img.shields.io/travis/shaarli/Shaarli/stable.svg?label=stable)](https://travis-ci.org/shaarli/Shaarli) • -[![](https://img.shields.io/badge/latest-v0.9.1-blue.svg)](https://github.com/shaarli/Shaarli/releases/tag/v0.9.1) +[![](https://img.shields.io/badge/latest-v0.9.2-blue.svg)](https://github.com/shaarli/Shaarli/releases/tag/v0.9.2) [![](https://img.shields.io/travis/shaarli/Shaarli/latest.svg?label=latest)](https://travis-ci.org/shaarli/Shaarli) • [![](https://img.shields.io/badge/master-v0.9.x-blue.svg)](https://github.com/shaarli/Shaarli) From ba6245670d071abcc6e90c0f277222ec5e55b413 Mon Sep 17 00:00:00 2001 From: Daniel Jakots Date: Sat, 7 Oct 2017 09:35:40 -0400 Subject: [PATCH 05/77] Fix link in Upgrade-and-migration.md --- doc/md/Upgrade-and-migration.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/doc/md/Upgrade-and-migration.md b/doc/md/Upgrade-and-migration.md index b3a0876..7033cd4 100644 --- a/doc/md/Upgrade-and-migration.md +++ b/doc/md/Upgrade-and-migration.md @@ -14,7 +14,7 @@ Shaarli stores all user data under the `data` directory: - `data/ipbans.php` - banned IP addresses - `data/updates.txt` - contains all automatic update to the configuration and datastore files already run -See [Shaarli configuration](Shaarli configuration) for more information about Shaarli resources. +See [Shaarli configuration](Shaarli-configuration) for more information about Shaarli resources. It is recommended to backup this repository _before_ starting updating/upgrading Shaarli: @@ -27,7 +27,7 @@ As all user data is kept under `data`, this is the only directory you need to wo - backup the `data` directory - install or update Shaarli: - - fresh installation - see [Download and installation](Download and installation) + - fresh installation - see [Download and installation](Download-and-installation) - update - see the following sections - check or restore the `data` directory @@ -35,11 +35,11 @@ As all user data is kept under `data`, this is the only directory you need to wo All tagged revisions can be downloaded as tarballs or ZIP archives from the [releases](https://github.com/shaarli/Shaarli/releases) page. -We recommend that you use the latest release tarball with the `-full` suffix. It contains the dependencies, please read [Download and installation](Download and installation) for `git` complete instructions. +We recommend that you use the latest release tarball with the `-full` suffix. It contains the dependencies, please read [Download and installation](Download-and-installation) for `git` complete instructions. Once downloaded, extract the archive locally and update your remote installation (e.g. via FTP) -be sure you keep the content of the `data` directory! -After upgrading, access your fresh Shaarli installation from a web browser; the configuration and data store will then be automatically updated, and new settings added to `data/config.json.php` (see [Shaarli configuration](Shaarli configuration) for more details). +After upgrading, access your fresh Shaarli installation from a web browser; the configuration and data store will then be automatically updated, and new settings added to `data/config.json.php` (see [Shaarli configuration](Shaarli-configuration) for more details). ## Upgrading with Git @@ -173,7 +173,7 @@ Total 3317 (delta 2050), reused 3301 (delta 2034)to #### Step 3: configuration -After migrating, access your fresh Shaarli installation from a web browser; the configuration will then be automatically updated, and new settings added to `data/config.php` (see [Shaarli configuration](Shaarli configuration) for more details). +After migrating, access your fresh Shaarli installation from a web browser; the configuration will then be automatically updated, and new settings added to `data/config.php` (see [Shaarli configuration](Shaarli-configuration) for more details). ## Troubleshooting From 66e74d50d38a6fea8fc904a1746157633de7cc65 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Sat, 7 Oct 2017 16:40:16 +0200 Subject: [PATCH 06/77] Don't write History for link import With large imports it has a large impact on performances and isn't really useful. Instead, write an IMPORT event, which let client using the history service resync its DB. -> 15k link import done in 6 seconds. Fixes #985 --- application/History.php | 16 ++++ application/NetscapeBookmarkUtils.php | 16 ++-- .../BookmarkImportTest.php | 81 +++++++++---------- 3 files changed, 65 insertions(+), 48 deletions(-) diff --git a/application/History.php b/application/History.php index 116b926..5e3b1b7 100644 --- a/application/History.php +++ b/application/History.php @@ -16,6 +16,7 @@ * - UPDATED: link updated * - DELETED: link deleted * - SETTINGS: the settings have been updated through the UI. + * - IMPORT: bulk links import * * Note: new events are put at the beginning of the file and history array. */ @@ -41,6 +42,11 @@ class History */ const SETTINGS = 'SETTINGS'; + /** + * @var string Action key: a bulk import has been processed. + */ + const IMPORT = 'IMPORT'; + /** * @var string History file path. */ @@ -121,6 +127,16 @@ class History $this->addEvent(self::SETTINGS); } + /** + * Add Event: bulk import. + * + * Note: we don't store links add/update one by one since it can have a huge impact on performances. + */ + public function importLinks() + { + $this->addEvent(self::IMPORT); + } + /** * Save a new event and write it in the history file. * diff --git a/application/NetscapeBookmarkUtils.php b/application/NetscapeBookmarkUtils.php index 2a10ff2..3179636 100644 --- a/application/NetscapeBookmarkUtils.php +++ b/application/NetscapeBookmarkUtils.php @@ -66,6 +66,7 @@ class NetscapeBookmarkUtils * @param int $importCount how many links were imported * @param int $overwriteCount how many links were overwritten * @param int $skipCount how many links were skipped + * @param int $duration how many seconds did the import take * * @return string Summary of the bookmark import status */ @@ -74,14 +75,16 @@ class NetscapeBookmarkUtils $filesize, $importCount=0, $overwriteCount=0, - $skipCount=0 + $skipCount=0, + $duration=0 ) { $status = 'File '.$filename.' ('.$filesize.' bytes) '; if ($importCount == 0 && $overwriteCount == 0 && $skipCount == 0) { $status .= 'has an unknown file format. Nothing was imported.'; } else { - $status .= 'was successfully processed: '.$importCount.' links imported, '; + $status .= 'was successfully processed in '. $duration .' seconds: '; + $status .= $importCount.' links imported, '; $status .= $overwriteCount.' links overwritten, '; $status .= $skipCount.' links skipped.'; } @@ -101,6 +104,7 @@ class NetscapeBookmarkUtils */ public static function import($post, $files, $linkDb, $conf, $history) { + $start = time(); $filename = $files['filetoupload']['name']; $filesize = $files['filetoupload']['size']; $data = file_get_contents($files['filetoupload']['tmp_name']); @@ -184,7 +188,6 @@ class NetscapeBookmarkUtils $linkDb[$existingLink['id']] = $newLink; $importCount++; $overwriteCount++; - $history->updateLink($newLink); continue; } @@ -196,16 +199,19 @@ class NetscapeBookmarkUtils $newLink['shorturl'] = link_small_hash($newLink['created'], $newLink['id']); $linkDb[$newLink['id']] = $newLink; $importCount++; - $history->addLink($newLink); } $linkDb->save($conf->get('resource.page_cache')); + $history->importLinks(); + + $duration = time() - $start; return self::importStatus( $filename, $filesize, $importCount, $overwriteCount, - $skipCount + $skipCount, + $duration ); } } diff --git a/tests/NetscapeBookmarkUtils/BookmarkImportTest.php b/tests/NetscapeBookmarkUtils/BookmarkImportTest.php index 5fc1d1e..4961aa2 100644 --- a/tests/NetscapeBookmarkUtils/BookmarkImportTest.php +++ b/tests/NetscapeBookmarkUtils/BookmarkImportTest.php @@ -132,8 +132,8 @@ class BookmarkImportTest extends PHPUnit_Framework_TestCase public function testImportInternetExplorerEncoding() { $files = file2array('internet_explorer_encoding.htm'); - $this->assertEquals( - 'File internet_explorer_encoding.htm (356 bytes) was successfully processed:' + $this->assertStringMatchesFormat( + 'File internet_explorer_encoding.htm (356 bytes) was successfully processed in %d seconds:' .' 1 links imported, 0 links overwritten, 0 links skipped.', NetscapeBookmarkUtils::import([], $files, $this->linkDb, $this->conf, $this->history) ); @@ -161,8 +161,8 @@ class BookmarkImportTest extends PHPUnit_Framework_TestCase public function testImportNested() { $files = file2array('netscape_nested.htm'); - $this->assertEquals( - 'File netscape_nested.htm (1337 bytes) was successfully processed:' + $this->assertStringMatchesFormat( + 'File netscape_nested.htm (1337 bytes) was successfully processed in %d seconds:' .' 8 links imported, 0 links overwritten, 0 links skipped.', NetscapeBookmarkUtils::import([], $files, $this->linkDb, $this->conf, $this->history) ); @@ -283,8 +283,8 @@ class BookmarkImportTest extends PHPUnit_Framework_TestCase public function testImportDefaultPrivacyNoPost() { $files = file2array('netscape_basic.htm'); - $this->assertEquals( - 'File netscape_basic.htm (482 bytes) was successfully processed:' + $this->assertStringMatchesFormat( + 'File netscape_basic.htm (482 bytes) was successfully processed in %d seconds:' .' 2 links imported, 0 links overwritten, 0 links skipped.', NetscapeBookmarkUtils::import([], $files, $this->linkDb, $this->conf, $this->history) ); @@ -328,8 +328,8 @@ class BookmarkImportTest extends PHPUnit_Framework_TestCase { $post = array('privacy' => 'default'); $files = file2array('netscape_basic.htm'); - $this->assertEquals( - 'File netscape_basic.htm (482 bytes) was successfully processed:' + $this->assertStringMatchesFormat( + 'File netscape_basic.htm (482 bytes) was successfully processed in %d seconds:' .' 2 links imported, 0 links overwritten, 0 links skipped.', NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf, $this->history) ); @@ -372,8 +372,8 @@ class BookmarkImportTest extends PHPUnit_Framework_TestCase { $post = array('privacy' => 'public'); $files = file2array('netscape_basic.htm'); - $this->assertEquals( - 'File netscape_basic.htm (482 bytes) was successfully processed:' + $this->assertStringMatchesFormat( + 'File netscape_basic.htm (482 bytes) was successfully processed in %d seconds:' .' 2 links imported, 0 links overwritten, 0 links skipped.', NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf, $this->history) ); @@ -396,8 +396,8 @@ class BookmarkImportTest extends PHPUnit_Framework_TestCase { $post = array('privacy' => 'private'); $files = file2array('netscape_basic.htm'); - $this->assertEquals( - 'File netscape_basic.htm (482 bytes) was successfully processed:' + $this->assertStringMatchesFormat( + 'File netscape_basic.htm (482 bytes) was successfully processed in %d seconds:' .' 2 links imported, 0 links overwritten, 0 links skipped.', NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf, $this->history) ); @@ -422,8 +422,8 @@ class BookmarkImportTest extends PHPUnit_Framework_TestCase // import links as private $post = array('privacy' => 'private'); - $this->assertEquals( - 'File netscape_basic.htm (482 bytes) was successfully processed:' + $this->assertStringMatchesFormat( + 'File netscape_basic.htm (482 bytes) was successfully processed in %d seconds:' .' 2 links imported, 0 links overwritten, 0 links skipped.', NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf, $this->history) ); @@ -442,8 +442,8 @@ class BookmarkImportTest extends PHPUnit_Framework_TestCase 'privacy' => 'public', 'overwrite' => 'true' ); - $this->assertEquals( - 'File netscape_basic.htm (482 bytes) was successfully processed:' + $this->assertStringMatchesFormat( + 'File netscape_basic.htm (482 bytes) was successfully processed in %d seconds:' .' 2 links imported, 2 links overwritten, 0 links skipped.', NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf, $this->history) ); @@ -468,8 +468,8 @@ class BookmarkImportTest extends PHPUnit_Framework_TestCase // import links as public $post = array('privacy' => 'public'); - $this->assertEquals( - 'File netscape_basic.htm (482 bytes) was successfully processed:' + $this->assertStringMatchesFormat( + 'File netscape_basic.htm (482 bytes) was successfully processed in %d seconds:' .' 2 links imported, 0 links overwritten, 0 links skipped.', NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf, $this->history) ); @@ -489,8 +489,8 @@ class BookmarkImportTest extends PHPUnit_Framework_TestCase 'privacy' => 'private', 'overwrite' => 'true' ); - $this->assertEquals( - 'File netscape_basic.htm (482 bytes) was successfully processed:' + $this->assertStringMatchesFormat( + 'File netscape_basic.htm (482 bytes) was successfully processed in %d seconds:' .' 2 links imported, 2 links overwritten, 0 links skipped.', NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf, $this->history) ); @@ -513,8 +513,8 @@ class BookmarkImportTest extends PHPUnit_Framework_TestCase { $post = array('privacy' => 'public'); $files = file2array('netscape_basic.htm'); - $this->assertEquals( - 'File netscape_basic.htm (482 bytes) was successfully processed:' + $this->assertStringMatchesFormat( + 'File netscape_basic.htm (482 bytes) was successfully processed in %d seconds:' .' 2 links imported, 0 links overwritten, 0 links skipped.', NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf, $this->history) ); @@ -523,8 +523,8 @@ class BookmarkImportTest extends PHPUnit_Framework_TestCase // re-import as private, DO NOT enable overwriting $post = array('privacy' => 'private'); - $this->assertEquals( - 'File netscape_basic.htm (482 bytes) was successfully processed:' + $this->assertStringMatchesFormat( + 'File netscape_basic.htm (482 bytes) was successfully processed in %d seconds:' .' 0 links imported, 0 links overwritten, 2 links skipped.', NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf, $this->history) ); @@ -542,8 +542,8 @@ class BookmarkImportTest extends PHPUnit_Framework_TestCase 'default_tags' => 'tag1,tag2 tag3' ); $files = file2array('netscape_basic.htm'); - $this->assertEquals( - 'File netscape_basic.htm (482 bytes) was successfully processed:' + $this->assertStringMatchesFormat( + 'File netscape_basic.htm (482 bytes) was successfully processed in %d seconds:' .' 2 links imported, 0 links overwritten, 0 links skipped.', NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf, $this->history) ); @@ -569,8 +569,8 @@ class BookmarkImportTest extends PHPUnit_Framework_TestCase 'default_tags' => 'tag1&,tag2 "tag3"' ); $files = file2array('netscape_basic.htm'); - $this->assertEquals( - 'File netscape_basic.htm (482 bytes) was successfully processed:' + $this->assertStringMatchesFormat( + 'File netscape_basic.htm (482 bytes) was successfully processed in %d seconds:' .' 2 links imported, 0 links overwritten, 0 links skipped.', NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf, $this->history) ); @@ -594,8 +594,8 @@ class BookmarkImportTest extends PHPUnit_Framework_TestCase public function testImportSameDate() { $files = file2array('same_date.htm'); - $this->assertEquals( - 'File same_date.htm (453 bytes) was successfully processed:' + $this->assertStringMatchesFormat( + 'File same_date.htm (453 bytes) was successfully processed in %d seconds:' .' 3 links imported, 0 links overwritten, 0 links skipped.', NetscapeBookmarkUtils::import(array(), $files, $this->linkDb, $this->conf, $this->history) ); @@ -622,24 +622,19 @@ class BookmarkImportTest extends PHPUnit_Framework_TestCase 'overwrite' => 'true', ]; $files = file2array('netscape_basic.htm'); - $nbLinks = 2; NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf, $this->history); $history = $this->history->getHistory(); - $this->assertEquals($nbLinks, count($history)); - foreach ($history as $value) { - $this->assertEquals(History::CREATED, $value['event']); - $this->assertTrue(new DateTime('-5 seconds') < $value['datetime']); - $this->assertTrue(is_int($value['id'])); - } + $this->assertEquals(1, count($history)); + $this->assertEquals(History::IMPORT, $history[0]['event']); + $this->assertTrue(new DateTime('-5 seconds') < $history[0]['datetime']); // re-import as private, enable overwriting NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf, $this->history); $history = $this->history->getHistory(); - $this->assertEquals($nbLinks * 2, count($history)); - for ($i = 0 ; $i < $nbLinks ; $i++) { - $this->assertEquals(History::UPDATED, $history[$i]['event']); - $this->assertTrue(new DateTime('-5 seconds') < $history[$i]['datetime']); - $this->assertTrue(is_int($history[$i]['id'])); - } + $this->assertEquals(2, count($history)); + $this->assertEquals(History::IMPORT, $history[0]['event']); + $this->assertTrue(new DateTime('-5 seconds') < $history[0]['datetime']); + $this->assertEquals(History::IMPORT, $history[1]['event']); + $this->assertTrue(new DateTime('-5 seconds') < $history[1]['datetime']); } } From e9619cc4f8717c1b8b66f60998cf8402bb983349 Mon Sep 17 00:00:00 2001 From: VirtualTam Date: Wed, 11 Oct 2017 21:35:17 +0200 Subject: [PATCH 07/77] Add EditorConfig configuration EditorConfig allows specifying indentation, line feed and encoding properties according to the type of file being edited. Most editors support it out-of-the-box, or can benefit from it through a plugin. See: - http://editorconfig.org/ - https://github.com/editorconfig/editorconfig - https://github.com/editorconfig/editorconfig/wiki/EditorConfig-Properties Signed-off-by: VirtualTam --- .editorconfig | 23 +++++++++++++++++++++++ .gitattributes | 1 + 2 files changed, 24 insertions(+) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..5abbd7b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,23 @@ +# EditorConfig: http://EditorConfig.org + +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.{html,xml}] +indent_size = 2 + +[*.php] +max_line_length = 100 + +[Dockerfile] +max_line_length = 80 + +[Makefile] +indent_style = tab diff --git a/.gitattributes b/.gitattributes index dd0e573..9390060 100644 --- a/.gitattributes +++ b/.gitattributes @@ -24,6 +24,7 @@ Dockerfile text *.min.js binary # Exclude from Git archives +.editorconfig export-ignore .gitattributes export-ignore .github export-ignore .gitignore export-ignore From a93b620a35a9768e102de31f19552624f33a0ae0 Mon Sep 17 00:00:00 2001 From: VirtualTam Date: Mon, 16 Oct 2017 19:38:33 +0200 Subject: [PATCH 08/77] EditorConfig: add .htaccess support Signed-off-by: VirtualTam --- .editorconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.editorconfig b/.editorconfig index 5abbd7b..4a6589a 100644 --- a/.editorconfig +++ b/.editorconfig @@ -10,7 +10,7 @@ trim_trailing_whitespace = true indent_style = space indent_size = 4 -[*.{html,xml}] +[*.{htaccess,html,xml}] indent_size = 2 [*.php] From 710291b164421663b9b1cf1f866e957801ff83e0 Mon Sep 17 00:00:00 2001 From: VirtualTam Date: Mon, 16 Oct 2017 19:39:16 +0200 Subject: [PATCH 09/77] Fix: enable access to data/user.css (Apache 2.2 & 2.4) Relates to https://github.com/shaarli/Shaarli/issues/872 Relates to https://github.com/shaarli/Shaarli/issues/993 Signed-off-by: VirtualTam --- data/.htaccess | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/data/.htaccess b/data/.htaccess index f601c1e..1d49da3 100644 --- a/data/.htaccess +++ b/data/.htaccess @@ -1,10 +1,16 @@ = 2.4> - Require all denied + Require all denied + + Require all granted + - Allow from none - Deny from all + Allow from none + Deny from all + + Allow from all + From 919c9803443d5b05623fb34e755c85e1e3c22d91 Mon Sep 17 00:00:00 2001 From: nodiscc Date: Thu, 19 Oct 2017 18:06:07 +0200 Subject: [PATCH 10/77] documentation: update tag cloud/filtering doc Ref. https://github.com/shaarli/Shaarli/issues/959 --- doc/md/Browsing-and-searching.md | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/doc/md/Browsing-and-searching.md b/doc/md/Browsing-and-searching.md index 3570748..2448313 100644 --- a/doc/md/Browsing-and-searching.md +++ b/doc/md/Browsing-and-searching.md @@ -14,10 +14,24 @@ Use the `Filter by tags` field to restrict displayed links to entries tagged wit **Hidden tags:** Tags starting with a dot `.` (example `.secret`) are private. They can only be seen and searched when logged in. -Alternatively you can use the `Tag cloud` to discover all tags and click on any of them to display related links. +### Tag cloud -To search for links that are not tagged, enter `""` in the tag search field. +The `Tag cloud` page diplays a "cloud" view of all tags in your Shaarli. + + * More frequently used tags are displayed with a bigger font size. + * When sorting by `Most used` or `Alphabetical`, tags are displayed as a _list_, along with counters and edit/delete button for each tag. + * Clicking on any tag will display a list of all Shaares matching this tag. + * Clicking on the counter next to a tag `example` , will filter the tag cloud to only display tags found in Shaares tagged `example`. Repeat this any number of times to further filter the tag cloud. Click `List all links with those tags` to display Shaares matching your current tag filter. ## Filtering RSS feeds/Picture wall RSS feeds can also be restricted to only return items matching a text/tag search: see [RSS feeds](RSS feeds). + +## Filter buttons + +Filter buttons can be found at the top left of the link list. They allow you to apply different filters to the list: + + * **Private links:** When this toggle button is enabled, only shaares set to `private` will be shown. + * **Untagged links:** When the this toggle button is enabled (top left of the link list), only shaares _without any tags_ will be shown in the link list. + +Filter buttons are only available when logged in. From fab0f4e5766d519a37b497927812c6b5b38e51ed Mon Sep 17 00:00:00 2001 From: VirtualTam Date: Sat, 21 Oct 2017 18:15:52 +0200 Subject: [PATCH 11/77] docker: add 'latest' image This implies the following changes: - `shaarli/shaarli:latest` will now point to the `latest` release - `shaarli/shaarli:master` will point to the `master` branch Signed-off-by: VirtualTam --- doc/md/docker/shaarli-images.md | 13 +++++++-- docker/alpine/Dockerfile.latest | 47 +++++++++++++++++++++++++++++++++ docker/alpine/IMAGE.md | 13 ++++++--- 3 files changed, 67 insertions(+), 6 deletions(-) create mode 100644 docker/alpine/Dockerfile.latest diff --git a/doc/md/docker/shaarli-images.md b/doc/md/docker/shaarli-images.md index 6d108d2..1d19510 100644 --- a/doc/md/docker/shaarli-images.md +++ b/doc/md/docker/shaarli-images.md @@ -5,14 +5,23 @@ The images can be found in the [`shaarli/shaarli`](https://hub.docker.com/r/shaa repository. ### Available image tags -- `latest`: master branch (tarball release) +- `latest`: latest branch (tarball release) +- `master`: master branch (tarball release) - `stable`: stable branch (tarball release) -All images rely on: +The `latest` and `master` images rely on: + +- [Alpine Linux](https://www.alpinelinux.org/) +- [PHP7-FPM](http://php-fpm.org/) +- [Nginx](http://nginx.org/) + +The `stable` image relies on: + - [Debian 8 Jessie](https://hub.docker.com/_/debian/) - [PHP5-FPM](http://php-fpm.org/) - [Nginx](http://nginx.org/) + ### Download from DockerHub ```bash $ docker pull shaarli/shaarli diff --git a/docker/alpine/Dockerfile.latest b/docker/alpine/Dockerfile.latest new file mode 100644 index 0000000..dd4a173 --- /dev/null +++ b/docker/alpine/Dockerfile.latest @@ -0,0 +1,47 @@ +FROM alpine:3.6 +MAINTAINER Shaarli Community + +RUN apk --update --no-cache add \ + ca-certificates \ + curl \ + nginx \ + php7 \ + php7-ctype \ + php7-curl \ + php7-fpm \ + php7-gd \ + php7-iconv \ + php7-intl \ + php7-json \ + php7-mbstring \ + php7-openssl \ + php7-phar \ + php7-session \ + php7-xml \ + php7-zlib \ + s6 + +COPY nginx.conf /etc/nginx/nginx.conf +COPY php-fpm.conf /etc/php7/php-fpm.conf +COPY services.d /etc/services.d + +RUN curl -sS https://getcomposer.org/installer | php7 -- --install-dir=/usr/local/bin --filename=composer \ + && rm -rf /etc/php7/php-fpm.d/www.conf \ + && sed -i 's/post_max_size.*/post_max_size = 10M/' /etc/php7/php.ini \ + && sed -i 's/upload_max_filesize.*/upload_max_filesize = 10M/' /etc/php7/php.ini + + +WORKDIR /var/www +RUN curl -L https://github.com/shaarli/Shaarli/archive/latest.tar.gz | tar xzf - \ + && mv Shaarli-latest shaarli \ + && cd shaarli \ + && composer --prefer-dist --no-dev install \ + && rm -rf ~/.composer \ + && chown -R nginx:nginx . + +VOLUME /var/www/shaarli/data + +EXPOSE 80 + +ENTRYPOINT ["/bin/s6-svscan", "/etc/services.d"] +CMD [] diff --git a/docker/alpine/IMAGE.md b/docker/alpine/IMAGE.md index 6f827b3..a895225 100644 --- a/docker/alpine/IMAGE.md +++ b/docker/alpine/IMAGE.md @@ -1,5 +1,10 @@ -## shaarli:latest -- [Debian 8 Jessie](https://hub.docker.com/_/debian/) -- [PHP5-FPM](http://php-fpm.org/) +## Alpine images +- [Alpine Linux](https://www.alpinelinux.org/) +- [PHP-FPM](http://php-fpm.org/) - [Nginx](http://nginx.org/) -- [Shaarli](https://github.com/shaarli/Shaarli) + +### `shaarli/shaarli:latest` +- [Shaarli](https://github.com/shaarli/Shaarli), `latest` branch + +### `shaarli/shaarli:master` +- [Shaarli](https://github.com/shaarli/Shaarli), `master` branch From cfcc38192aff2bcabafa5cc67f18a5026255c96d Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Sun, 22 Oct 2017 12:50:04 +0200 Subject: [PATCH 12/77] Doc: mention Docker docs in the download & install page --- doc/md/Download-and-Installation.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/doc/md/Download-and-Installation.md b/doc/md/Download-and-Installation.md index e5e929e..3453e8b 100644 --- a/doc/md/Download-and-Installation.md +++ b/doc/md/Download-and-Installation.md @@ -4,11 +4,18 @@ Document Root (or directly at the document root). Also, please make sure your server meets the [requirements](Server-requirements) and is properly [configured](Server-configuration). -Several releases are available: +Multiple releases branches are available: + +- latest (last release) +- stable (previous major release) +- master (development) + +Using one of the following methods: - by downloading full release archives including all dependencies - by downloading Github archives - by cloning the Git repository +- using Docker: [see the documentation](docker/shaarli-images) --- @@ -28,13 +35,14 @@ $ unzip shaarli-v0.9.1-full.zip $ mv Shaarli /path/to/shaarli/ ``` -In most cases, download Shaarli from the [releases](https://github.com/shaarli/Shaarli/releases) page. Cloning using `git` or downloading Github branches as zip files requires additional steps (see below).| +In most cases, download Shaarli from the [releases](https://github.com/shaarli/Shaarli/releases) page. +Cloning using `git` or downloading Github branches as zip files requires additional steps (see below). ### Using git ``` $ mkdir -p /path/to/shaarli && cd /path/to/shaarli/ -$ git clone -b v0.9 https://github.com/shaarli/Shaarli.git . +$ git clone -b latest https://github.com/shaarli/Shaarli.git . $ composer install --no-dev --prefer-dist ``` From 12266213d098a53c5f005b9afcbbe62771fd580c Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Tue, 9 May 2017 18:12:15 +0200 Subject: [PATCH 13/77] Shaarli's translation * translation system and unit tests * Translations everywhere Dont use translation merge It is not available with PHP builtin gettext, so it would have lead to inconsistency. --- Makefile | 2 +- application/ApplicationUtils.php | 19 +- application/Cache.php | 2 +- application/FeedBuilder.php | 4 +- application/History.php | 4 +- application/Languages.php | 153 +- application/LinkDB.php | 20 +- application/LinkFilter.php | 8 +- application/NetscapeBookmarkUtils.php | 16 +- application/PageBuilder.php | 7 +- application/PluginManager.php | 7 +- application/Updater.php | 8 +- application/Utils.php | 17 +- application/config/ConfigJson.php | 15 +- application/config/ConfigManager.php | 6 +- application/config/ConfigPhp.php | 4 +- .../exception/MissingFieldConfigException.php | 2 +- .../exception/PluginConfigOrderException.php | 2 +- .../exception/UnauthorizedConfigException.php | 2 +- application/exceptions/IOException.php | 2 +- composer.json | 3 +- composer.lock | 217 ++- inc/languages/fr/LC_MESSAGES/shaarli.mo | Bin 0 -> 22943 bytes inc/languages/fr/LC_MESSAGES/shaarli.po | 1243 +++++++++++++++++ index.php | 78 +- plugins/TODO.md | 28 - plugins/addlink_toolbar/addlink_toolbar.php | 13 +- plugins/archiveorg/archiveorg.html | 6 +- plugins/archiveorg/archiveorg.php | 11 +- plugins/demo_plugin/demo_plugin.php | 9 + plugins/isso/isso.php | 17 +- plugins/markdown/help.html | 6 +- plugins/markdown/markdown.php | 21 +- plugins/piwik/piwik.php | 15 +- plugins/playvideos/playvideos.php | 13 +- plugins/pubsubhubbub/pubsubhubbub.php | 16 +- plugins/qrcode/qrcode.meta | 2 +- plugins/qrcode/qrcode.php | 9 + plugins/wallabag/wallabag.html | 6 +- plugins/wallabag/wallabag.php | 20 +- tests/LanguagesTest.php | 184 ++- tests/UtilsTest.php | 30 +- tests/bootstrap.php | 6 + tests/languages/bootstrap.php | 7 +- tests/languages/fr/LanguagesFrTest.php | 173 +++ tests/utils/languages/fr/LC_MESSAGES/test.mo | Bin 0 -> 456 bytes tests/utils/languages/fr/LC_MESSAGES/test.po | 19 + tpl/default/import.html | 8 +- tpl/default/linklist.html | 30 +- tpl/default/page.footer.html | 4 +- tpl/default/pluginsadmin.html | 4 +- 51 files changed, 2252 insertions(+), 246 deletions(-) create mode 100644 inc/languages/fr/LC_MESSAGES/shaarli.mo create mode 100644 inc/languages/fr/LC_MESSAGES/shaarli.po delete mode 100644 plugins/TODO.md create mode 100644 tests/bootstrap.php create mode 100644 tests/languages/fr/LanguagesFrTest.php create mode 100644 tests/utils/languages/fr/LC_MESSAGES/test.mo create mode 100644 tests/utils/languages/fr/LC_MESSAGES/test.po diff --git a/Makefile b/Makefile index 656c27b..300f1d7 100644 --- a/Makefile +++ b/Makefile @@ -135,7 +135,7 @@ test: @echo "PHPUNIT" @echo "-------" @mkdir -p sandbox coverage - @$(BIN)/phpunit --coverage-php coverage/main.cov --testsuite unit-tests + @$(BIN)/phpunit --coverage-php coverage/main.cov --bootstrap tests/bootstrap.php --testsuite unit-tests locale_test_%: @UT_LOCALE=$*.utf8 \ diff --git a/application/ApplicationUtils.php b/application/ApplicationUtils.php index 5643f4a..911873a 100644 --- a/application/ApplicationUtils.php +++ b/application/ApplicationUtils.php @@ -149,12 +149,13 @@ class ApplicationUtils public static function checkPHPVersion($minVersion, $curVersion) { if (version_compare($curVersion, $minVersion) < 0) { - throw new Exception( + $msg = t( 'Your PHP version is obsolete!' - .' Shaarli requires at least PHP '.$minVersion.', and thus cannot run.' - .' Your PHP version has known security vulnerabilities and should be' - .' updated as soon as possible.' + . ' Shaarli requires at least PHP %s, and thus cannot run.' + . ' Your PHP version has known security vulnerabilities and should be' + . ' updated as soon as possible.' ); + throw new Exception(sprintf($msg, $minVersion)); } } @@ -179,7 +180,7 @@ class ApplicationUtils $rainTplDir.'/'.$conf->get('resource.theme'), ) as $path) { if (! is_readable(realpath($path))) { - $errors[] = '"'.$path.'" directory is not readable'; + $errors[] = '"'.$path.'" '. t('directory is not readable'); } } @@ -191,10 +192,10 @@ class ApplicationUtils $conf->get('resource.raintpl_tmp'), ) as $path) { if (! is_readable(realpath($path))) { - $errors[] = '"'.$path.'" directory is not readable'; + $errors[] = '"'.$path.'" '. t('directory is not readable'); } if (! is_writable(realpath($path))) { - $errors[] = '"'.$path.'" directory is not writable'; + $errors[] = '"'.$path.'" '. t('directory is not writable'); } } @@ -212,10 +213,10 @@ class ApplicationUtils } if (! is_readable(realpath($path))) { - $errors[] = '"'.$path.'" file is not readable'; + $errors[] = '"'.$path.'" '. t('file is not readable'); } if (! is_writable(realpath($path))) { - $errors[] = '"'.$path.'" file is not writable'; + $errors[] = '"'.$path.'" '. t('file is not writable'); } } diff --git a/application/Cache.php b/application/Cache.php index 5d05016..e5d43e6 100644 --- a/application/Cache.php +++ b/application/Cache.php @@ -13,7 +13,7 @@ function purgeCachedPages($pageCacheDir) { if (! is_dir($pageCacheDir)) { - $error = 'Cannot purge '.$pageCacheDir.': no directory'; + $error = sprintf(t('Cannot purge %s: no directory'), $pageCacheDir); error_log($error); return $error; } diff --git a/application/FeedBuilder.php b/application/FeedBuilder.php index 7377bce..3cfaafb 100644 --- a/application/FeedBuilder.php +++ b/application/FeedBuilder.php @@ -148,9 +148,9 @@ class FeedBuilder $link['url'] = $pageaddr . $link['url']; } if ($this->usePermalinks === true) { - $permalink = 'Direct link'; + $permalink = ''. t('Direct link') .''; } else { - $permalink = 'Permalink'; + $permalink = ''. t('Permalink') .''; } $link['description'] = format_description($link['description'], '', $pageaddr); $link['description'] .= PHP_EOL .'
— '. $permalink; diff --git a/application/History.php b/application/History.php index 5e3b1b7..35ec016 100644 --- a/application/History.php +++ b/application/History.php @@ -171,7 +171,7 @@ class History } if (! is_writable($this->historyFilePath)) { - throw new Exception('History file isn\'t readable or writable'); + throw new Exception(t('History file isn\'t readable or writable')); } } @@ -182,7 +182,7 @@ class History { $this->history = FileUtils::readFlatDB($this->historyFilePath, []); if ($this->history === false) { - throw new Exception('Could not parse history file'); + throw new Exception(t('Could not parse history file')); } } diff --git a/application/Languages.php b/application/Languages.php index c8b0a25..4ba32f2 100644 --- a/application/Languages.php +++ b/application/Languages.php @@ -1,21 +1,150 @@ //LC_MESSAGES/.[po|mo] * - * @return String Text translated. + * Pros/cons: + * - gettext extension is faster + * - gettext is very system dependent (PHP extension, the locale must be installed, and web server reloaded) + * + * Settings: + * - translation.mode: + * - auto: use default setting (PHP implementation) + * - php: use PHP implementation + * - gettext: use gettext wrapper + * - translation.language: + * - auto: use autoLocale() and the language change according to user HTTP headers + * - fixed language: e.g. 'fr' + * - translation.extensions: + * - domain => translation_path: allow plugins and themes to extend the defaut extension + * The domain must be unique, and translation path must be relative, and contains the tree mentioned above. + * + * @package Shaarli */ -function t($text, $nText = '', $nb = 0) { - if (empty($nText)) { - return $text; +class Languages +{ + /** + * Core translations domain + */ + const DEFAULT_DOMAIN = 'shaarli'; + + /** + * @var TranslatorInterface + */ + protected $translator; + + /** + * @var string + */ + protected $language; + + /** + * @var ConfigManager + */ + protected $conf; + + /** + * Languages constructor. + * + * @param string $language lang determined by autoLocale(), can be override. + * @param ConfigManager $conf instance. + */ + public function __construct($language, $conf) + { + $this->conf = $conf; + $confLanguage = $this->conf->get('translation.language', 'auto'); + if ($confLanguage === 'auto' || ! $this->isValidLanguage($confLanguage)) { + $this->language = substr($language, 0, 5); + } else { + $this->language = $confLanguage; + } + + if (! extension_loaded('gettext') + || in_array($this->conf->get('translation.mode', 'auto'), ['auto', 'php']) + ) { + $this->initPhpTranslator(); + } else { + $this->initGettextTranslator(); + } + + // Register default functions (e.g. '__()') to use our Translator + $this->translator->register(); + } + + /** + * Initialize the translator using php gettext extension (gettext dependency act as a wrapper). + */ + protected function initGettextTranslator () + { + $this->translator = new GettextTranslator(); + $this->translator->setLanguage($this->language); + $this->translator->loadDomain(self::DEFAULT_DOMAIN, 'inc/languages'); + + foreach ($this->conf->get('translation.extensions', []) as $domain => $translationPath) { + if ($domain !== self::DEFAULT_DOMAIN) { + $this->translator->loadDomain($domain, $translationPath, false); + } + } + } + + /** + * Initialize the translator using a PHP implementation of gettext. + * + * Note that if language po file doesn't exist, errors are ignored (e.g. not installed language). + */ + protected function initPhpTranslator() + { + $this->translator = new Translator(); + $translations = new Translations(); + // Core translations + try { + /** @var Translations $translations */ + $translations = $translations->addFromPoFile('inc/languages/'. $this->language .'/LC_MESSAGES/shaarli.po'); + $translations->setDomain('shaarli'); + $this->translator->loadTranslations($translations); + } catch (\InvalidArgumentException $e) {} + + + // Extension translations (plugins, themes, etc.). + foreach ($this->conf->get('translation.extensions', []) as $domain => $translationPath) { + if ($domain === self::DEFAULT_DOMAIN) { + continue; + } + + try { + /** @var Translations $extension */ + $extension = Translations::fromPoFile($translationPath . $this->language .'/LC_MESSAGES/'. $domain .'.po'); + $extension->setDomain($domain); + $this->translator->loadTranslations($extension); + } catch (\InvalidArgumentException $e) {} + } + } + + /** + * Checks if a language string is valid. + * + * @param string $language e.g. 'fr' or 'en_US' + * + * @return bool true if valid, false otherwise + */ + protected function isValidLanguage($language) + { + return preg_match('/^[a-z]{2}(_[A-Z]{2})?/', $language) === 1; } - $actualForm = $nb > 1 ? $nText : $text; - return sprintf($actualForm, $nb); } diff --git a/application/LinkDB.php b/application/LinkDB.php index 22c1f0a..f026a04 100644 --- a/application/LinkDB.php +++ b/application/LinkDB.php @@ -133,16 +133,16 @@ class LinkDB implements Iterator, Countable, ArrayAccess { // TODO: use exceptions instead of "die" if (!$this->loggedIn) { - die('You are not authorized to add a link.'); + die(t('You are not authorized to add a link.')); } if (!isset($value['id']) || empty($value['url'])) { - die('Internal Error: A link should always have an id and URL.'); + die(t('Internal Error: A link should always have an id and URL.')); } if (($offset !== null && ! is_int($offset)) || ! is_int($value['id'])) { - die('You must specify an integer as a key.'); + die(t('You must specify an integer as a key.')); } if ($offset !== null && $offset !== $value['id']) { - die('Array offset and link ID must be equal.'); + die(t('Array offset and link ID must be equal.')); } // If the link exists, we reuse the real offset, otherwise new entry @@ -248,13 +248,13 @@ class LinkDB implements Iterator, Countable, ArrayAccess $this->links = array(); $link = array( 'id' => 1, - 'title'=>' Shaarli: the personal, minimalist, super-fast, no-database delicious clone', + 'title'=> t('The personal, minimalist, super-fast, database free, bookmarking service'), 'url'=>'https://shaarli.readthedocs.io', - 'description'=>'Welcome to Shaarli! This is your first public bookmark. To edit or delete me, you must first login. + 'description'=>t('Welcome to Shaarli! This is your first public bookmark. To edit or delete me, you must first login. -To learn how to use Shaarli, consult the link "Help/documentation" at the bottom of this page. +To learn how to use Shaarli, consult the link "Documentation" at the bottom of this page. -You use the community supported version of the original Shaarli project, by Sebastien Sauvage.', +You use the community supported version of the original Shaarli project, by Sebastien Sauvage.'), 'private'=>0, 'created'=> new DateTime(), 'tags'=>'opensource software' @@ -264,9 +264,9 @@ You use the community supported version of the original Shaarli project, by Seba $link = array( 'id' => 0, - 'title'=>'My secret stuff... - Pastebin.com', + 'title'=> t('My secret stuff... - Pastebin.com'), 'url'=>'http://sebsauvage.net/paste/?8434b27936c09649#bR7XsXhoTiLcqCpQbmOpBi3rq2zzQUC5hBI7ZT1O3x8=', - 'description'=>'Shhhh! I\'m a private link only YOU can see. You can delete me too.', + 'description'=> t('Shhhh! I\'m a private link only YOU can see. You can delete me too.'), 'private'=>1, 'created'=> new DateTime('1 minute ago'), 'tags'=>'secretstuff', diff --git a/application/LinkFilter.php b/application/LinkFilter.php index 99ecd1e..12376e2 100644 --- a/application/LinkFilter.php +++ b/application/LinkFilter.php @@ -444,5 +444,11 @@ class LinkFilter class LinkNotFoundException extends Exception { - protected $message = 'The link you are trying to reach does not exist or has been deleted.'; + /** + * LinkNotFoundException constructor. + */ + public function __construct() + { + $this->message = t('The link you are trying to reach does not exist or has been deleted.'); + } } diff --git a/application/NetscapeBookmarkUtils.php b/application/NetscapeBookmarkUtils.php index 3179636..31a1453 100644 --- a/application/NetscapeBookmarkUtils.php +++ b/application/NetscapeBookmarkUtils.php @@ -32,11 +32,11 @@ class NetscapeBookmarkUtils { // see tpl/export.html for possible values if (! in_array($selection, array('all', 'public', 'private'))) { - throw new Exception('Invalid export selection: "'.$selection.'"'); + throw new Exception(t('Invalid export selection:') .' "'.$selection.'"'); } $bookmarkLinks = array(); - +7 foreach ($linkDb as $link) { if ($link['private'] != 0 && $selection == 'public') { continue; @@ -79,14 +79,14 @@ class NetscapeBookmarkUtils $duration=0 ) { - $status = 'File '.$filename.' ('.$filesize.' bytes) '; + $status = sprintf(t('File %s (%d bytes) '), $filename, $filesize); if ($importCount == 0 && $overwriteCount == 0 && $skipCount == 0) { - $status .= 'has an unknown file format. Nothing was imported.'; + $status .= t('has an unknown file format. Nothing was imported.'); } else { - $status .= 'was successfully processed in '. $duration .' seconds: '; - $status .= $importCount.' links imported, '; - $status .= $overwriteCount.' links overwritten, '; - $status .= $skipCount.' links skipped.'; + $status .= vsprintf( + t('was successfully processed in %d seconds: %d links imported, %d links overwritten, %d links skipped.'), + [$duration, $importCount, $overwriteCount, $skipCount] + ); } return $status; } diff --git a/application/PageBuilder.php b/application/PageBuilder.php index 291860a..af29067 100644 --- a/application/PageBuilder.php +++ b/application/PageBuilder.php @@ -159,9 +159,12 @@ class PageBuilder * * @param string $message A messate to display what is not found */ - public function render404($message = 'The page you are trying to reach does not exist or has been deleted.') + public function render404($message = '') { - header($_SERVER['SERVER_PROTOCOL'] . ' 404 Not Found'); + if (empty($message)) { + $message = t('The page you are trying to reach does not exist or has been deleted.'); + } + header($_SERVER['SERVER_PROTOCOL'] .' '. t('404 Not Found')); $this->tpl->assign('error_message', $message); $this->renderPage('404'); } diff --git a/application/PluginManager.php b/application/PluginManager.php index 59ece4f..cf60384 100644 --- a/application/PluginManager.php +++ b/application/PluginManager.php @@ -188,6 +188,9 @@ class PluginManager $metaData[$plugin] = parse_ini_file($metaFile); $metaData[$plugin]['order'] = array_search($plugin, $this->authorizedPlugins); + if (isset($metaData[$plugin]['description'])) { + $metaData[$plugin]['description'] = t($metaData[$plugin]['description']); + } // Read parameters and format them into an array. if (isset($metaData[$plugin]['parameters'])) { $params = explode(';', $metaData[$plugin]['parameters']); @@ -203,7 +206,7 @@ class PluginManager $metaData[$plugin]['parameters'][$param]['value'] = ''; // Optional parameter description in parameter.PARAM_NAME= if (isset($metaData[$plugin]['parameter.'. $param])) { - $metaData[$plugin]['parameters'][$param]['desc'] = $metaData[$plugin]['parameter.'. $param]; + $metaData[$plugin]['parameters'][$param]['desc'] = t($metaData[$plugin]['parameter.'. $param]); } } } @@ -237,6 +240,6 @@ class PluginFileNotFoundException extends Exception */ public function __construct($pluginName) { - $this->message = 'Plugin "'. $pluginName .'" files not found.'; + $this->message = sprintf(t('Plugin "%s" files not found.'), $pluginName); } } diff --git a/application/Updater.php b/application/Updater.php index 72b2def..723a7a8 100644 --- a/application/Updater.php +++ b/application/Updater.php @@ -73,7 +73,7 @@ class Updater } if ($this->methods === null) { - throw new UpdaterException('Couldn\'t retrieve Updater class methods.'); + throw new UpdaterException(t('Couldn\'t retrieve Updater class methods.')); } foreach ($this->methods as $method) { @@ -482,7 +482,7 @@ class UpdaterException extends Exception } if (! empty($this->method)) { - $out .= 'An error occurred while running the update '. $this->method . PHP_EOL; + $out .= t('An error occurred while running the update ') . $this->method . PHP_EOL; } if (! empty($this->previous)) { @@ -522,11 +522,11 @@ function read_updates_file($updatesFilepath) function write_updates_file($updatesFilepath, $updates) { if (empty($updatesFilepath)) { - throw new Exception('Updates file path is not set, can\'t write updates.'); + throw new Exception(t('Updates file path is not set, can\'t write updates.')); } $res = file_put_contents($updatesFilepath, implode(';', $updates)); if ($res === false) { - throw new Exception('Unable to write updates in '. $updatesFilepath . '.'); + throw new Exception(t('Unable to write updates in '. $updatesFilepath . '.')); } } diff --git a/application/Utils.php b/application/Utils.php index 4a2f556..27eaafc 100644 --- a/application/Utils.php +++ b/application/Utils.php @@ -452,7 +452,7 @@ function get_max_upload_size($limitPost, $limitUpload, $format = true) */ function alphabetical_sort(&$data, $reverse = false, $byKeys = false) { - $callback = function($a, $b) use ($reverse) { + $callback = function ($a, $b) use ($reverse) { // Collator is part of PHP intl. if (class_exists('Collator')) { $collator = new Collator(setlocale(LC_COLLATE, 0)); @@ -470,3 +470,18 @@ function alphabetical_sort(&$data, $reverse = false, $byKeys = false) usort($data, $callback); } } + +/** + * Wrapper function for translation which match the API + * of gettext()/_() and ngettext(). + * + * @param string $text Text to translate. + * @param string $nText The plural message ID. + * @param int $nb The number of items for plural forms. + * @param string $domain The domain where the translation is stored (default: shaarli). + * + * @return String Text translated. + */ +function t($text, $nText = '', $nb = 1, $domain = 'shaarli') { + return dn__($domain, $text, $nText, $nb); +} diff --git a/application/config/ConfigJson.php b/application/config/ConfigJson.php index 9ef2ef5..8c8d561 100644 --- a/application/config/ConfigJson.php +++ b/application/config/ConfigJson.php @@ -22,10 +22,15 @@ class ConfigJson implements ConfigIO $data = json_decode($data, true); if ($data === null) { $errorCode = json_last_error(); - $error = 'An error occurred while parsing JSON configuration file ('. $filepath .'): error code #'; - $error .= $errorCode. '
' . json_last_error_msg() .''; + $error = sprintf( + 'An error occurred while parsing JSON configuration file (%s): error code #%d', + $filepath, + $errorCode + ); + $error .= '
' . json_last_error_msg() .''; if ($errorCode === JSON_ERROR_SYNTAX) { - $error .= '
Please check your JSON syntax (without PHP comment tags) using a JSON lint tool such as '; + $error .= '
'; + $error .= 'Please check your JSON syntax (without PHP comment tags) using a JSON lint tool such as '; $error .= 'jsonlint.com.'; } throw new \Exception($error); @@ -44,8 +49,8 @@ class ConfigJson implements ConfigIO if (!file_put_contents($filepath, $data)) { throw new \IOException( $filepath, - 'Shaarli could not create the config file. - Please make sure Shaarli has the right to write in the folder is it installed in.' + t('Shaarli could not create the config file. '. + 'Please make sure Shaarli has the right to write in the folder is it installed in.') ); } } diff --git a/application/config/ConfigManager.php b/application/config/ConfigManager.php index 7ff2fe6..9e4c9f6 100644 --- a/application/config/ConfigManager.php +++ b/application/config/ConfigManager.php @@ -132,7 +132,7 @@ class ConfigManager public function set($setting, $value, $write = false, $isLoggedIn = false) { if (empty($setting) || ! is_string($setting)) { - throw new \Exception('Invalid setting key parameter. String expected, got: '. gettype($setting)); + throw new \Exception(t('Invalid setting key parameter. String expected, got: '). gettype($setting)); } // During the ConfigIO transition, map legacy settings to the new ones. @@ -339,6 +339,10 @@ class ConfigManager $this->setEmpty('redirector.url', ''); $this->setEmpty('redirector.encode_url', true); + $this->setEmpty('translation.language', 'auto'); + $this->setEmpty('translation.mode', 'php'); + $this->setEmpty('translation.extensions', []); + $this->setEmpty('plugins', array()); } diff --git a/application/config/ConfigPhp.php b/application/config/ConfigPhp.php index 2633824..2f66e8e 100644 --- a/application/config/ConfigPhp.php +++ b/application/config/ConfigPhp.php @@ -118,8 +118,8 @@ class ConfigPhp implements ConfigIO ) { throw new \IOException( $filepath, - 'Shaarli could not create the config file. - Please make sure Shaarli has the right to write in the folder is it installed in.' + t('Shaarli could not create the config file. '. + 'Please make sure Shaarli has the right to write in the folder is it installed in.') ); } } diff --git a/application/config/exception/MissingFieldConfigException.php b/application/config/exception/MissingFieldConfigException.php index 6346c6a..9e0a935 100644 --- a/application/config/exception/MissingFieldConfigException.php +++ b/application/config/exception/MissingFieldConfigException.php @@ -18,6 +18,6 @@ class MissingFieldConfigException extends \Exception public function __construct($field) { $this->field = $field; - $this->message = 'Configuration value is required for '. $this->field; + $this->message = sprintf(t('Configuration value is required for %s'), $this->field); } } diff --git a/application/config/exception/PluginConfigOrderException.php b/application/config/exception/PluginConfigOrderException.php index f9d6875..f82ec26 100644 --- a/application/config/exception/PluginConfigOrderException.php +++ b/application/config/exception/PluginConfigOrderException.php @@ -12,6 +12,6 @@ class PluginConfigOrderException extends \Exception */ public function __construct() { - $this->message = 'An error occurred while trying to save plugins loading order.'; + $this->message = t('An error occurred while trying to save plugins loading order.'); } } diff --git a/application/config/exception/UnauthorizedConfigException.php b/application/config/exception/UnauthorizedConfigException.php index 79672c1..72311fa 100644 --- a/application/config/exception/UnauthorizedConfigException.php +++ b/application/config/exception/UnauthorizedConfigException.php @@ -13,6 +13,6 @@ class UnauthorizedConfigException extends \Exception */ public function __construct() { - $this->message = 'You are not authorized to alter config.'; + $this->message = t('You are not authorized to alter config.'); } } diff --git a/application/exceptions/IOException.php b/application/exceptions/IOException.php index b563b23..18e46b7 100644 --- a/application/exceptions/IOException.php +++ b/application/exceptions/IOException.php @@ -16,7 +16,7 @@ class IOException extends Exception public function __construct($path, $message = '') { $this->path = $path; - $this->message = empty($message) ? 'Error accessing' : $message; + $this->message = empty($message) ? t('Error accessing') : $message; $this->message .= ' "' . $this->path .'"'; } } diff --git a/composer.json b/composer.json index afb8aca..f331d6c 100644 --- a/composer.json +++ b/composer.json @@ -19,7 +19,8 @@ "shaarli/netscape-bookmark-parser": "^2.0", "erusev/parsedown": "1.6", "slim/slim": "^3.0", - "pubsubhubbub/publisher": "dev-master" + "pubsubhubbub/publisher": "dev-master", + "gettext/gettext": "^4.4" }, "require-dev": { "phpmd/phpmd" : "@stable", diff --git a/composer.lock b/composer.lock index 435d6a8..ea20025 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", "This file is @generated automatically" ], - "content-hash": "68beedbfa104c788029b079800cfd6e8", + "content-hash": "13b7e1e474fe9264b098ba86face0feb", "packages": [ { "name": "container-interop/container-interop", @@ -76,6 +76,129 @@ ], "time": "2015-10-04T16:44:32+00:00" }, + { + "name": "gettext/gettext", + "version": "v4.4.3", + "source": { + "type": "git", + "url": "https://github.com/oscarotero/Gettext.git", + "reference": "4f57f004635cc6311a20815ebfdc0757cb337113" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/oscarotero/Gettext/zipball/4f57f004635cc6311a20815ebfdc0757cb337113", + "reference": "4f57f004635cc6311a20815ebfdc0757cb337113", + "shasum": "" + }, + "require": { + "gettext/languages": "^2.3", + "php": ">=5.4.0" + }, + "require-dev": { + "illuminate/view": "*", + "phpunit/phpunit": "^4.8|^5.7", + "squizlabs/php_codesniffer": "^3.0", + "symfony/yaml": "~2", + "twig/extensions": "*", + "twig/twig": "^1.31|^2.0" + }, + "suggest": { + "illuminate/view": "Is necessary if you want to use the Blade extractor", + "symfony/yaml": "Is necessary if you want to use the Yaml extractor/generator", + "twig/extensions": "Is necessary if you want to use the Twig extractor", + "twig/twig": "Is necessary if you want to use the Twig extractor" + }, + "type": "library", + "autoload": { + "psr-4": { + "Gettext\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Oscar Otero", + "email": "oom@oscarotero.com", + "homepage": "http://oscarotero.com", + "role": "Developer" + } + ], + "description": "PHP gettext manager", + "homepage": "https://github.com/oscarotero/Gettext", + "keywords": [ + "JS", + "gettext", + "i18n", + "mo", + "po", + "translation" + ], + "time": "2017-08-09T16:59:46+00:00" + }, + { + "name": "gettext/languages", + "version": "2.3.0", + "source": { + "type": "git", + "url": "https://github.com/mlocati/cldr-to-gettext-plural-rules.git", + "reference": "49c39e51569963cc917a924b489e7025bfb9d8c7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mlocati/cldr-to-gettext-plural-rules/zipball/49c39e51569963cc917a924b489e7025bfb9d8c7", + "reference": "49c39e51569963cc917a924b489e7025bfb9d8c7", + "shasum": "" + }, + "require": { + "php": ">=5.3" + }, + "require-dev": { + "phpunit/phpunit": "^4" + }, + "bin": [ + "bin/export-plural-rules", + "bin/export-plural-rules.php" + ], + "type": "library", + "autoload": { + "psr-4": { + "Gettext\\Languages\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Michele Locati", + "email": "mlocati@gmail.com", + "role": "Developer" + } + ], + "description": "gettext languages with plural rules", + "homepage": "https://github.com/mlocati/cldr-to-gettext-plural-rules", + "keywords": [ + "cldr", + "i18n", + "internationalization", + "l10n", + "language", + "languages", + "localization", + "php", + "plural", + "plural rules", + "plurals", + "translate", + "translations", + "unicode" + ], + "time": "2017-03-23T17:02:28+00:00" + }, { "name": "katzgrau/klogger", "version": "1.2.1", @@ -686,16 +809,16 @@ }, { "name": "phpdocumentor/reflection-docblock", - "version": "3.2.1", + "version": "3.2.2", "source": { "type": "git", "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", - "reference": "183824db76118b9dddffc7e522b91fa175f75119" + "reference": "4aada1f93c72c35e22fb1383b47fee43b8f1d157" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/183824db76118b9dddffc7e522b91fa175f75119", - "reference": "183824db76118b9dddffc7e522b91fa175f75119", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/4aada1f93c72c35e22fb1383b47fee43b8f1d157", + "reference": "4aada1f93c72c35e22fb1383b47fee43b8f1d157", "shasum": "" }, "require": { @@ -727,7 +850,7 @@ } ], "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", - "time": "2017-08-04T20:55:59+00:00" + "time": "2017-08-08T06:39:58+00:00" }, { "name": "phpdocumentor/type-resolver", @@ -1875,20 +1998,20 @@ }, { "name": "symfony/config", - "version": "v3.3.6", + "version": "v3.3.8", "source": { "type": "git", "url": "https://github.com/symfony/config.git", - "reference": "54ee12b0dd60f294132cabae6f5da9573d2e5297" + "reference": "6ac0cc1f047c1dbc058fc25b7a4d91b068ed4488" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/config/zipball/54ee12b0dd60f294132cabae6f5da9573d2e5297", - "reference": "54ee12b0dd60f294132cabae6f5da9573d2e5297", + "url": "https://api.github.com/repos/symfony/config/zipball/6ac0cc1f047c1dbc058fc25b7a4d91b068ed4488", + "reference": "6ac0cc1f047c1dbc058fc25b7a4d91b068ed4488", "shasum": "" }, "require": { - "php": ">=5.5.9", + "php": "^5.5.9|>=7.0.8", "symfony/filesystem": "~2.8|~3.0" }, "conflict": { @@ -1933,20 +2056,20 @@ ], "description": "Symfony Config Component", "homepage": "https://symfony.com", - "time": "2017-07-19T07:37:29+00:00" + "time": "2017-08-03T08:59:45+00:00" }, { "name": "symfony/console", - "version": "v2.8.26", + "version": "v2.8.27", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "32a3c6b3398de5db8ed381f4ef92970c59c2fcdd" + "reference": "c0807a2ca978e64d8945d373a9221a5c35d1a253" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/32a3c6b3398de5db8ed381f4ef92970c59c2fcdd", - "reference": "32a3c6b3398de5db8ed381f4ef92970c59c2fcdd", + "url": "https://api.github.com/repos/symfony/console/zipball/c0807a2ca978e64d8945d373a9221a5c35d1a253", + "reference": "c0807a2ca978e64d8945d373a9221a5c35d1a253", "shasum": "" }, "require": { @@ -1994,7 +2117,7 @@ ], "description": "Symfony Console Component", "homepage": "https://symfony.com", - "time": "2017-07-29T21:26:04+00:00" + "time": "2017-08-27T14:29:03+00:00" }, { "name": "symfony/debug", @@ -2055,20 +2178,20 @@ }, { "name": "symfony/dependency-injection", - "version": "v3.3.6", + "version": "v3.3.8", "source": { "type": "git", "url": "https://github.com/symfony/dependency-injection.git", - "reference": "8d70987f991481e809c63681ffe8ce3f3fde68a0" + "reference": "2ac658972626c75cbde7b0067c84b988170a6907" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/8d70987f991481e809c63681ffe8ce3f3fde68a0", - "reference": "8d70987f991481e809c63681ffe8ce3f3fde68a0", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/2ac658972626c75cbde7b0067c84b988170a6907", + "reference": "2ac658972626c75cbde7b0067c84b988170a6907", "shasum": "" }, "require": { - "php": ">=5.5.9", + "php": "^5.5.9|>=7.0.8", "psr/container": "^1.0" }, "conflict": { @@ -2121,24 +2244,24 @@ ], "description": "Symfony DependencyInjection Component", "homepage": "https://symfony.com", - "time": "2017-07-28T15:27:31+00:00" + "time": "2017-08-28T22:20:37+00:00" }, { "name": "symfony/filesystem", - "version": "v3.3.6", + "version": "v3.3.8", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "427987eb4eed764c3b6e38d52a0f87989e010676" + "reference": "b32a0e5f928d0fa3d1dd03c78d020777e50c10cb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/427987eb4eed764c3b6e38d52a0f87989e010676", - "reference": "427987eb4eed764c3b6e38d52a0f87989e010676", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/b32a0e5f928d0fa3d1dd03c78d020777e50c10cb", + "reference": "b32a0e5f928d0fa3d1dd03c78d020777e50c10cb", "shasum": "" }, "require": { - "php": ">=5.5.9" + "php": "^5.5.9|>=7.0.8" }, "type": "library", "extra": { @@ -2170,24 +2293,24 @@ ], "description": "Symfony Filesystem Component", "homepage": "https://symfony.com", - "time": "2017-07-11T07:17:58+00:00" + "time": "2017-07-29T21:54:42+00:00" }, { "name": "symfony/finder", - "version": "v3.3.6", + "version": "v3.3.8", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "baea7f66d30854ad32988c11a09d7ffd485810c4" + "reference": "b2260dbc80f3c4198f903215f91a1ac7fe9fe09e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/baea7f66d30854ad32988c11a09d7ffd485810c4", - "reference": "baea7f66d30854ad32988c11a09d7ffd485810c4", + "url": "https://api.github.com/repos/symfony/finder/zipball/b2260dbc80f3c4198f903215f91a1ac7fe9fe09e", + "reference": "b2260dbc80f3c4198f903215f91a1ac7fe9fe09e", "shasum": "" }, "require": { - "php": ">=5.5.9" + "php": "^5.5.9|>=7.0.8" }, "type": "library", "extra": { @@ -2219,20 +2342,20 @@ ], "description": "Symfony Finder Component", "homepage": "https://symfony.com", - "time": "2017-06-01T21:01:25+00:00" + "time": "2017-07-29T21:54:42+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "v1.4.0", + "version": "v1.5.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "f29dca382a6485c3cbe6379f0c61230167681937" + "reference": "7c8fae0ac1d216eb54349e6a8baa57d515fe8803" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/f29dca382a6485c3cbe6379f0c61230167681937", - "reference": "f29dca382a6485c3cbe6379f0c61230167681937", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/7c8fae0ac1d216eb54349e6a8baa57d515fe8803", + "reference": "7c8fae0ac1d216eb54349e6a8baa57d515fe8803", "shasum": "" }, "require": { @@ -2244,7 +2367,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.4-dev" + "dev-master": "1.5-dev" } }, "autoload": { @@ -2278,24 +2401,24 @@ "portable", "shim" ], - "time": "2017-06-09T14:24:12+00:00" + "time": "2017-06-14T15:44:48+00:00" }, { "name": "symfony/yaml", - "version": "v3.3.6", + "version": "v3.3.8", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "ddc23324e6cfe066f3dd34a37ff494fa80b617ed" + "reference": "1d8c2a99c80862bdc3af94c1781bf70f86bccac0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/ddc23324e6cfe066f3dd34a37ff494fa80b617ed", - "reference": "ddc23324e6cfe066f3dd34a37ff494fa80b617ed", + "url": "https://api.github.com/repos/symfony/yaml/zipball/1d8c2a99c80862bdc3af94c1781bf70f86bccac0", + "reference": "1d8c2a99c80862bdc3af94c1781bf70f86bccac0", "shasum": "" }, "require": { - "php": ">=5.5.9" + "php": "^5.5.9|>=7.0.8" }, "require-dev": { "symfony/console": "~2.8|~3.0" @@ -2333,7 +2456,7 @@ ], "description": "Symfony Yaml Component", "homepage": "https://symfony.com", - "time": "2017-07-23T12:43:26+00:00" + "time": "2017-07-29T21:54:42+00:00" }, { "name": "theseer/fdomdocument", diff --git a/inc/languages/fr/LC_MESSAGES/shaarli.mo b/inc/languages/fr/LC_MESSAGES/shaarli.mo new file mode 100644 index 0000000000000000000000000000000000000000..d6b195da781c5e93bc2f2db138f449939003614a GIT binary patch literal 22943 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 QMYW!b;$eIh>=nWP17x~i3jhEB literal 0 HcmV?d00001 diff --git a/inc/languages/fr/LC_MESSAGES/shaarli.po b/inc/languages/fr/LC_MESSAGES/shaarli.po new file mode 100644 index 0000000..8763581 --- /dev/null +++ b/inc/languages/fr/LC_MESSAGES/shaarli.po @@ -0,0 +1,1243 @@ +msgid "" +msgstr "" +"Project-Id-Version: Shaarli\n" +"POT-Creation-Date: 2017-05-20 13:54+0200\n" +"PO-Revision-Date: 2017-05-20 14:11+0200\n" +"Last-Translator: \n" +"Language-Team: Shaarli\n" +"Language: fr_FR\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: Poedit 2.0.1\n" +"X-Poedit-Basepath: ../../../..\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"X-Poedit-SourceCharset: UTF-8\n" +"X-Poedit-KeywordsList: t:1,2;t\n" +"X-Poedit-SearchPath-0: .\n" + +#: application/ApplicationUtils.php:152 +#, php-format +msgid "" +"Your PHP version is obsolete! Shaarli requires at least PHP %s, and thus " +"cannot run. Your PHP version has known security vulnerabilities and should " +"be updated as soon as possible." +msgstr "" +"Votre version de PHP est obsolète ! Shaarli nécessite au moins PHP %s, et ne " +"peut donc pas fonctionner. Votre version de PHP a des failles de sécurités " +"connues et devrait être mise à jour au plus tôt." + +#: application/ApplicationUtils.php:180 application/ApplicationUtils.php:192 +msgid "directory is not readable" +msgstr "le répertoire n'est pas accessible en lecture" + +#: application/ApplicationUtils.php:195 +msgid "directory is not writable" +msgstr "le répertoire n'est pas accessible en écriture" + +#: application/ApplicationUtils.php:213 +msgid "file is not readable" +msgstr "le fichier n'est pas accessible en lecture" + +#: application/ApplicationUtils.php:216 +msgid "file is not writable" +msgstr "le fichier n'est pas accessible en écriture" + +#: application/Cache.php:16 +#, php-format +msgid "Cannot purge %s: no directory" +msgstr "Impossible de purger %s: le répertoire n'existe pas" + +#: application/FeedBuilder.php:146 +msgid "Direct link" +msgstr "Liens directs" + +#: application/FeedBuilder.php:148 +#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:88 +#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:242 +#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:245 +#: tmp/paper.b91ef64efc3688266305ea9b42e5017e.rtpl.php:88 +msgid "Permalink" +msgstr "Permalien" + +#: application/History.php:158 +msgid "History file isn't readable or writable" +msgstr "Le fichier d'historique n'est pas accessible en lecture ou en écriture" + +#: application/History.php:169 +msgid "Could not parse history file" +msgstr "Format incorrect pour le fichier d'historique" + +#: application/LinkDB.php:136 +msgid "You are not authorized to add a link." +msgstr "Vous n'êtes pas autorisé à ajouter un lien." + +#: application/LinkDB.php:139 +msgid "Internal Error: A link should always have an id and URL." +msgstr "Erreur interne : un lien devrait toujours avoir un ID et une URL." + +#: application/LinkDB.php:142 +msgid "You must specify an integer as a key." +msgstr "Vous devez utiliser un entier comme clé." + +#: application/LinkDB.php:145 +msgid "Array offset and link ID must be equal." +msgstr "La clé du tableau et l'ID du lien doivent être égaux." + +#: application/LinkDB.php:251 +#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:14 +msgid "" +"The personal, minimalist, super-fast, database free, bookmarking service" +msgstr "" +"Le gestionnaire de marque-page personnel, minimaliste, et sans base de " +"données" + +#: application/LinkDB.php:253 +msgid "" +"Welcome to Shaarli! This is your first public bookmark. To edit or delete " +"me, you must first login.\n" +"\n" +"To learn how to use Shaarli, consult the link \"Documentation\" at the " +"bottom of this page.\n" +"\n" +"You use the community supported version of the original Shaarli project, by " +"Sebastien Sauvage." +msgstr "" +"Bienvenue sur Shaarli ! Ceci est votre premier marque-page public. Pour me " +"modifier ou me supprimer, vous devez d'abord vous connecter.\n" +"\n" +"Pour apprendre comment utiliser Shaarli, consultez le lien « Documentation » " +"en bas de page.\n" +"\n" +"Vous utilisez la version supportée par la communauté du projet original " +"Shaarli, de Sébastien Sauvage." + +#: application/LinkDB.php:267 +msgid "My secret stuff... - Pastebin.com" +msgstr "Mes trucs secrets... - Pastebin.com" + +#: application/LinkDB.php:269 +msgid "Shhhh! I'm a private link only YOU can see. You can delete me too." +msgstr "" +"Pssst ! Je suis un lien privé que VOUS êtes le seul à voir. Vous pouvez me " +"supprimer aussi." + +#: application/LinkFilter.php:376 +msgid "The link you are trying to reach does not exist or has been deleted." +msgstr "Le lien que vous essayez de consulter n'existe pas ou a été supprimé." + +#: application/NetscapeBookmarkUtils.php:35 +msgid "Invalid export selection:" +msgstr "Sélection d'export invalide :" + +#: application/NetscapeBookmarkUtils.php:80 +#, php-format +msgid "File %s (%d bytes) " +msgstr "Le fichier %s (%d octets) " + +#: application/NetscapeBookmarkUtils.php:82 +msgid "has an unknown file format. Nothing was imported." +msgstr "a un format inconnu. Rien n'a été importé." + +#: application/NetscapeBookmarkUtils.php:85 +#, php-format +msgid "" +"was successfully processed: %d links imported, %d links overwritten, %d " +"links skipped." +msgstr "" +"a été importé avec succès : %d liens importés, %d liens écrasés, %d liens " +"ignorés." + +#: application/PageBuilder.php:159 +msgid "The page you are trying to reach does not exist or has been deleted." +msgstr "La page que vous essayez de consulter n'existe pas ou a été supprimée." + +#: application/PageBuilder.php:161 +#, fuzzy +#| msgid " 404 Not Found" +msgid "404 Not Found" +msgstr "404 Introuvable" + +#: application/PluginManager.php:243 +#, php-format +msgid "Plugin \"%s\" files not found." +msgstr "Les fichiers de l'extension \"%s\" sont introuvables." + +#: application/Updater.php:76 +msgid "Couldn't retrieve Updater class methods." +msgstr "Impossible de récupérer les méthodes de la classe Updater." + +#: application/Updater.php:500 +msgid "An error occurred while running the update " +msgstr "Une erreur s'est produite lors de l'exécution de la mise à jour " + +#: application/Updater.php:540 +msgid "Updates file path is not set, can't write updates." +msgstr "" +"Le chemin vers le fichier de mise à jour n'est pas défini, impossible " +"d'écrire les mises à jour." + +#: application/Updater.php:545 +msgid "Unable to write updates in " +msgstr "Impossible d'écrire les mises à jour dans " + +#: application/Utils.php:402 tests/UtilsTest.php:398 +msgid "Setting not set" +msgstr "Paramètre non défini" + +#: application/Utils.php:409 tests/UtilsTest.php:396 tests/UtilsTest.php:397 +msgid "Unlimited" +msgstr "Illimité" + +#: application/Utils.php:412 tests/UtilsTest.php:393 tests/UtilsTest.php:394 +#: tests/UtilsTest.php:408 +msgid "B" +msgstr "o" + +#: application/Utils.php:412 tests/UtilsTest.php:387 tests/UtilsTest.php:388 +#: tests/UtilsTest.php:395 +msgid "kiB" +msgstr "ko" + +#: application/Utils.php:412 tests/UtilsTest.php:389 tests/UtilsTest.php:390 +#: tests/UtilsTest.php:406 tests/UtilsTest.php:407 +msgid "MiB" +msgstr "Mo" + +#: application/Utils.php:412 tests/UtilsTest.php:391 tests/UtilsTest.php:392 +msgid "GiB" +msgstr "Go" + +#: application/config/ConfigJson.php:26 +#, php-format +msgid "" +"An error occurred while parsing JSON configuration file (%s): error code #%d" +msgstr "" +"Une erreur s'est produite lors de la lecture du fichier de configuration " +"JSON (%s) : code d'erreur #%d" + +#: application/config/ConfigJson.php:33 +msgid "" +"Please check your JSON syntax (without PHP comment tags) using a JSON lint " +"tool such as " +msgstr "" +"Merci de vérifier la syntaxe JSON (sans les balises de commentaires PHP) en " +"utilisant un validateur de JSON tel que " + +#: application/config/ConfigJson.php:52 application/config/ConfigPhp.php:121 +msgid "" +"Shaarli could not create the config file. Please make sure Shaarli has the " +"right to write in the folder is it installed in." +msgstr "" +"Shaarli n'a pas pu créer le fichier de configuration. Merci de vérifier que " +"Shaarli a les droits d'écriture dans le dossier dans lequel il est installé." + +#: application/config/ConfigManager.php:135 +msgid "Invalid setting key parameter. String expected, got: " +msgstr "Clé de paramétrage invalide. Chaîne de caractères obtenue, attendu :" + +#: application/config/exception/MissingFieldConfigException.php:21 +#, php-format +msgid "Configuration value is required for %s" +msgstr "Le paramètre %s est obligatoire" + +#: application/config/exception/PluginConfigOrderException.php:15 +msgid "An error occurred while trying to save plugins loading order." +msgstr "" +"Une erreur s'est produite lors de la sauvegarde de l'ordre des extensions." + +#: application/config/exception/UnauthorizedConfigException.php:16 +msgid "You are not authorized to alter config." +msgstr "Vous n'êtes pas autorisé à modifier la configuration." + +#: application/exceptions/IOException.php:19 +msgid "Error accessing" +msgstr "Une erreur s'est produite en accédant à" + +#: index.php:48 +msgid "" +"Error: missing Composer dependencies\n" +"\n" +"If you installed Shaarli through Git or using the development branch,\n" +"please refer to the installation documentation to install PHP dependencies " +"using Composer:\n" +msgstr "" +"Erreur : les dépendances Composer sont manquantes\n" +"\n" +"Si vous avez installé Shaarli avec Git ou depuis la branche de " +"développement\n" +"merci de consulter la documentation d'installation pour installer les " +"dépendances Composer :\n" +"\n" + +#: index.php:137 +msgid "Shared links on " +msgstr "Liens partagés sur " + +#: index.php:168 +msgid "Insufficient permissions:" +msgstr "Permissions insuffisantes :" + +#: index.php:415 +msgid "I said: NO. You are banned for the moment. Go away." +msgstr "NON. Vous êtes banni pour le moment. Revenez plus tard." + +#: index.php:479 +msgid "Wrong login/password." +msgstr "Nom d'utilisateur ou mot de passe incorrects." + +#: index.php:1072 +msgid "You are not supposed to change a password on an Open Shaarli." +msgstr "" +"Vous n'êtes pas censé modifier le mot de passe d'un Shaarli en mode ouvert." + +#: index.php:1077 index.php:1118 index.php:1189 index.php:1243 index.php:1350 +msgid "Wrong token." +msgstr "Jeton invalide." + +#: index.php:1082 +msgid "The old password is not correct." +msgstr "L'ancien mot de passe est incorrect." + +#: index.php:1102 +msgid "Your password has been changed" +msgstr "Votre mot de passe a été modifié" + +#: index.php:1153 +msgid "Configuration was saved." +msgstr "La configuration a été sauvegardé." + +#: index.php:1206 +#, php-format +msgid "Tag was removed from %d links." +msgstr "Le tag a été supprimé de %d liens." + +#: index.php:1225 +#, php-format +msgid "Tag was renamed in %d links." +msgstr "Le tag a été renommé dans %d liens." + +#: index.php:1544 +#, php-format +msgid "" +"The file you are trying to upload is probably bigger than what this " +"webserver can accept (%s). Please upload in smaller chunks." +msgstr "" +"Le fichier que vous essayer d'envoyer est probablement plus lourd que ce que " +"le serveur web peut accepter (%s). Merci de l'envoyer en parties plus " +"légères." + +#: index.php:1941 +#, php-format +msgid "" +"
Sessions do not seem to work correctly on your server.
Make sure the " +"variable \"session.save_path\" is set correctly in your PHP config, and that " +"you have write access to it.
It currently points to %s.
On some " +"browsers, accessing your server via a hostname like 'localhost' or any " +"custom hostname without a dot causes cookie storage to fail. We recommend " +"accessing your server via it's IP address or Fully Qualified Domain Name.
" +msgstr "" +"
Les sesssions ne semble pas fonctionner sur ce serveur.
Assurez vous " +"que la variable « session.save_path » est correctement définie dans votre " +"fichier de configuration PHP, et que vous y avez les droits d'écriture." +"
Ce paramètre pointe actuellement sur %s.
Sur certains navigateurs, " +"accéder à votre serveur depuis un nom d'hôte comme « localhost » ou autre " +"nom personnalisé sans point '.' entraine l'échec de la sauvegarde des " +"cookies. Nous vous recommandons d'accéder à votre serveur depuis son adresse " +"IP ou un Fully Qualified Domain Name.
" + +#: index.php:1951 +msgid "Click to try again." +msgstr "Cliquer ici pour réessayer." + +#: plugins/addlink_toolbar/addlink_toolbar.php:29 +msgid "URI" +msgstr "URI" + +#: plugins/addlink_toolbar/addlink_toolbar.php:33 +#: tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:19 +msgid "Add link" +msgstr "Shaare" + +#: plugins/addlink_toolbar/addlink_toolbar.php:50 +msgid "Adds the addlink input on the linklist page." +msgstr "Ajout le formulaire d'ajout de liens sur la page principale." + +#: plugins/archiveorg/archiveorg.php:23 +msgid "View on archive.org" +msgstr "Voir sur archive.org" + +#: plugins/archiveorg/archiveorg.php:36 +msgid "For each link, add an Archive.org icon." +msgstr "Pour chaque lien, ajoute une icône pour Archive.org." + +#: plugins/demo_plugin/demo_plugin.php:443 +msgid "" +"A demo plugin covering all use cases for template designers and plugin " +"developers." +msgstr "" +"Une extension de démonstration couvrant tous les cas d'utilisation pour les " +"designers et les développeurs." + +#: plugins/isso/isso.php:20 +msgid "" +"Isso plugin error: Please define the \"ISSO_SERVER\" setting in the plugin " +"administration page." +msgstr "" +"Erreur de l'extension Isso : Merci de définir le paramètre « ISSO_SERVER » " +"dans la page d'administration des extensions." + +#: plugins/isso/isso.php:63 +msgid "Let visitor comment your shaares on permalinks with Isso." +msgstr "" +"Permet aux visiteurs de commenter vos shaares sur les permaliens avec Isso." + +#: plugins/isso/isso.php:64 +msgid "Isso server URL (without 'http://')" +msgstr "URL du serveur Isso (sans 'http://')" + +#: plugins/markdown/markdown.php:150 +msgid "Description will be rendered with" +msgstr "La description sera générée avec" + +#: plugins/markdown/markdown.php:151 +msgid "Markdown syntax documentation" +msgstr "Documentation sur la syntaxe Markdown" + +#: plugins/markdown/markdown.php:152 +msgid "Markdown syntax" +msgstr "la syntaxe Markdown" + +#: plugins/markdown/markdown.php:311 +msgid "" +"Render shaare description with Markdown syntax.
Warning:\n" +"If your shaared descriptions contained HTML tags before enabling the " +"markdown plugin,\n" +"enabling it might break your page.\n" +"See the README." +msgstr "" +"Utilise la syntaxe Markdown pour la description des liens." +"
Attention :\n" +"Si vous aviez des descriptions contenant du HTML avant d'activer cette " +"extension,\n" +"l'activer pourrait déformer vos pages.\n" +"Voir le README." + +#: plugins/piwik/piwik.php:21 +msgid "" +"Piwik plugin error: Please define PIWIK_URL and PIWIK_SITEID in the plugin " +"administration page." +msgstr "" +"Erreur de l'extension Piwik : Merci de définir les paramètres PIWIK_URL et " +"PIWIK_SITEID dans la page d'administration des extensions." + +#: plugins/piwik/piwik.php:70 +msgid "A plugin that adds Piwik tracking code to Shaarli pages." +msgstr "Ajoute le code de traçage de Piwik sur les pages de Shaarli." + +#: plugins/piwik/piwik.php:71 +msgid "Piwik URL" +msgstr "URL de Piwik" + +#: plugins/piwik/piwik.php:72 +msgid "Piwik site ID" +msgstr "Site ID de Piwik" + +#: plugins/playvideos/playvideos.php:22 +msgid "Video player" +msgstr "Lecteur vidéo" + +#: plugins/playvideos/playvideos.php:25 +msgid "Play Videos" +msgstr "Jouer les vidéos" + +#: plugins/playvideos/playvideos.php:56 +msgid "Add a button in the toolbar allowing to watch all videos." +msgstr "" +"Ajoute un bouton dans la barre de menu pour regarder toutes les vidéos." + +#: plugins/playvideos/youtube_playlist.js:214 +msgid "plugins/playvideos/jquery-1.11.2.min.js" +msgstr "" + +#: plugins/pubsubhubbub/pubsubhubbub.php:69 +#, php-format +msgid "Could not publish to PubSubHubbub: %s" +msgstr "Impossible de publier vers PubSubHubbub : %s" + +#: plugins/pubsubhubbub/pubsubhubbub.php:95 +#, php-format +msgid "Could not post to %s" +msgstr "Impossible de publier vers %s" + +#: plugins/pubsubhubbub/pubsubhubbub.php:99 +#, php-format +msgid "Bad response from the hub %s" +msgstr "Mauvaise réponse du hub %s" + +#: plugins/pubsubhubbub/pubsubhubbub.php:110 +msgid "Enable PubSubHubbub feed publishing." +msgstr "Active la publication de flux vers PubSubHubbub" + +#: plugins/qrcode/qrcode.php:69 plugins/wallabag/wallabag.php:68 +msgid "For each link, add a QRCode icon." +msgstr "Pour chaque liens, ajouter une icône de QRCode." + +#: plugins/wallabag/wallabag.php:21 +msgid "" +"Wallabag plugin error: Please define the \"WALLABAG_URL\" setting in the " +"plugin administration page." +msgstr "" +"Erreur de l'extension Wallabag : Merci de définir le paramètre « " +"WALLABAG_URL » dans la page d'administration des extensions." + +#: plugins/wallabag/wallabag.php:47 +msgid "Save to wallabag" +msgstr "Sauvegarder dans Wallabag" + +#: plugins/wallabag/wallabag.php:69 +msgid "Wallabag API URL" +msgstr "URL de l'API Wallabag " + +#: plugins/wallabag/wallabag.php:70 +msgid "Wallabag API version (1 or 2)" +msgstr "Version de l'API Wallabag (1 ou 2)" + +#: tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:13 +msgid "Shaare a new link" +msgstr "Partager un nouveau lien" + +#: tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16 +msgid "URL or leave empty to post a note" +msgstr "URL ou laisser vide pour créer une note" + +#: tmp/changepassword.b91ef64efc3688266305ea9b42e5017e.rtpl.php:13 +#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:29 +msgid "Change password" +msgstr "Modification du mot de passe" + +#: tmp/changepassword.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16 +msgid "Current password" +msgstr "Mot de passe actuel" + +#: tmp/changepassword.b91ef64efc3688266305ea9b42e5017e.rtpl.php:19 +msgid "New password" +msgstr "Nouveau mot de passe\t" + +#: tmp/changepassword.b91ef64efc3688266305ea9b42e5017e.rtpl.php:23 +msgid "Change" +msgstr "Changer" + +#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:13 +#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:36 +msgid "Manage tags" +msgstr "Gérer les tags" + +#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16 +#: tmp/import.b91ef64efc3688266305ea9b42e5017e.rtpl.php:77 +msgid "Tag" +msgstr "Tag" + +#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:24 +msgid "New name" +msgstr "Nouveau nom" + +#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:31 +msgid "Case sensitive" +msgstr "Sensible à la casse" + +#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:34 +msgid "Rename" +msgstr "Renommer" + +#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:35 +#: tmp/editlink.90100d2eaf5d3705e14b9b4f78ecddc9.rtpl.php:60 +#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:71 +#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:288 +#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:313 +msgid "Delete" +msgstr "Supprimer" + +#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:24 +msgid "Configure" +msgstr "Configurer" + +#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:29 +msgid "title" +msgstr "titre" + +#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:43 +msgid "Home link" +msgstr "Lien vers l'accueil" + +#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:44 +msgid "Default value" +msgstr "Valeur par défaut" + +#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:58 +msgid "Theme" +msgstr "Thème" + +#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:87 +#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:63 +msgid "Timezone" +msgstr "Fuseau horaire" + +#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:88 +msgid "Continent" +msgstr "Continent" + +#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:88 +msgid "City" +msgstr "Ville" + +#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:134 +msgid "Redirector" +msgstr "Redirecteur" + +#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:135 +msgid "e. g." +msgstr "ex :" + +#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:135 +msgid "will mask the HTTP_REFERER" +msgstr "masque le HTTP_REFERER" + +#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:150 +msgid "Disable session cookie hijacking protection" +msgstr "Désactiver la protection contre le détournement de cookies" + +#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:152 +msgid "Check this if you get disconnected or if your IP address changes often" +msgstr "" +"Cocher cette case si vous êtes souvent déconnecté ou si votre adresse IP " +"change souvent" + +#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:169 +msgid "Private links by default" +msgstr "Liens privés par défaut" + +#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:170 +msgid "All new links are private by default" +msgstr "Tous les nouveaux liens sont privés par défaut" + +#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:185 +msgid "RSS direct links" +msgstr "Liens directs dans le flux RSS" + +#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:186 +msgid "Check this to use direct URL instead of permalink in feeds" +msgstr "" +"Cocher cette case pour utiliser des liens directs au lieu des permaliens " +"dans le flux RSS" + +#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:201 +msgid "Hide public links" +msgstr "Cacher les liens publics" + +#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:202 +msgid "Do not show any links if the user is not logged in" +msgstr "N'afficher aucun lien sans être connecté" + +#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:217 +#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:95 +msgid "Check updates" +msgstr "Vérifier les mises à jour" + +#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:218 +#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:97 +msgid "Notify me when a new release is ready" +msgstr "Me notifier lorsqu'une nouvelle version est disponible" + +#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:233 +#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:114 +msgid "Enable REST API" +msgstr "Activer l'API REST" + +#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:234 +#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:115 +msgid "Allow third party software to use Shaarli such as mobile application" +msgstr "" +"Permets aux applications tierces d'utiliser Shaarli, par exemple les " +"applications mobiles" + +#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:249 +msgid "API secret" +msgstr "Clé d'API secrète" + +#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:260 +#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:66 +#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:139 +#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:192 +msgid "Save" +msgstr "Enregistrer" + +#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:15 +#: tmp/paper.b91ef64efc3688266305ea9b42e5017e.rtpl.php:15 +msgid "The Daily Shaarli" +msgstr "Le Quotidien Shaarli" + +#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:17 +#: tmp/paper.b91ef64efc3688266305ea9b42e5017e.rtpl.php:17 +msgid "1 RSS entry per day" +msgstr "1 entrée RSS par jour" + +#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:37 +#: tmp/paper.b91ef64efc3688266305ea9b42e5017e.rtpl.php:37 +msgid "Previous day" +msgstr "Jour précédent" + +#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:44 +#: tmp/paper.b91ef64efc3688266305ea9b42e5017e.rtpl.php:44 +msgid "All links of one day in a single page." +msgstr "Tous les liens d'un jour sur une page." + +#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:51 +#: tmp/paper.b91ef64efc3688266305ea9b42e5017e.rtpl.php:51 +msgid "Next day" +msgstr "Jour suivant" + +#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:13 +#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:26 +msgid "Shaare" +msgstr "Shaare" + +#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:21 +msgid "URL" +msgstr "URL" + +#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:27 +msgid "Title" +msgstr "Titre" + +#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:33 +#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:42 +#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:75 +#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:99 +#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:124 +msgid "Description" +msgstr "Description" + +#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:39 +msgid "Tags" +msgstr "Tags" + +#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:52 +#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:36 +#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:177 +msgid "Private" +msgstr "Privé" + +#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16 +msgid "Export Database" +msgstr "Exporter les données" + +#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:24 +msgid "Selection" +msgstr "Choisir" + +#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:31 +msgid "All" +msgstr "Tous" + +#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:41 +msgid "Public" +msgstr "Publics" + +#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:52 +msgid "Prepend note permalinks with this Shaarli instance's URL" +msgstr "Préfixer les liens de notes avec l'URL de l'instance de Shaarli" + +#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:53 +msgid "Useful to import bookmarks in a web browser" +msgstr "Utile pour importer les marques-pages dans un navigateur" + +#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:65 +msgid "Export" +msgstr "Exporter" + +#: tmp/import.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16 +msgid "Import Database" +msgstr "Importer des données" + +#: tmp/import.b91ef64efc3688266305ea9b42e5017e.rtpl.php:23 +msgid "Maximum size allowed:" +msgstr "Taille maximum autorisée :" + +#: tmp/import.b91ef64efc3688266305ea9b42e5017e.rtpl.php:29 +msgid "Visibility" +msgstr "Visibilité" + +#: tmp/import.b91ef64efc3688266305ea9b42e5017e.rtpl.php:36 +msgid "Use values from the imported file, default to public" +msgstr "" +"Utiliser les valeurs présentes dans le fichier d'import, public par défaut" + +#: tmp/import.b91ef64efc3688266305ea9b42e5017e.rtpl.php:41 +msgid "Import all bookmarks as private" +msgstr "Importer tous les liens comme privés" + +#: tmp/import.b91ef64efc3688266305ea9b42e5017e.rtpl.php:46 +msgid "Import all bookmarks as public" +msgstr "Importer tous les liens comme publics" + +#: tmp/import.b91ef64efc3688266305ea9b42e5017e.rtpl.php:57 +msgid "Overwrite existing bookmarks" +msgstr "Remplacer les liens existants" + +#: tmp/import.b91ef64efc3688266305ea9b42e5017e.rtpl.php:58 +msgid "Duplicates based on URL" +msgstr "Les doublons s'appuient sur les URL" + +#: tmp/import.b91ef64efc3688266305ea9b42e5017e.rtpl.php:72 +msgid "Add default tags" +msgstr "Ajouter des tags par défaut" + +#: tmp/import.b91ef64efc3688266305ea9b42e5017e.rtpl.php:83 +msgid "Import" +msgstr "Importer" + +#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:22 +msgid "Install Shaarli" +msgstr "Installation de Shaarli" + +#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:25 +msgid "It looks like it's the first time you run Shaarli. Please configure it." +msgstr "" +"Il semblerait que ça soit la première fois que vous lancez Shaarli. Merci de " +"le configurer." + +#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:33 +#: tmp/loginform.b91ef64efc3688266305ea9b42e5017e.rtpl.php:30 +#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:149 +msgid "Username" +msgstr "Nom d'utilisateur" + +#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:48 +#: tmp/loginform.b91ef64efc3688266305ea9b42e5017e.rtpl.php:34 +#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:150 +msgid "Password" +msgstr "Mot de passe" + +#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:80 +msgid "Shaarli title" +msgstr "Titre du Shaarli" + +#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:86 +msgid "My links" +msgstr "Mes liens" + +#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:127 +msgid "Install" +msgstr "Installer" + +#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14 +#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:87 +msgid "shaare" +msgid_plural "shaares" +msgstr[0] "shaare" +msgstr[1] "shaares" + +#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:18 +#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:91 +msgid "private link" +msgid_plural "private links" +msgstr[0] "lien privé" +msgstr[1] "liens privés" + +#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:31 +#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:119 +msgid "Search text" +msgstr "Recherche texte" + +#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:43 +#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:131 +msgid "Filter by tag" +msgstr "Filtrer par tag" + +#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:118 +msgid "Nothing found." +msgstr "Aucun résultat." + +#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:126 +#, php-format +msgid "%s result" +msgid_plural "%s results" +msgstr[0] "%s résultat" +msgstr[1] "%s résultats" + +#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:130 +msgid "for" +msgstr "pour" + +#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:137 +msgid "tagged" +msgstr "taggé" + +#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:141 +msgid "Remove tag" +msgstr "Retirer le tag" + +#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:150 +msgid "with status" +msgstr "avec le statut" + +#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:181 +msgid "Edit" +msgstr "Modifier" + +#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:182 +msgid "Fold" +msgstr "Replier" + +#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:245 +msgid "Edited: " +msgstr "Modifié :" + +#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:257 +msgid "permalink" +msgstr "permalien" + +#: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:7 +msgid "Filters" +msgstr "Filtres" + +#: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:12 +msgid "Filter private links" +msgstr "Filtrer par liens privés" + +#: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:63 +msgid "Links per page" +msgstr "Liens par page" + +#: tmp/loginform.b91ef64efc3688266305ea9b42e5017e.rtpl.php:15 +msgid "" +"You have been banned after too many failed login attempts. Try again later." +msgstr "" +"Vous avez été banni après trop d'échec d'authentification. Merci de " +"réessayer plus tard." + +#: tmp/loginform.b91ef64efc3688266305ea9b42e5017e.rtpl.php:28 +#: tmp/loginform.b91ef64efc3688266305ea9b42e5017e.rtpl.php:42 +#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:71 +#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:95 +msgid "Login" +msgstr "Connexion" + +#: tmp/loginform.b91ef64efc3688266305ea9b42e5017e.rtpl.php:39 +#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:153 +msgid "Remember me" +msgstr "Rester connecté" + +#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:14 +msgid "by the Shaarli community" +msgstr "par la communauté Shaarli" + +#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:15 +msgid "Documentation" +msgstr "Documentation" + +#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:31 +msgid "Tools" +msgstr "Outils" + +#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:36 +#: tmp/tag.cloud.b91ef64efc3688266305ea9b42e5017e.rtpl.php:19 +#: tmp/tagcloud.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16 +msgid "Tag cloud" +msgstr "Nuage de tags" + +#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:39 +msgid "Picture wall" +msgstr "Mur d'images" + +#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:42 +msgid "Daily" +msgstr "Quotidien" + +#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:61 +#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:86 +msgid "RSS Feed" +msgstr "Flux RSS" + +#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:66 +#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:102 +msgid "Logout" +msgstr "Déconnexion" + +#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:81 +msgid "Search" +msgstr "Rechercher" + +#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:171 +msgid "is available" +msgstr "est disponible" + +#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:178 +msgid "Error" +msgstr "Erreur" + +#: tmp/picwall.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16 +msgid "Picture Wall" +msgstr "Mur d'images" + +#: tmp/picwall.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16 +msgid "pics" +msgstr "images" + +#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:15 +msgid "You need to enable Javascript to change plugin loading order." +msgstr "" +"Vous devez activer Javascript pour pouvoir modifier l'ordre des extensions." + +#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:26 +#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:22 +msgid "Plugin administration" +msgstr "Administration des extensions" + +#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:29 +msgid "Enabled Plugins" +msgstr "Extensions activées" + +#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:34 +#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:155 +msgid "No plugin enabled." +msgstr "Aucune extension activée." + +#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:40 +#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:73 +msgid "Disable" +msgstr "Désactiver" + +#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:41 +#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:74 +#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:98 +#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:123 +msgid "Name" +msgstr "Nom" + +#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:43 +#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:76 +msgid "Order" +msgstr "Ordre" + +#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:86 +msgid "Disabled Plugins" +msgstr "Extensions désactivées" + +#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:91 +msgid "No plugin disabled." +msgstr "Aucune extension désactivée." + +#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:97 +#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:122 +msgid "Enable" +msgstr "Activer" + +#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:134 +msgid "More plugins available" +msgstr "Plus d'extensions disponibles" + +#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:136 +msgid "in the documentation" +msgstr "dans la documentation" + +#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:150 +msgid "Plugin configuration" +msgstr "Configuration des extensions" + +#: tmp/tag.cloud.b91ef64efc3688266305ea9b42e5017e.rtpl.php:19 +#: tmp/tag.list.b91ef64efc3688266305ea9b42e5017e.rtpl.php:19 +#: tmp/tagcloud.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16 +msgid "tags" +msgstr "tags" + +#: tmp/tag.list.b91ef64efc3688266305ea9b42e5017e.rtpl.php:19 +msgid "Tag list" +msgstr "List des tags" + +#: tmp/tag.sort.cedf684561d925457130839629000a81.rtpl.php:3 +msgid "Sort by:" +msgstr "Trier par :" + +#: tmp/tag.sort.cedf684561d925457130839629000a81.rtpl.php:5 +msgid "Cloud" +msgstr "Nuage" + +#: tmp/tag.sort.cedf684561d925457130839629000a81.rtpl.php:6 +msgid "Most used" +msgstr "Plus utilisés" + +#: tmp/tag.sort.cedf684561d925457130839629000a81.rtpl.php:7 +msgid "Alphabetical" +msgstr "Alphabétique" + +#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14 +msgid "Settings" +msgstr "Paramètres" + +#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16 +msgid "Change Shaarli settings: title, timezone, etc." +msgstr "Changer les paramètres de Shaarli : titre, fuseau horaire, etc." + +#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:17 +msgid "Configure your Shaarli" +msgstr "Conguration de Shaarli" + +#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:21 +msgid "Enable, disable and configure plugins" +msgstr "Activer, désactiver et configurer les extensions" + +#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:28 +msgid "Change your password" +msgstr "Modification du mot de passe" + +#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:35 +msgid "Rename or delete a tag in all links" +msgstr "Rename or delete a tag in all links" + +#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:41 +msgid "" +"Import Netscape HTML bookmarks (as exported from Firefox, Chrome, Opera, " +"delicious...)" +msgstr "" +"Importer des marques pages au format Netscape HTML (comme exportés depuis " +"Firefox, Chrome, Opera, delicious...)" + +#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:42 +msgid "Import links" +msgstr "Importer des liens" + +#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:47 +msgid "" +"Export Netscape HTML bookmarks (which can be imported in Firefox, Chrome, " +"Opera, delicious...)" +msgstr "" +"Exporter les marques pages au format Netscape HTML (comme exportés depuis " +"Firefox, Chrome, Opera, delicious...)" + +#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:48 +msgid "Export database" +msgstr "Exporter les données" + +#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:71 +msgid "" +"Drag one of these button to your bookmarks toolbar or right-click it and " +"\"Bookmark This Link\"" +msgstr "" +"Glisser un de ces bouttons dans votre barre de favoris ou cliquer droit " +"dessus et « Ajouter aux favoris »" + +#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:72 +msgid "then click on the bookmarklet in any page you want to share." +msgstr "" +"puis cliquer sur le marque page depuis un site que vous souhaitez partager." + +#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:76 +#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:95 +msgid "" +"Drag this link to your bookmarks toolbar or right-click it and Bookmark This " +"Link" +msgstr "" +"Glisser ce lien dans votre barre de favoris ou cliquer droit dessus et « " +"Ajouter aux favoris »" + +#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:77 +msgid "then click ✚Shaare link button in any page you want to share" +msgstr "puis cliquer sur ✚Shaare depuis un site que vous souhaitez partager" + +#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:91 +msgid "Shaare link" +msgstr "Shaare" + +#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:96 +msgid "" +"Then click ✚Add Note button anytime to start composing a private Note (text " +"post) to your Shaarli" +msgstr "" +"Puis cliquer sur ✚Add Note pour commencer à rédiger une Note sur Shaarli" + +#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:99 +msgid "Add Note" +msgstr "Ajouter une Note" + +#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:111 +msgid "" +"You need to browse your Shaarli over HTTPS to use this " +"functionality." +msgstr "" +"Vous devez utiliser Shaarli en HTTPS pour utiliser cette " +"fonctionalité." + +#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:116 +msgid "Add to" +msgstr "Ajouter à" + +#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:127 +msgid "3rd party" +msgstr "Applications tierces" + +#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:129 +#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:135 +msgid "Plugin" +msgstr "Extension" + +#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:130 +#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:136 +msgid "plugin" +msgstr "extension" + +#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:157 +msgid "" +"Drag this link to your bookmarks toolbar, or right-click it and choose " +"Bookmark This Link" +msgstr "" +"Glisser ce lien dans votre barre de favoris ou cliquer droit dessus et « " +"Ajouter aux favoris »" + +#~ msgid "Sessions do not seem to work correctly on your server." +#~ msgstr "Les sessions ne semblent " + +#~ msgid "Tag was renamed in " +#~ msgstr "Le tag a été renommé dans " + +#, fuzzy +#~| msgid "My links" +#~ msgid " links" +#~ msgstr "Mes liens" + +#, fuzzy +#~| msgid "" +#~| "Error: missing Composer configuration\n" +#~| "\n" +#~ msgid "Error: missing Composer configuration" +#~ msgstr "" +#~ "Erreur : la configuration Composer est manquante\n" +#~ "\n" + +#, fuzzy +#~| msgid "" +#~| "Shaarli could not create the config file. Please make sure Shaarli has " +#~| "the right to write in the folder is it installed in." +#~ msgid "" +#~ "Shaarli could not create the config file. \n" +#~ " Please make sure Shaarli has the right to write in the " +#~ "folder is it installed in." +#~ msgstr "" +#~ "Shaarli n'a pas pu créer le fichier de configuration. Merci de vérifier " +#~ "que Shaarli a les droits d'écriture dans le dossier dans lequel il est " +#~ "installé." + +#, fuzzy +#~| msgid "Plugin" +#~ msgid "Plugin \"" +#~ msgstr "Extension" + +#~ msgid "Your PHP version is obsolete!" +#~ msgstr "Votre version de PHP est obsolète !" + +#~ msgid " Shaarli requires at least PHP " +#~ msgstr "Shaarli nécessite au moins PHP" diff --git a/index.php b/index.php index 4068a82..98171d7 100644 --- a/index.php +++ b/index.php @@ -64,7 +64,6 @@ require_once 'application/FeedBuilder.php'; require_once 'application/FileUtils.php'; require_once 'application/History.php'; require_once 'application/HttpUtils.php'; -require_once 'application/Languages.php'; require_once 'application/LinkDB.php'; require_once 'application/LinkFilter.php'; require_once 'application/LinkUtils.php'; @@ -76,6 +75,7 @@ require_once 'application/Utils.php'; require_once 'application/PluginManager.php'; require_once 'application/Router.php'; require_once 'application/Updater.php'; +use \Shaarli\Languages; use \Shaarli\ThemeUtils; use \Shaarli\Config\ConfigManager; @@ -121,8 +121,16 @@ if (isset($_COOKIE['shaarli']) && !is_session_id_valid($_COOKIE['shaarli'])) { } $conf = new ConfigManager(); + +// Sniff browser language and set date format accordingly. +if (isset($_SERVER['HTTP_ACCEPT_LANGUAGE'])) { + autoLocale($_SERVER['HTTP_ACCEPT_LANGUAGE']); +} + +new Languages(setlocale(LC_MESSAGES, 0), $conf); + $conf->setEmpty('general.timezone', date_default_timezone_get()); -$conf->setEmpty('general.title', 'Shared links on '. escape(index_url($_SERVER))); +$conf->setEmpty('general.title', t('Shared links on '). escape(index_url($_SERVER))); RainTPL::$tpl_dir = $conf->get('resource.raintpl_tpl').'/'.$conf->get('resource.theme').'/'; // template directory RainTPL::$cache_dir = $conf->get('resource.raintpl_tmp'); // cache directory @@ -144,7 +152,7 @@ if (! is_file($conf->getConfigFileExt())) { $errors = ApplicationUtils::checkResourcePermissions($conf); if ($errors != array()) { - $message = '

Insufficient permissions:

    '; + $message = '

    '. t('Insufficient permissions:') .'

      '; foreach ($errors as $error) { $message .= '
    • '.$error.'
    • '; @@ -163,11 +171,6 @@ if (! is_file($conf->getConfigFileExt())) { // a token depending of deployment salt, user password, and the current ip define('STAY_SIGNED_IN_TOKEN', sha1($conf->get('credentials.hash') . $_SERVER['REMOTE_ADDR'] . $conf->get('credentials.salt'))); -// Sniff browser language and set date format accordingly. -if (isset($_SERVER['HTTP_ACCEPT_LANGUAGE'])) { - autoLocale($_SERVER['HTTP_ACCEPT_LANGUAGE']); -} - /** * Checking session state (i.e. is the user still logged in) * @@ -376,7 +379,7 @@ function ban_canLogin($conf) // Process login form: Check if login/password is correct. if (isset($_POST['login'])) { - if (!ban_canLogin($conf)) die('I said: NO. You are banned for the moment. Go away.'); + if (!ban_canLogin($conf)) die(t('I said: NO. You are banned for the moment. Go away.')); if (isset($_POST['password']) && tokenOk($_POST['token']) && (check_auth($_POST['login'], $_POST['password'], $conf)) @@ -440,7 +443,8 @@ if (isset($_POST['login'])) } } } - echo ''; // Redirect to login screen. + // Redirect to login screen. + echo ''; exit; } } @@ -1100,16 +1104,19 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history) if ($targetPage == Router::$PAGE_CHANGEPASSWORD) { if ($conf->get('security.open_shaarli')) { - die('You are not supposed to change a password on an Open Shaarli.'); + die(t('You are not supposed to change a password on an Open Shaarli.')); } if (!empty($_POST['setpassword']) && !empty($_POST['oldpassword'])) { - if (!tokenOk($_POST['token'])) die('Wrong token.'); // Go away! + if (!tokenOk($_POST['token'])) die(t('Wrong token.')); // Go away! // Make sure old password is correct. $oldhash = sha1($_POST['oldpassword'].$conf->get('credentials.login').$conf->get('credentials.salt')); - if ($oldhash!= $conf->get('credentials.hash')) { echo ''; exit; } + if ($oldhash!= $conf->get('credentials.hash')) { + echo ''; + exit; + } // Save new password // Salt renders rainbow-tables attacks useless. $conf->set('credentials.salt', sha1(uniqid('', true) .'_'. mt_rand())); @@ -1127,7 +1134,7 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history) echo ''; exit; } - echo ''; + echo ''; exit; } else // show the change password form. @@ -1143,7 +1150,7 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history) if (!empty($_POST['title']) ) { if (!tokenOk($_POST['token'])) { - die('Wrong token.'); // Go away! + die(t('Wrong token.')); // Go away! } $tz = 'UTC'; if (!empty($_POST['continent']) && !empty($_POST['city']) @@ -1178,7 +1185,7 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history) echo ''; exit; } - echo ''; + echo ''; exit; } else // Show the configuration form. @@ -1215,7 +1222,7 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history) } if (!tokenOk($_POST['token'])) { - die('Wrong token.'); + die(t('Wrong token.')); } $alteredLinks = $LINKSDB->renameTag(escape($_POST['fromtag']), escape($_POST['totag'])); @@ -1244,7 +1251,7 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history) { // Go away! if (! tokenOk($_POST['token'])) { - die('Wrong token.'); + die(t('Wrong token.')); } // lf_id should only be present if the link exists. @@ -1344,7 +1351,7 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history) if ($targetPage == Router::$PAGE_DELETELINK) { if (! tokenOk($_GET['token'])) { - die('Wrong token.'); + die(t('Wrong token.')); } $ids = trim($_GET['lf_linkdate']); @@ -1550,11 +1557,14 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history) // Import bookmarks from an uploaded file if (isset($_FILES['filetoupload']['size']) && $_FILES['filetoupload']['size'] == 0) { // The file is too big or some form field may be missing. - echo ''; + $msg = sprintf( + t( + 'The file you are trying to upload is probably bigger than what this webserver can accept' + .' (%s). Please upload in smaller chunks.' + ), + get_max_upload_size(ini_get('post_max_size'), ini_get('upload_max_filesize')) + ); + echo ''; exit; } if (! tokenOk($_POST['token'])) { @@ -1962,12 +1972,20 @@ function install($conf) // (Because on some hosts, session.save_path may not be set correctly, // or we may not have write access to it.) if (isset($_GET['test_session']) && ( !isset($_SESSION) || !isset($_SESSION['session_tested']) || $_SESSION['session_tested']!='Working')) - { // Step 2: Check if data in session is correct. - echo '
      Sessions do not seem to work correctly on your server.
      '; - echo 'Make sure the variable session.save_path is set correctly in your php config, and that you have write access to it.
      '; - echo 'It currently points to '.session_save_path().'
      '; - echo 'Check that the hostname used to access Shaarli contains a dot. On some browsers, accessing your server via a hostname like \'localhost\' or any custom hostname without a dot causes cookie storage to fail. We recommend accessing your server via it\'s IP address or Fully Qualified Domain Name.
      '; - echo '
      Click to try again.
      '; + { + // Step 2: Check if data in session is correct. + $msg = t( + '
      Sessions do not seem to work correctly on your server.
      '. + 'Make sure the variable "session.save_path" is set correctly in your PHP config, '. + 'and that you have write access to it.
      '. + 'It currently points to %s.
      '. + 'On some browsers, accessing your server via a hostname like \'localhost\' '. + 'or any custom hostname without a dot causes cookie storage to fail. '. + 'We recommend accessing your server via it\'s IP address or Fully Qualified Domain Name.
      ' + ); + $msg = sprintf($msg, session_save_path()); + echo $msg; + echo '
      '. t('Click to try again.') .'
      '; die; } if (!isset($_SESSION['session_tested'])) diff --git a/plugins/TODO.md b/plugins/TODO.md deleted file mode 100644 index e3313d6..0000000 --- a/plugins/TODO.md +++ /dev/null @@ -1,28 +0,0 @@ -https://github.com/shaarli/Shaarli/issues/181 - Add Disqus or Isso comments box on a permalink page - - * http://posativ.org/isso/ - * install debian package https://packages.debian.org/sid/isso - * configure server http://posativ.org/isso/docs/configuration/server/ - * configure client http://posativ.org/isso/docs/configuration/client/ - * http://posativ.org/isso/docs/quickstart/ and add `` to includes.html template; then add `
      ` in the linklist template where you want the comments (in the linklist_plugins loop for example) - - -Problem: by default, Isso thread ID is guessed from the current url (only one thread per page). -if we want multiple threads on a single page (shaarli linklist), we must use : the `data-isso-id` client config, -with data-isso-id being the permalink of an item. - -`
      ` -`data-isso-id: Set a custom thread id, defaults to current URI.` - -Problem: feature is currently broken https://github.com/posativ/isso/issues/27 - -Another option, only display isso threads when current URL is a permalink (`\?(A-Z|a-z|0-9|-){7}`) (only show thread -when displaying only this link), and just display a "comments" button on each linklist item. Optionally show the comment -count on each item using the API (http://posativ.org/isso/docs/extras/api/#get-comment-count). API requests can be done -by raintpl `{function` or client-side with js. The former should be faster if isso and shaarli are on ther same server. - -Showing all full isso threads in the linklist would destroy layout - ------------------------------------------------------------ - -http://www.git-attitude.fr/2014/11/04/git-rerere/ for the merge diff --git a/plugins/addlink_toolbar/addlink_toolbar.php b/plugins/addlink_toolbar/addlink_toolbar.php index ddf50aa..8c05a23 100644 --- a/plugins/addlink_toolbar/addlink_toolbar.php +++ b/plugins/addlink_toolbar/addlink_toolbar.php @@ -26,11 +26,11 @@ function hook_addlink_toolbar_render_header($data) array( 'type' => 'text', 'name' => 'post', - 'placeholder' => 'URI', + 'placeholder' => t('URI'), ), array( 'type' => 'submit', - 'value' => 'Add link', + 'value' => t('Add link'), 'class' => 'bigbutton', ), ), @@ -40,3 +40,12 @@ function hook_addlink_toolbar_render_header($data) return $data; } + +/** + * This function is never called, but contains translation calls for GNU gettext extraction. + */ +function addlink_toolbar_dummy_translation() +{ + // meta + t('Adds the addlink input on the linklist page.'); +} diff --git a/plugins/archiveorg/archiveorg.html b/plugins/archiveorg/archiveorg.html index 0781fe3..ad501f4 100644 --- a/plugins/archiveorg/archiveorg.html +++ b/plugins/archiveorg/archiveorg.html @@ -1 +1,5 @@ -archive.org + + + archive.org + + diff --git a/plugins/archiveorg/archiveorg.php b/plugins/archiveorg/archiveorg.php index 03d13d0..cda3575 100644 --- a/plugins/archiveorg/archiveorg.php +++ b/plugins/archiveorg/archiveorg.php @@ -20,9 +20,18 @@ function hook_archiveorg_render_linklist($data) if($value['private'] && preg_match('/^\?[a-zA-Z0-9-_@]{6}($|&|#)/', $value['real_url'])) { continue; } - $archive = sprintf($archive_html, $value['url']); + $archive = sprintf($archive_html, $value['url'], t('View on archive.org')); $value['link_plugin'][] = $archive; } return $data; } + +/** + * This function is never called, but contains translation calls for GNU gettext extraction. + */ +function archiveorg_dummy_translation() +{ + // meta + t('For each link, add an Archive.org icon.'); +} diff --git a/plugins/demo_plugin/demo_plugin.php b/plugins/demo_plugin/demo_plugin.php index 8fdbf66..3a90ae6 100644 --- a/plugins/demo_plugin/demo_plugin.php +++ b/plugins/demo_plugin/demo_plugin.php @@ -433,3 +433,12 @@ function hook_demo_plugin_render_feed($data) } return $data; } + +/** + * This function is never called, but contains translation calls for GNU gettext extraction. + */ +function demo_dummy_translation() +{ + // meta + t('A demo plugin covering all use cases for template designers and plugin developers.'); +} diff --git a/plugins/isso/isso.php b/plugins/isso/isso.php index ce16645..5bc1cce 100644 --- a/plugins/isso/isso.php +++ b/plugins/isso/isso.php @@ -4,10 +4,11 @@ * Plugin Isso. */ +use Shaarli\Config\ConfigManager; + /** * Display an error everywhere if the plugin is enabled without configuration. * - * @param $data array List of links * @param $conf ConfigManager instance * * @return mixed - linklist data with Isso plugin. @@ -16,8 +17,8 @@ function isso_init($conf) { $issoUrl = $conf->get('plugins.ISSO_SERVER'); if (empty($issoUrl)) { - $error = 'Isso plugin error: '. - 'Please define the "ISSO_SERVER" setting in the plugin administration page.'; + $error = t('Isso plugin error: '. + 'Please define the "ISSO_SERVER" setting in the plugin administration page.'); return array($error); } } @@ -52,3 +53,13 @@ function hook_isso_render_linklist($data, $conf) return $data; } + +/** + * This function is never called, but contains translation calls for GNU gettext extraction. + */ +function isso_dummy_translation() +{ + // meta + t('Let visitor comment your shaares on permalinks with Isso.'); + t('Isso server URL (without \'http://\')'); +} diff --git a/plugins/markdown/help.html b/plugins/markdown/help.html index 9c4e5ae..ded3d34 100644 --- a/plugins/markdown/help.html +++ b/plugins/markdown/help.html @@ -1,5 +1,5 @@
      - Description will be rendered with - - Markdown syntax. + %s + + %s.
      diff --git a/plugins/markdown/markdown.php b/plugins/markdown/markdown.php index 772c56e..1531549 100644 --- a/plugins/markdown/markdown.php +++ b/plugins/markdown/markdown.php @@ -154,8 +154,13 @@ function hook_markdown_render_includes($data) function hook_markdown_render_editlink($data) { // Load help HTML into a string - $data['edit_link_plugin'][] = file_get_contents(PluginManager::$PLUGINS_PATH .'/markdown/help.html'); - + $txt = file_get_contents(PluginManager::$PLUGINS_PATH .'/markdown/help.html'); + $translations = [ + t('Description will be rendered with'), + t('Markdown syntax documentation'), + t('Markdown syntax'), + ]; + $data['edit_link_plugin'][] = vsprintf($txt, $translations); // Add no markdown 'meta-tag' in tag list if it was never used, for autocompletion. if (! in_array(NO_MD_TAG, $data['tags'])) { $data['tags'][NO_MD_TAG] = 0; @@ -325,3 +330,15 @@ function process_markdown($description, $escape = true, $allowedProtocols = []) return $processedDescription; } + +/** + * This function is never called, but contains translation calls for GNU gettext extraction. + */ +function markdown_dummy_translation() +{ + // meta + t('Render shaare description with Markdown syntax.
      Warning: +If your shaared descriptions contained HTML tags before enabling the markdown plugin, +enabling it might break your page. +See the README.'); +} diff --git a/plugins/piwik/piwik.php b/plugins/piwik/piwik.php index 4a2b48a..ca00c2b 100644 --- a/plugins/piwik/piwik.php +++ b/plugins/piwik/piwik.php @@ -18,8 +18,8 @@ function piwik_init($conf) $piwikUrl = $conf->get('plugins.PIWIK_URL'); $piwikSiteid = $conf->get('plugins.PIWIK_SITEID'); if (empty($piwikUrl) || empty($piwikSiteid)) { - $error = 'Piwik plugin error: ' . - 'Please define PIWIK_URL and PIWIK_SITEID in the plugin administration page.'; + $error = t('Piwik plugin error: ' . + 'Please define PIWIK_URL and PIWIK_SITEID in the plugin administration page.'); return array($error); } } @@ -60,3 +60,14 @@ function hook_piwik_render_footer($data, $conf) return $data; } + +/** + * This function is never called, but contains translation calls for GNU gettext extraction. + */ +function piwik_dummy_translation() +{ + // meta + t('A plugin that adds Piwik tracking code to Shaarli pages.'); + t('Piwik URL'); + t('Piwik site ID'); +} diff --git a/plugins/playvideos/playvideos.php b/plugins/playvideos/playvideos.php index 6448450..c6d6b0c 100644 --- a/plugins/playvideos/playvideos.php +++ b/plugins/playvideos/playvideos.php @@ -19,10 +19,10 @@ function hook_playvideos_render_header($data) $playvideo = array( 'attr' => array( 'href' => '#', - 'title' => 'Video player', + 'title' => t('Video player'), 'id' => 'playvideos', ), - 'html' => '► Play Videos' + 'html' => '► '. t('Play Videos') ); $data['buttons_toolbar'][] = $playvideo; } @@ -46,3 +46,12 @@ function hook_playvideos_render_footer($data) return $data; } + +/** + * This function is never called, but contains translation calls for GNU gettext extraction. + */ +function playvideos_dummy_translation() +{ + // meta + t('Add a button in the toolbar allowing to watch all videos.'); +} diff --git a/plugins/pubsubhubbub/pubsubhubbub.php b/plugins/pubsubhubbub/pubsubhubbub.php index 03b6757..184b588 100644 --- a/plugins/pubsubhubbub/pubsubhubbub.php +++ b/plugins/pubsubhubbub/pubsubhubbub.php @@ -10,6 +10,7 @@ */ use pubsubhubbub\publisher\Publisher; +use Shaarli\Config\ConfigManager; /** * Plugin init function - set the hub to the default appspot one. @@ -65,7 +66,7 @@ function hook_pubsubhubbub_save_link($data, $conf) $p = new Publisher($conf->get('plugins.PUBSUBHUB_URL')); $p->publish_update($feeds, $httpPost); } catch (Exception $e) { - error_log('Could not publish to PubSubHubbub: ' . $e->getMessage()); + error_log(sprintf(t('Could not publish to PubSubHubbub: %s'), $e->getMessage())); } return $data; @@ -91,11 +92,20 @@ function nocurl_http_post($url, $postString) { $context = stream_context_create($params); $fp = @fopen($url, 'rb', false, $context); if (!$fp) { - throw new Exception('Could not post to '. $url); + throw new Exception(sprintf(t('Could not post to %s'), $url)); } $response = @stream_get_contents($fp); if ($response === false) { - throw new Exception('Bad response from the hub '. $url); + throw new Exception(sprintf(t('Bad response from the hub %s'), $url)); } return $response; } + +/** + * This function is never called, but contains translation calls for GNU gettext extraction. + */ +function pubsubhubbub_dummy_translation() +{ + // meta + t('Enable PubSubHubbub feed publishing.'); +} diff --git a/plugins/qrcode/qrcode.meta b/plugins/qrcode/qrcode.meta index cbf371e..1812cd2 100644 --- a/plugins/qrcode/qrcode.meta +++ b/plugins/qrcode/qrcode.meta @@ -1 +1 @@ -description="For each link, add a QRCode icon ." +description="For each link, add a QRCode icon." diff --git a/plugins/qrcode/qrcode.php b/plugins/qrcode/qrcode.php index 8bc610d..0f96a10 100644 --- a/plugins/qrcode/qrcode.php +++ b/plugins/qrcode/qrcode.php @@ -59,3 +59,12 @@ function hook_qrcode_render_includes($data) return $data; } + +/** + * This function is never called, but contains translation calls for GNU gettext extraction. + */ +function qrcode_dummy_translation() +{ + // meta + t('For each link, add a QRCode icon.'); +} diff --git a/plugins/wallabag/wallabag.html b/plugins/wallabag/wallabag.html index e861536..4c57691 100644 --- a/plugins/wallabag/wallabag.html +++ b/plugins/wallabag/wallabag.html @@ -1 +1,5 @@ -wallabag + + + wallabag + + diff --git a/plugins/wallabag/wallabag.php b/plugins/wallabag/wallabag.php index 641e4cc..9dfd079 100644 --- a/plugins/wallabag/wallabag.php +++ b/plugins/wallabag/wallabag.php @@ -5,6 +5,7 @@ */ require_once 'WallabagInstance.php'; +use Shaarli\Config\ConfigManager; /** * Init function, return an error if the server is not set. @@ -17,8 +18,8 @@ function wallabag_init($conf) { $wallabagUrl = $conf->get('plugins.WALLABAG_URL'); if (empty($wallabagUrl)) { - $error = 'Wallabag plugin error: '. - 'Please define the "WALLABAG_URL" setting in the plugin administration page.'; + $error = t('Wallabag plugin error: '. + 'Please define the "WALLABAG_URL" setting in the plugin administration page.'); return array($error); } } @@ -43,12 +44,14 @@ function hook_wallabag_render_linklist($data, $conf) $wallabagHtml = file_get_contents(PluginManager::$PLUGINS_PATH . '/wallabag/wallabag.html'); + $linkTitle = t('Save to wallabag'); foreach ($data['links'] as &$value) { $wallabag = sprintf( $wallabagHtml, $wallabagInstance->getWallabagUrl(), urlencode($value['url']), - PluginManager::$PLUGINS_PATH + PluginManager::$PLUGINS_PATH, + $linkTitle ); $value['link_plugin'][] = $wallabag; } @@ -56,3 +59,14 @@ function hook_wallabag_render_linklist($data, $conf) return $data; } +/** + * This function is never called, but contains translation calls for GNU gettext extraction. + */ +function wallabag_dummy_translation() +{ + // meta + t('For each link, add a QRCode icon.'); + t('Wallabag API URL'); + t('Wallabag API version (1 or 2)'); +} + diff --git a/tests/LanguagesTest.php b/tests/LanguagesTest.php index 79c136c..46bfcd7 100644 --- a/tests/LanguagesTest.php +++ b/tests/LanguagesTest.php @@ -1,41 +1,201 @@ conf = new ConfigManager(self::$configFile); + } + /** * Test t() with a simple non identified value. */ - public function testTranslateSingleNotID() + public function testTranslateSingleNotIDGettext() { + $this->conf->set('translation.mode', 'gettext'); + new Languages('en', $this->conf); $text = 'abcdé 564 fgK'; $this->assertEquals($text, t($text)); } /** - * Test t() with a non identified plural form. + * Test t() with a simple identified value in gettext mode. */ - public function testTranslatePluralNotID() + public function testTranslateSingleIDGettext() { - $text = '%s sandwich'; - $nText = '%s sandwiches'; - $this->assertEquals('0 sandwich', t($text, $nText)); - $this->assertEquals('1 sandwich', t($text, $nText, 1)); - $this->assertEquals('2 sandwiches', t($text, $nText, 2)); + $this->conf->set('translation.mode', 'gettext'); + new Languages('en', $this->conf); + $text = 'permalink'; + $this->assertEquals($text, t($text)); } /** - * Test t() with a non identified invalid plural form. + * Test t() with a non identified plural form in gettext mode. */ - public function testTranslatePluralNotIDInvalid() + public function testTranslatePluralNotIDGettext() { + $this->conf->set('translation.mode', 'gettext'); + new Languages('en', $this->conf); $text = 'sandwich'; $nText = 'sandwiches'; + $this->assertEquals('sandwiches', t($text, $nText, 0)); $this->assertEquals('sandwich', t($text, $nText, 1)); $this->assertEquals('sandwiches', t($text, $nText, 2)); } + + /** + * Test t() with an identified plural form in gettext mode. + */ + public function testTranslatePluralIDGettext() + { + $this->conf->set('translation.mode', 'gettext'); + new Languages('en', $this->conf); + $text = 'shaare'; + $nText = 'shaares'; + // In english, zero is followed by plural form + $this->assertEquals('shaares', t($text, $nText, 0)); + $this->assertEquals('shaare', t($text, $nText, 1)); + $this->assertEquals('shaares', t($text, $nText, 2)); + } + + /** + * Test t() with a simple non identified value. + */ + public function testTranslateSingleNotIDPhp() + { + $this->conf->set('translation.mode', 'php'); + new Languages('en', $this->conf); + $text = 'abcdé 564 fgK'; + $this->assertEquals($text, t($text)); + } + + /** + * Test t() with a simple identified value in PHP mode. + */ + public function testTranslateSingleIDPhp() + { + $this->conf->set('translation.mode', 'php'); + new Languages('en', $this->conf); + $text = 'permalink'; + $this->assertEquals($text, t($text)); + } + + /** + * Test t() with a non identified plural form in PHP mode. + */ + public function testTranslatePluralNotIDPhp() + { + $this->conf->set('translation.mode', 'php'); + new Languages('en', $this->conf); + $text = 'sandwich'; + $nText = 'sandwiches'; + $this->assertEquals('sandwiches', t($text, $nText, 0)); + $this->assertEquals('sandwich', t($text, $nText, 1)); + $this->assertEquals('sandwiches', t($text, $nText, 2)); + } + + /** + * Test t() with an identified plural form in PHP mode. + */ + public function testTranslatePluralIDPhp() + { + $this->conf->set('translation.mode', 'php'); + new Languages('en', $this->conf); + $text = 'shaare'; + $nText = 'shaares'; + // In english, zero is followed by plural form + $this->assertEquals('shaares', t($text, $nText, 0)); + $this->assertEquals('shaare', t($text, $nText, 1)); + $this->assertEquals('shaares', t($text, $nText, 2)); + } + + /** + * Test t() with an invalid language set in the configuration in gettext mode. + */ + public function testTranslateWithInvalidConfLanguageGettext() + { + $this->conf->set('translation.mode', 'gettext'); + $this->conf->set('translation.language', 'nope'); + new Languages('fr', $this->conf); + $text = 'grumble'; + $this->assertEquals($text, t($text)); + } + + /** + * Test t() with an invalid language set in the configuration in PHP mode. + */ + public function testTranslateWithInvalidConfLanguagePhp() + { + $this->conf->set('translation.mode', 'php'); + $this->conf->set('translation.language', 'nope'); + new Languages('fr', $this->conf); + $text = 'grumble'; + $this->assertEquals($text, t($text)); + } + + /** + * Test t() with an invalid language set with auto language in gettext mode. + */ + public function testTranslateWithInvalidAutoLanguageGettext() + { + $this->conf->set('translation.mode', 'gettext'); + new Languages('nope', $this->conf); + $text = 'grumble'; + $this->assertEquals($text, t($text)); + } + + /** + * Test t() with an invalid language set with auto language in PHP mode. + */ + public function testTranslateWithInvalidAutoLanguagePhp() + { + $this->conf->set('translation.mode', 'php'); + new Languages('nope', $this->conf); + $text = 'grumble'; + $this->assertEquals($text, t($text)); + } + + /** + * Test t() with an extension language file in gettext mode + */ + public function testTranslationExtensionGettext() + { + $this->conf->set('translation.mode', 'gettext'); + $this->conf->set('translation.extensions.test', 'tests/utils/languages/'); + new Languages('en', $this->conf); + $this->assertEquals('car', t('car', 'car', 1, 'test')); + $this->assertEquals('Search', t('Search', 'Search', 1, 'test')); + } + + /** + * Test t() with an extension language file in PHP mode + */ + public function testTranslationExtensionPhp() + { + $this->conf->set('translation.mode', 'php'); + $this->conf->set('translation.extensions.test', 'tests/utils/languages/'); + new Languages('en', $this->conf); + $this->assertEquals('car', t('car', 'car', 1, 'test')); + $this->assertEquals('Search', t('Search', 'Search', 1, 'test')); + } } diff --git a/tests/UtilsTest.php b/tests/UtilsTest.php index 3d1aa65..840eaf2 100644 --- a/tests/UtilsTest.php +++ b/tests/UtilsTest.php @@ -384,18 +384,18 @@ class UtilsTest extends PHPUnit_Framework_TestCase */ public function testHumanBytes() { - $this->assertEquals('2kiB', human_bytes(2 * 1024)); - $this->assertEquals('2kiB', human_bytes(strval(2 * 1024))); - $this->assertEquals('2MiB', human_bytes(2 * (pow(1024, 2)))); - $this->assertEquals('2MiB', human_bytes(strval(2 * (pow(1024, 2))))); - $this->assertEquals('2GiB', human_bytes(2 * (pow(1024, 3)))); - $this->assertEquals('2GiB', human_bytes(strval(2 * (pow(1024, 3))))); - $this->assertEquals('374B', human_bytes(374)); - $this->assertEquals('374B', human_bytes('374')); - $this->assertEquals('232kiB', human_bytes(237481)); - $this->assertEquals('Unlimited', human_bytes('0')); - $this->assertEquals('Unlimited', human_bytes(0)); - $this->assertEquals('Setting not set', human_bytes('')); + $this->assertEquals('2'. t('kiB'), human_bytes(2 * 1024)); + $this->assertEquals('2'. t('kiB'), human_bytes(strval(2 * 1024))); + $this->assertEquals('2'. t('MiB'), human_bytes(2 * (pow(1024, 2)))); + $this->assertEquals('2'. t('MiB'), human_bytes(strval(2 * (pow(1024, 2))))); + $this->assertEquals('2'. t('GiB'), human_bytes(2 * (pow(1024, 3)))); + $this->assertEquals('2'. t('GiB'), human_bytes(strval(2 * (pow(1024, 3))))); + $this->assertEquals('374'. t('B'), human_bytes(374)); + $this->assertEquals('374'. t('B'), human_bytes('374')); + $this->assertEquals('232'. t('kiB'), human_bytes(237481)); + $this->assertEquals(t('Unlimited'), human_bytes('0')); + $this->assertEquals(t('Unlimited'), human_bytes(0)); + $this->assertEquals(t('Setting not set'), human_bytes('')); } /** @@ -403,9 +403,9 @@ class UtilsTest extends PHPUnit_Framework_TestCase */ public function testGetMaxUploadSize() { - $this->assertEquals('1MiB', get_max_upload_size(2097152, '1024k')); - $this->assertEquals('1MiB', get_max_upload_size('1m', '2m')); - $this->assertEquals('100B', get_max_upload_size(100, 100)); + $this->assertEquals('1'. t('MiB'), get_max_upload_size(2097152, '1024k')); + $this->assertEquals('1'. t('MiB'), get_max_upload_size('1m', '2m')); + $this->assertEquals('100'. t('B'), get_max_upload_size(100, 100)); } /** diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..d36d73c --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,6 @@ +conf = new ConfigManager(self::$configFile); + $this->conf->set('translation.language', 'fr'); + } + + /** + * Reset the locale since gettext seems to mess with it, making it too long + */ + public static function tearDownAfterClass() + { + if (! empty(getenv('UT_LOCALE'))) { + setlocale(LC_ALL, getenv('UT_LOCALE')); + } + } + + /** + * Test t() with a simple non identified value. + */ + public function testTranslateSingleNotIDGettext() + { + $this->conf->set('translation.mode', 'gettext'); + new Languages('en', $this->conf); + $text = 'abcdé 564 fgK'; + $this->assertEquals($text, t($text)); + } + + /** + * Test t() with a simple identified value in gettext mode. + */ + public function testTranslateSingleIDGettext() + { + $this->conf->set('translation.mode', 'gettext'); + new Languages('en', $this->conf); + $text = 'permalink'; + $this->assertEquals('permalien', t($text)); + } + + /** + * Test t() with a non identified plural form in gettext mode. + */ + public function testTranslatePluralNotIDGettext() + { + $this->conf->set('translation.mode', 'gettext'); + new Languages('en', $this->conf); + $text = 'sandwich'; + $nText = 'sandwiches'; + // Not ID, so English fallback, and in english, plural 0 + $this->assertEquals('sandwiches', t($text, $nText, 0)); + $this->assertEquals('sandwich', t($text, $nText, 1)); + $this->assertEquals('sandwiches', t($text, $nText, 2)); + } + + /** + * Test t() with an identified plural form in gettext mode. + */ + public function testTranslatePluralIDGettext() + { + $this->conf->set('translation.mode', 'gettext'); + new Languages('en', $this->conf); + $text = 'shaare'; + $nText = 'shaares'; + $this->assertEquals('shaare', t($text, $nText, 0)); + $this->assertEquals('shaare', t($text, $nText, 1)); + $this->assertEquals('shaares', t($text, $nText, 2)); + } + + /** + * Test t() with a simple non identified value. + */ + public function testTranslateSingleNotIDPhp() + { + $this->conf->set('translation.mode', 'php'); + new Languages('en', $this->conf); + $text = 'abcdé 564 fgK'; + $this->assertEquals($text, t($text)); + } + + /** + * Test t() with a simple identified value in PHP mode. + */ + public function testTranslateSingleIDPhp() + { + $this->conf->set('translation.mode', 'php'); + new Languages('en', $this->conf); + $text = 'permalink'; + $this->assertEquals('permalien', t($text)); + } + + /** + * Test t() with a non identified plural form in PHP mode. + */ + public function testTranslatePluralNotIDPhp() + { + $this->conf->set('translation.mode', 'php'); + new Languages('en', $this->conf); + $text = 'sandwich'; + $nText = 'sandwiches'; + // Not ID, so English fallback, and in english, plural 0 + $this->assertEquals('sandwiches', t($text, $nText, 0)); + $this->assertEquals('sandwich', t($text, $nText, 1)); + $this->assertEquals('sandwiches', t($text, $nText, 2)); + } + + /** + * Test t() with an identified plural form in PHP mode. + */ + public function testTranslatePluralIDPhp() + { + $this->conf->set('translation.mode', 'php'); + new Languages('en', $this->conf); + $text = 'shaare'; + $nText = 'shaares'; + // In english, zero is followed by plural form + $this->assertEquals('shaare', t($text, $nText, 0)); + $this->assertEquals('shaare', t($text, $nText, 1)); + $this->assertEquals('shaares', t($text, $nText, 2)); + } + + /** + * Test t() with an extension language file in gettext mode + */ + public function testTranslationExtensionGettext() + { + $this->conf->set('translation.mode', 'gettext'); + $this->conf->set('translation.extensions.test', 'tests/utils/languages/'); + new Languages('en', $this->conf); + $this->assertEquals('voiture', t('car', 'car', 1, 'test')); + $this->assertEquals('Fouille', t('Search', 'Search', 1, 'test')); + } + + /** + * Test t() with an extension language file in PHP mode + */ + public function testTranslationExtensionPhp() + { + $this->conf->set('translation.mode', 'php'); + $this->conf->set('translation.extensions.test', 'tests/utils/languages/'); + new Languages('en', $this->conf); + $this->assertEquals('voiture', t('car', 'car', 1, 'test')); + $this->assertEquals('Fouille', t('Search', 'Search', 1, 'test')); + } +} diff --git a/tests/utils/languages/fr/LC_MESSAGES/test.mo b/tests/utils/languages/fr/LC_MESSAGES/test.mo new file mode 100644 index 0000000000000000000000000000000000000000..416c783105300fb5ecdfdb2636b060c338b65e55 GIT binary patch literal 456 zcmZvX&rSj{5XS5OWRIRb^r$h#?g~mu@Pe!%LL{&nZ#pgmTeG&L+a>rQzKchn#Ak69 zj9i@bm-#yR+J5~$-2GrwJH!!DC-#UwQPnfCPXJMQGU9fNv7Gt=@kzSsU({)>s`73B zYBol2X~t4;Z0PJOre5?W;sITutx>$Y^k^!{Jr+I~-X)^r5Ijx9HF#7!lHsM04G~Em zo~uUvR7O&gQH*e*tCRou>MFcg`}$CLkvK3#4&FM&gFcv92{RN4!kgmb48Z03Z>;## zJ;kG7&>M6&F~gb+I@VBDy6t^Vu{j>PPCaR z-h9#Y$Gmiqi`criLUA)+ZHso);%nJHHvEc*C#~R7+@L|X4nDLv(Vj;&SqlRe+fwH_ LvW3;YbjW@H<$QQq literal 0 HcmV?d00001 diff --git a/tests/utils/languages/fr/LC_MESSAGES/test.po b/tests/utils/languages/fr/LC_MESSAGES/test.po new file mode 100644 index 0000000..89a4fd9 --- /dev/null +++ b/tests/utils/languages/fr/LC_MESSAGES/test.po @@ -0,0 +1,19 @@ +msgid "" +msgstr "" +"Project-Id-Version: Extension test\n" +"POT-Creation-Date: 2017-05-20 13:54+0200\n" +"PO-Revision-Date: 2017-05-20 14:16+0200\n" +"Last-Translator: \n" +"Language-Team: Shaarli\n" +"Language: fr_FR\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"X-Generator: Poedit 2.0.1\n" + +msgid "car" +msgstr "voiture" + +msgid "Search" +msgstr "Fouille" diff --git a/tpl/default/import.html b/tpl/default/import.html index 1f04068..000a50a 100644 --- a/tpl/default/import.html +++ b/tpl/default/import.html @@ -18,7 +18,7 @@
      -


      Maximum size allowed: {$maxfilesizeHuman}

      +


      {'Maximum size allowed:'|t} {$maxfilesizeHuman}

      @@ -31,15 +31,15 @@
      - Use values from the imported file, default to public + {'Use values from the imported file, default to public'|t}
      - Import all bookmarks as private + {'Import all bookmarks as private'|t}
      - Import all bookmarks as public + {'Import all bookmarks as public'|t}
      diff --git a/tpl/default/linklist.html b/tpl/default/linklist.html index 685821e..5dab8e9 100644 --- a/tpl/default/linklist.html +++ b/tpl/default/linklist.html @@ -86,7 +86,7 @@
      - {function="t('%s result', '%s results', $result_count)"} + {function="sprintf(t('%s result', '%s results', $result_count), $result_count)"} {if="!empty($search_term)"} {'for'|t} {$search_term} {/if} @@ -117,6 +117,16 @@
      + {ignore}Set translation here, for performances{/ignore} + {$strPrivate=t('Private')} + {$strEdit=t('Edit')} + {$strDelete=t('Delete')} + {$strFold=t('Fold')} + {$strEdited=t('Edited: ')} + {$strPermalink=t('Permalink')} + {$strPermalinkLc=t('permalink')} + {$strAddTag=t('Add tag')} + {ignore}End of translations{/ignore} {loop="links"}
      +
      +
      +
      + +
      +
      +
      +
      + +
      +
      +
      diff --git a/tpl/default/install.html b/tpl/default/install.html index 164d453..6199b33 100644 --- a/tpl/default/install.html +++ b/tpl/default/install.html @@ -65,6 +65,27 @@
      +
      +
      +
      + +
      +
      +
      +
      + +
      +
      +
      +
      From 6a65bc579810e3688a63a7c3b0e720dc0f5456b0 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Sat, 19 Aug 2017 10:53:19 +0200 Subject: [PATCH 17/77] Translations : Working demo example of translation extension --- application/Utils.php | 2 +- inc/languages/fr/LC_MESSAGES/shaarli.po | 70 +++++++++--------- plugins/demo_plugin/demo_plugin.php | 28 ++++++- .../languages/fr/LC_MESSAGES/demo.mo | Bin 0 -> 652 bytes .../languages/fr/LC_MESSAGES/demo.po | 21 ++++++ 5 files changed, 86 insertions(+), 35 deletions(-) create mode 100644 plugins/demo_plugin/languages/fr/LC_MESSAGES/demo.mo create mode 100644 plugins/demo_plugin/languages/fr/LC_MESSAGES/demo.po diff --git a/application/Utils.php b/application/Utils.php index 27eaafc..2f38a8d 100644 --- a/application/Utils.php +++ b/application/Utils.php @@ -480,7 +480,7 @@ function alphabetical_sort(&$data, $reverse = false, $byKeys = false) * @param int $nb The number of items for plural forms. * @param string $domain The domain where the translation is stored (default: shaarli). * - * @return String Text translated. + * @return string Text translated. */ function t($text, $nText = '', $nb = 1, $domain = 'shaarli') { return dn__($domain, $text, $nText, $nb); diff --git a/inc/languages/fr/LC_MESSAGES/shaarli.po b/inc/languages/fr/LC_MESSAGES/shaarli.po index cb9161d..6b2de95 100644 --- a/inc/languages/fr/LC_MESSAGES/shaarli.po +++ b/inc/languages/fr/LC_MESSAGES/shaarli.po @@ -1,15 +1,15 @@ msgid "" msgstr "" "Project-Id-Version: Shaarli\n" -"POT-Creation-Date: 2017-09-01 19:21+0200\n" -"PO-Revision-Date: 2017-09-01 19:21+0200\n" +"POT-Creation-Date: 2017-10-22 13:13+0200\n" +"PO-Revision-Date: 2017-10-22 13:14+0200\n" "Last-Translator: \n" "Language-Team: Shaarli\n" "Language: fr_FR\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"X-Generator: Poedit 2.0.3\n" +"X-Generator: Poedit 2.0.4\n" "X-Poedit-Basepath: ../../../..\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "X-Poedit-SourceCharset: UTF-8\n" @@ -27,19 +27,19 @@ msgstr "" "peut donc pas fonctionner. Votre version de PHP a des failles de sécurités " "connues et devrait être mise à jour au plus tôt." -#: application/ApplicationUtils.php:182 application/ApplicationUtils.php:194 +#: application/ApplicationUtils.php:183 application/ApplicationUtils.php:195 msgid "directory is not readable" msgstr "le répertoire n'est pas accessible en lecture" -#: application/ApplicationUtils.php:197 +#: application/ApplicationUtils.php:198 msgid "directory is not writable" msgstr "le répertoire n'est pas accessible en écriture" -#: application/ApplicationUtils.php:215 +#: application/ApplicationUtils.php:216 msgid "file is not readable" msgstr "le fichier n'est pas accessible en lecture" -#: application/ApplicationUtils.php:218 +#: application/ApplicationUtils.php:219 msgid "file is not writable" msgstr "le fichier n'est pas accessible en écriture" @@ -58,11 +58,11 @@ msgstr "Liens directs" msgid "Permalink" msgstr "Permalien" -#: application/History.php:158 +#: application/History.php:174 msgid "History file isn't readable or writable" msgstr "Le fichier d'historique n'est pas accessible en lecture ou en écriture" -#: application/History.php:169 +#: application/History.php:185 msgid "Could not parse history file" msgstr "Format incorrect pour le fichier d'historique" @@ -135,7 +135,7 @@ msgstr "" "Pssst ! Je suis un lien privé que VOUS êtes le seul à voir. Vous pouvez me " "supprimer aussi." -#: application/LinkFilter.php:415 +#: application/LinkFilter.php:452 msgid "The link you are trying to reach does not exist or has been deleted." msgstr "Le lien que vous essayez de consulter n'existe pas ou a été supprimé." @@ -143,29 +143,29 @@ msgstr "Le lien que vous essayez de consulter n'existe pas ou a été supprimé. msgid "Invalid export selection:" msgstr "Sélection d'export invalide :" -#: application/NetscapeBookmarkUtils.php:80 +#: application/NetscapeBookmarkUtils.php:81 #, php-format msgid "File %s (%d bytes) " msgstr "Le fichier %s (%d octets) " -#: application/NetscapeBookmarkUtils.php:82 +#: application/NetscapeBookmarkUtils.php:83 msgid "has an unknown file format. Nothing was imported." msgstr "a un format inconnu. Rien n'a été importé." -#: application/NetscapeBookmarkUtils.php:85 +#: application/NetscapeBookmarkUtils.php:86 #, php-format msgid "" -"was successfully processed: %d links imported, %d links overwritten, %d " -"links skipped." +"was successfully processed in %d seconds: %d links imported, %d links " +"overwritten, %d links skipped." msgstr "" -"a été importé avec succès : %d liens importés, %d liens écrasés, %d liens " -"ignorés." +"a été importé avec succès en %d secondes : %d liens importés, %d liens " +"écrasés, %d liens ignorés." -#: application/PageBuilder.php:160 +#: application/PageBuilder.php:165 msgid "The page you are trying to reach does not exist or has been deleted." msgstr "La page que vous essayez de consulter n'existe pas ou a été supprimée." -#: application/PageBuilder.php:162 +#: application/PageBuilder.php:167 msgid "404 Not Found" msgstr "404 Introuvable" @@ -249,58 +249,62 @@ msgstr "Vous n'êtes pas autorisé à modifier la configuration." msgid "Error accessing" msgstr "Une erreur s'est produite en accédant à" -#: index.php:134 +#: index.php:133 msgid "Shared links on " msgstr "Liens partagés sur " -#: index.php:156 +#: index.php:155 msgid "Insufficient permissions:" msgstr "Permissions insuffisantes :" -#: index.php:383 +#: index.php:382 msgid "I said: NO. You are banned for the moment. Go away." msgstr "NON. Vous êtes banni pour le moment. Revenez plus tard." -#: index.php:448 +#: index.php:447 msgid "Wrong login/password." msgstr "Nom d'utilisateur ou mot de passe incorrects." -#: index.php:1091 +#: index.php:1107 msgid "You are not supposed to change a password on an Open Shaarli." msgstr "" "Vous n'êtes pas censé modifier le mot de passe d'un Shaarli en mode ouvert." -#: index.php:1096 index.php:1137 index.php:1213 index.php:1243 index.php:1343 +#: index.php:1112 index.php:1153 index.php:1229 index.php:1259 index.php:1359 msgid "Wrong token." msgstr "Jeton invalide." -#: index.php:1101 +#: index.php:1117 msgid "The old password is not correct." msgstr "L'ancien mot de passe est incorrect." -#: index.php:1121 +#: index.php:1137 msgid "Your password has been changed" msgstr "Votre mot de passe a été modifié" -#: index.php:1174 +#: index.php:1190 msgid "Configuration was saved." msgstr "La configuration a été sauvegardé." -#: index.php:1225 +#: index.php:1241 #, php-format msgid "The tag was removed from %d link." msgid_plural "The tag was removed from %d links." msgstr[0] "Le tag a été supprimé de %d lien." msgstr[1] "Le tag a été supprimé de %d liens." -#: index.php:1226 +#: index.php:1242 #, php-format msgid "The tag was renamed in %d link." msgid_plural "The tag was renamed in %d links." msgstr[0] "Le tag a été renommé dans %d lien." msgstr[1] "Le tag a été renommé dans %d liens." -#: index.php:1551 +#: index.php:1458 +msgid "Note: " +msgstr "Note : " + +#: index.php:1567 #, php-format msgid "" "The file you are trying to upload is probably bigger than what this " @@ -310,7 +314,7 @@ msgstr "" "le serveur web peut accepter (%s). Merci de l'envoyer en parties plus " "légères." -#: index.php:1967 +#: index.php:1983 #, php-format msgid "" "
      Sessions do not seem to work correctly on your server.
      Make sure the " @@ -329,7 +333,7 @@ msgstr "" "cookies. Nous vous recommandons d'accéder à votre serveur depuis son adresse " "IP ou un Fully Qualified Domain Name.
      " -#: index.php:1977 +#: index.php:1993 msgid "Click to try again." msgstr "Cliquer ici pour réessayer." diff --git a/plugins/demo_plugin/demo_plugin.php b/plugins/demo_plugin/demo_plugin.php index 3a90ae6..b80a2b6 100644 --- a/plugins/demo_plugin/demo_plugin.php +++ b/plugins/demo_plugin/demo_plugin.php @@ -14,6 +14,26 @@ * and check user status with _LOGGEDIN_. */ +use Shaarli\Config\ConfigManager; + +/** + * In the footer hook, there is a working example of a translation extension for Shaarli. + * + * The extension must be attached to a new translation domain (i.e. NOT 'shaarli'). + * Use case: any custom theme or non official plugin can use the translation system. + * + * See the documentation for more information. + */ +const EXT_TRANSLATION_DOMAIN = 'demo'; + +/* + * This is not necessary, but it's easier if you don't want Poedit to mix up your translations. + */ +function demo_plugin_t($text, $nText = '', $nb = 1) +{ + return t($text, $nText, $nb, EXT_TRANSLATION_DOMAIN); +} + /** * Initialization function. * It will be called when the plugin is loaded. @@ -27,6 +47,12 @@ function demo_plugin_init($conf) { $conf->get('toto', 'nope'); + if (! $conf->exists('translation.extensions.demo')) { + // Custom translation with the domain 'demo' + $conf->set('translation.extensions.demo', 'plugins/demo_plugin/languages/'); + $conf->write(true); + } + $errors[] = 'This a demo init error.'; return $errors; } @@ -160,7 +186,7 @@ function hook_demo_plugin_render_includes($data) function hook_demo_plugin_render_footer($data) { // footer text - $data['text'][] = 'Shaarli is now enhanced by the awesome demo_plugin.'; + $data['text'][] = '
      '. demo_plugin_t('Shaarli is now enhanced by the awesome demo_plugin.'); // Free elements at the end of the page. $data['endofpage'][] = '' . diff --git a/plugins/demo_plugin/languages/fr/LC_MESSAGES/demo.mo b/plugins/demo_plugin/languages/fr/LC_MESSAGES/demo.mo new file mode 100644 index 0000000000000000000000000000000000000000..0f80f6ed54884a3b4c2e9567662c86f68d555b17 GIT binary patch literal 652 zcmZuv!EO^V5M2;l_Q;vT96{7u?*eLY2#2OEib%zlq=;KJ-JN8OcI{w$8}b2s0w2-u z$baxH%x2X@LX7-mkNrG;e)gYdPku)nj~UMyM~tV8_lz!b#%snEWBVwIesO=wnD7Qp z_tEX&#jHYfwFd3M*fo%;&=gd{?FK?cfNS!$Atdo6%GLw>t;tSpBuEKwKsmcaDZ;C#M zxkKY?4Av=#r)ZWfE=kM@8?}?SpHr}K@#W<5{GcvPWIVPekl{L;6$=XVGaRpK)Mf}p zh0aqLUC%GX;K1w7TaXjyrm&?pO9g{l9pZ1|BN=kNkla$K17wLzGG6IZaf+T+7%Bxx za=dGCUbhZwabaEK6&QAkQ$NavooPHAy)f_r(wD 1);\n" +"X-Poedit-KeywordsList: ;demo_plugin_t:1,2;demo_plugin_t\n" +"X-Poedit-SourceCharset: UTF-8\n" +"X-Poedit-SearchPath-0: .\n" + +#: demo_plugin.php:173 +msgid "Shaarli is now enhanced by the awesome demo_plugin." +msgstr "Shaarli est maintenant amélioré avec le fantastique demo_plugin." From 1a47014f99d2f7aae00d37e62e0364d4eaa1ce29 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Fri, 1 Sep 2017 18:47:32 +0200 Subject: [PATCH 18/77] Translation documentation --- doc/md/Download-and-Installation.md | 4 +- doc/md/Server-requirements.md | 1 + doc/md/Shaarli-configuration.md | 21 ++++ doc/md/Translations.md | 152 ++++++++++++++++++++++++++++ doc/md/Upgrade-and-migration.md | 21 +++- doc/md/images/install-shaarli.png | Bin 0 -> 44376 bytes doc/md/images/poedit-1.jpg | Bin 0 -> 72956 bytes mkdocs.yml | 1 + 8 files changed, 198 insertions(+), 2 deletions(-) create mode 100644 doc/md/Translations.md create mode 100644 doc/md/images/install-shaarli.png create mode 100644 doc/md/images/poedit-1.jpg diff --git a/doc/md/Download-and-Installation.md b/doc/md/Download-and-Installation.md index e5e929e..59a1b7d 100644 --- a/doc/md/Download-and-Installation.md +++ b/doc/md/Download-and-Installation.md @@ -36,6 +36,7 @@ In most cases, download Shaarli from the [releases](https://github.com/shaarli/S $ mkdir -p /path/to/shaarli && cd /path/to/shaarli/ $ git clone -b v0.9 https://github.com/shaarli/Shaarli.git . $ composer install --no-dev --prefer-dist +$ make translate ``` ## Stable version @@ -83,13 +84,14 @@ $ git clone https://github.com/shaarli/Shaarli.git -b master /path/to/shaarli/ # install/update third-party dependencies $ cd /path/to/shaarli $ composer install --no-dev --prefer-dist +$ make translate ``` ## Finish Installation Once Shaarli is downloaded and files have been placed at the correct location, open it this location your favorite browser. -![install screenshot](http://i.imgur.com/wuMpDSN.png) +![install screenshot](images/install-shaarli.png) Setup your Shaarli installation, and it's ready to use! diff --git a/doc/md/Server-requirements.md b/doc/md/Server-requirements.md index 707af76..400b85a 100644 --- a/doc/md/Server-requirements.md +++ b/doc/md/Server-requirements.md @@ -39,3 +39,4 @@ Extension | Required? | Usage [`php-gd`](http://php.net/manual/en/book.image.php) | optional | thumbnail resizing [`php-intl`](http://php.net/manual/en/book.intl.php) | optional | localized text sorting (e.g. `e->è->f`) [`php-curl`](http://php.net/manual/en/book.curl.php) | optional | using cURL for fetching webpages and thumbnails in a more robust way +[`php-gettext`](http://php.net/manual/en/book.gettext.php) | optional | Use the translation system in gettext mode (faster) diff --git a/doc/md/Shaarli-configuration.md b/doc/md/Shaarli-configuration.md index 99b25ba..920c7e2 100644 --- a/doc/md/Shaarli-configuration.md +++ b/doc/md/Shaarli-configuration.md @@ -81,6 +81,20 @@ _These settings should not be edited_ - **page_cache**: Shaarli's internal cache directory. - **ban_file**: Banned IP file path. +### Translation + +- **language**: translation language (also see [Translations](Translations)) + - **auto** (default): The translation language is chosen from the browser locale. + It means that the language can be different for 2 different visitors depending on their locale. + - **en**: Use the English translation. + - **fr**: Use the French translation. +- **mode**: + - **auto** or **php** (default): Use the PHP implementation of gettext (slower) + - **gettext**: Use PHP builtin gettext extension + (faster, but requires `php-gettext` to be installed and to reload the web server on update) +- **extension**: Translation extensions for custom themes or plugins. +Must be an associative array: `translation domain => translation path`. + ### Updates - **check_updates**: Enable or disable update check to the git repository. @@ -211,6 +225,13 @@ _These settings should not be edited_ "plugins": { "WALLABAG_URL": "http://demo.wallabag.org", "WALLABAG_VERSION": "1" + }, + "translation": { + "language": "fr", + "mode": "php", + "extensions": { + "demo": "plugins/demo_plugin/languages/" + } } } ?> ``` diff --git a/doc/md/Translations.md b/doc/md/Translations.md new file mode 100644 index 0000000..54a3665 --- /dev/null +++ b/doc/md/Translations.md @@ -0,0 +1,152 @@ +## Translations + +Shaarli supports [gettext](https://www.gnu.org/software/gettext/manual/gettext.html) translations +since `>= v0.9.2`. + +Note that only the `default` theme supports translations. + +### Contributing + +We encourage the community to contribute to Shaarli's translation either by improving existing +translations or submitting a new language. + +Contributing to the translation does not require development skill. + +Please submit a pull request with the `.po` file updated/created. Note that the compiled file (`.mo`) +is not stored on the repository, and is generated during the release process. + +### How to + +First, install [Poedit](https://poedit.net/) tool. + +Poedit will extract strings to translate from the PHP source code. + +**Important**: due to the usage of a template engine, it's important to generate PHP cache files to extract +every translatable string. + +You can either use [this script](https://gist.github.com/ArthurHoaro/5d0323f758ab2401ef444a53f54e9a07) (recommended) +or visit every template page in your browser to generate cache files, while logged in. + +Here is a list : + +``` +http:/// +http:///?nonope +http:///?do=addlink +http:///?do=changepasswd +http:///?do=changetag +http:///?do=configure +http:///?do=tools +http:///?do=daily +http:///?post +http:///?do=export +http:///?do=import +http:///?do=login +http:///?do=picwall +http:///?do=pluginadmin +http:///?do=tagcloud +http:///?do=taglist +``` + +#### Improve existing translation + +In Poedit, click on "Edit a Translation", and from Shaarli's directory open +`inc/languages//LC_MESSAGES/shaarli.po`. + +The existing list of translatable strings should have been loaded, then click on the "Update" button. + +You can start editing the translation. + +![poedit-screenshot](images/poedit-1.jpg) + +Save when you're done, then you can submit a pull request containing the updated `shaarli.po`. + +#### Add a new language + +Open Poedit and select "Create New Translation", then from Shaarli's directory open +`inc/languages//LC_MESSAGES/shaarli.po`. + +Then select the language you want to create. + +Click on `File > Save as...`, and save your file in `/inc/language//LC_MESSAGES/shaarli.po`. +`` here should be the language code respecting the [ISO 3166-1 alpha-2](https://en.wikipedia.org/wiki/ISO_3166-2) +format in lowercase (e.g. `de` for German). + +Then click on the "Update" button, and you can start to translate every available string. + +Save when you're done, then you can submit a pull request containing the new `shaarli.po`. + +### Extend Shaarli's translation + +If you're writing a custom theme, or a non official plugin, you might want to use the translation system, +but you won't be able to able to override Shaarli's translation. + +However, you can add your own translation domain which extends the main translation list. + +> Note that you can find a live example of translation extension in the `demo_plugin`. + +First, create your translation files tree directory: + +``` +/languages//LC_MESSAGES/ +``` + +Your `.po` files must be named like your domain. E.g. if your translation domain is `my_theme`, then your file will be +`my_theme.po`. + +Users have to register your extension in their configuration with the parameter +`translation.extensions.: `. + +Example: + +```php +if (! $conf->exists('translation.extensions.my_theme')) { + $conf->set('translation.extensions.my_theme', '/languages/'); + $conf->write(true); +} +``` + +> Note that the page needs to be reloaded after the registration. + +It is then recommended to create a custom translation function which will call the `t()` function with your domain. +For example : + +```php +function my_theme_t($text, $nText = '', $nb = 1) +{ + return t($text, $nText, $nb, 'my_theme'); // the last parameter is your translation domain. +} +``` + +All strings which can be translated should be processed through your function: + +```php +my_theme_t('Comment'); +my_theme_t('Comment', 'Comments', 2); +``` + +Or in templates: + +```php +{'Comment'|my_theme_t} +{function="my_theme_t('Comment', 'Comments', 2)"} +``` + +> Note than in template, you need to visit your page at least once to generate a cache file. + +When you're done, open Poedit and load translation strings from sources: + + 1. `File > New` + 2. Choose your language + 3. Save your `PO` file in `/languages//LC_MESSAGES/my_theme.po`. + 4. Go to `Catalog > Properties...` + 5. Fill the `Translation Properties` tab + 6. Add your source path in the `Sources Paths` tab + 7. In the `Sources Keywords` tab uncheck "Also use default keywords" and add the following lines: + +``` +my_theme_t +my_theme_t:1,2 +``` + +Click on the "Update" button and you're free to start your translations! diff --git a/doc/md/Upgrade-and-migration.md b/doc/md/Upgrade-and-migration.md index 7033cd4..1dc0733 100644 --- a/doc/md/Upgrade-and-migration.md +++ b/doc/md/Upgrade-and-migration.md @@ -39,7 +39,10 @@ We recommend that you use the latest release tarball with the `-full` suffix. It Once downloaded, extract the archive locally and update your remote installation (e.g. via FTP) -be sure you keep the content of the `data` directory! -After upgrading, access your fresh Shaarli installation from a web browser; the configuration and data store will then be automatically updated, and new settings added to `data/config.json.php` (see [Shaarli configuration](Shaarli-configuration) for more details). +If you use translations in gettext mode - meaning you manually changed the default mode -, +reload your web server. + +After upgrading, access your fresh Shaarli installation from a web browser; the configuration and data store will then be automatically updated, and new settings added to `data/config.json.php` (see [Shaarli configuration](Shaarli configuration) for more details). ## Upgrading with Git @@ -72,6 +75,14 @@ Updating dependencies Downloading: 100% ``` +Shaarli >= `v0.9.2` supports translations: + +```bash +$ make translate +``` + +If you use translations in gettext mode, reload your web server. + ### Migrating and upgrading from Sebsauvage's repository If you have installed Shaarli from [Sebsauvage's original Git repository](https://github.com/sebsauvage/Shaarli), you can use [Git remotes](https://git-scm.com/book/en/v2/Git-Basics-Working-with-Remotes) to update your working copy. @@ -151,6 +162,14 @@ Updating dependencies Downloading: 100% ``` +Shaarli >= `v0.9.2` supports translations: + +```bash +$ make translate +``` + +If you use translations in gettext mode, reload your web server. + Optionally, you can delete information related to the legacy version: ```bash diff --git a/doc/md/images/install-shaarli.png b/doc/md/images/install-shaarli.png new file mode 100644 index 0000000000000000000000000000000000000000..7ae338165b549359dbc65a26d22aa726137ab129 GIT binary patch literal 44376 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 bvLF@n@W{KR`wv^mWw45Jr(~0k>)!rfw7$ZU literal 0 HcmV?d00001 diff --git a/doc/md/images/poedit-1.jpg b/doc/md/images/poedit-1.jpg new file mode 100644 index 0000000000000000000000000000000000000000..673ae6d6797d2bd431fa401acc7d0f8a23fb41c1 GIT binary patch literal 72956 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

      ILXCQptN9gTgmfG+aUhu6@lJN}OsKV+O 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 jg Date: Sun, 22 Oct 2017 14:08:05 +0200 Subject: [PATCH 19/77] Documentation: add reverse proxy examples for Docker images Closes https://github.com/shaarli/Shaarli/issues/888 Signed-off-by: VirtualTam --- doc/md/docker/reverse-proxy-configuration.md | 116 ++++++++++++++++++- 1 file changed, 115 insertions(+), 1 deletion(-) diff --git a/doc/md/docker/reverse-proxy-configuration.md b/doc/md/docker/reverse-proxy-configuration.md index 91ffecf..6066140 100644 --- a/doc/md/docker/reverse-proxy-configuration.md +++ b/doc/md/docker/reverse-proxy-configuration.md @@ -1,6 +1,120 @@ +## Foreword + +This guide assumes that: + +- Shaarli runs in a Docker container +- The host's `10080` port is mapped to the container's `80` port +- Shaarli's Fully Qualified Domain Name (FQDN) is `shaarli.domain.tld` +- HTTP traffic is redirected to HTTPS + +## Apache + +- [Apache 2.4 documentation](https://httpd.apache.org/docs/2.4/) + - [mod_proxy](https://httpd.apache.org/docs/2.4/mod/mod_proxy.html) + - [Reverse Proxy Request Headers](https://httpd.apache.org/docs/2.4/mod/mod_proxy.html#x-headers) + +The following HTTP headers are set by using the `ProxyPass` directive: + +- `X-Forwarded-For` +- `X-Forwarded-Host` +- `X-Forwarded-Server` + +```apache + + ServerName shaarli.domain.tld + Redirect permanent / https://shaarli.domain.tld + + + + ServerName shaarli.domain.tld + + SSLEngine on + SSLCertificateFile /path/to/cert + SSLCertificateKeyFile /path/to/certkey + + LogLevel warn + ErrorLog /var/log/apache2/shaarli-error.log + CustomLog /var/log/apache2/shaarli-access.log combined + + RequestHeader set X-Forwarded-Proto "https" + + ProxyPass / http://127.0.0.1:10080/ + ProxyPassReverse / http://127.0.0.1:10080/ + +``` -TODO, see https://github.com/shaarli/Shaarli/issues/888 ## HAProxy +- [HAProxy documentation](https://cbonte.github.io/haproxy-dconv/) + +```conf +global + [...] + +defaults + [...] + +frontend http-in + bind :80 + redirect scheme https code 301 if !{ ssl_fc } + + bind :443 ssl crt /path/to/cert.pem + + default_backend shaarli + + +backend shaarli + mode http + option http-server-close + option forwardfor + reqadd X-Forwarded-Proto: https + + server shaarli1 127.0.0.1:10080 +``` + + ## Nginx + +- [Nginx documentation](https://nginx.org/en/docs/) + +```nginx +http { + [...] + + index index.html index.php; + + root /home/john/web; + access_log /var/log/nginx/access.log; + error_log /var/log/nginx/error.log; + + server { + listen 80; + server_name shaarli.domain.tld; + return 301 https://shaarli.domain.tld$request_uri; + } + + server { + listen 443 ssl http2; + server_name shaarli.domain.tld; + + ssl_certificate /path/to/cert + ssl_certificate_key /path/to/certkey + + location / { + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + + proxy_pass http://localhost:10080/; + proxy_set_header Host $host; + proxy_connect_timeout 30s; + proxy_read_timeout 120s; + + access_log /var/log/nginx/shaarli.access.log; + error_log /var/log/nginx/shaarli.error.log; + } + } +} +``` From ebd650c06c67a67da2a0d099f625b6a7ec62ab2b Mon Sep 17 00:00:00 2001 From: VirtualTam Date: Sun, 22 Oct 2017 18:44:46 +0200 Subject: [PATCH 20/77] Refactor session token management Relates to https://github.com/shaarli/Shaarli/issues/324 Added: - `SessionManager` class to group session-related features - unit tests Changed: - `getToken()` -> `SessionManager->generateToken()` - `tokenOk()` -> `SessionManager->checkToken()` - inject a `$token` parameter to `PageBuilder`'s constructor Signed-off-by: VirtualTam --- application/PageBuilder.php | 6 ++- application/SessionManager.php | 53 +++++++++++++++++++++++++ index.php | 71 ++++++++++++--------------------- tests/SessionManagerTest.php | 72 ++++++++++++++++++++++++++++++++++ 4 files changed, 153 insertions(+), 49 deletions(-) create mode 100644 application/SessionManager.php create mode 100644 tests/SessionManagerTest.php diff --git a/application/PageBuilder.php b/application/PageBuilder.php index af29067..468f144 100644 --- a/application/PageBuilder.php +++ b/application/PageBuilder.php @@ -32,12 +32,14 @@ class PageBuilder * * @param ConfigManager $conf Configuration Manager instance (reference). * @param LinkDB $linkDB instance. + * @param string $token Session token */ - public function __construct(&$conf, $linkDB = null) + public function __construct(&$conf, $linkDB = null, $token = null) { $this->tpl = false; $this->conf = $conf; $this->linkDB = $linkDB; + $this->token = $token; } /** @@ -92,7 +94,7 @@ class PageBuilder $this->tpl->assign('showatom', $this->conf->get('feed.show_atom', true)); $this->tpl->assign('feed_type', $this->conf->get('feed.show_atom', true) !== false ? 'atom' : 'rss'); $this->tpl->assign('hide_timestamps', $this->conf->get('privacy.hide_timestamps', false)); - $this->tpl->assign('token', getToken($this->conf)); + $this->tpl->assign('token', $this->token); if ($this->linkDB !== null) { $this->tpl->assign('tags', $this->linkDB->linksCountPerTag()); diff --git a/application/SessionManager.php b/application/SessionManager.php new file mode 100644 index 0000000..2083df4 --- /dev/null +++ b/application/SessionManager.php @@ -0,0 +1,53 @@ +session = &$session; + $this->conf = &$conf; + } + + /** + * Generates a session token + * + * @return string token + */ + public function generateToken() + { + $token = sha1(uniqid('', true) .'_'. mt_rand() . $this->conf->get('credentials.salt')); + $this->session['tokens'][$token] = 1; + return $token; + } + + /** + * Checks the validity of a session token, and destroys it afterwards + * + * @param string $token The token to check + * + * @return bool true if the token is valid, else false + */ + public function checkToken($token) + { + if (! isset($this->session['tokens'][$token])) { + // the token is wrong, or has already been used + return false; + } + + // destroy the token to prevent future use + unset($this->session['tokens'][$token]); + return true; + } +} diff --git a/index.php b/index.php index 1dc8184..9e69862 100644 --- a/index.php +++ b/index.php @@ -78,6 +78,7 @@ require_once 'application/Updater.php'; use \Shaarli\Languages; use \Shaarli\ThemeUtils; use \Shaarli\Config\ConfigManager; +use \Shaarli\SessionManager; // Ensure the PHP version is supported try { @@ -121,6 +122,7 @@ if (isset($_COOKIE['shaarli']) && !is_session_id_valid($_COOKIE['shaarli'])) { } $conf = new ConfigManager(); +$sessionManager = new SessionManager($_SESSION, $conf); // Sniff browser language and set date format accordingly. if (isset($_SERVER['HTTP_ACCEPT_LANGUAGE'])) { @@ -165,7 +167,7 @@ if (! is_file($conf->getConfigFileExt())) { } // Display the installation form if no existing config is found - install($conf); + install($conf, $sessionManager); } // a token depending of deployment salt, user password, and the current ip @@ -381,7 +383,7 @@ if (isset($_POST['login'])) { if (!ban_canLogin($conf)) die(t('I said: NO. You are banned for the moment. Go away.')); if (isset($_POST['password']) - && tokenOk($_POST['token']) + && $sessionManager->checkToken($_POST['token']) && (check_auth($_POST['login'], $_POST['password'], $conf)) ) { // Login/password is OK. ban_loginOk($conf); @@ -454,32 +456,6 @@ if (isset($_POST['login'])) // Token should be used in any form which acts on data (create,update,delete,import...). if (!isset($_SESSION['tokens'])) $_SESSION['tokens']=array(); // Token are attached to the session. -/** - * Returns a token. - * - * @param ConfigManager $conf Configuration Manager instance. - * - * @return string token. - */ -function getToken($conf) -{ - $rnd = sha1(uniqid('', true) .'_'. mt_rand() . $conf->get('credentials.salt')); // We generate a random string. - $_SESSION['tokens'][$rnd]=1; // Store it on the server side. - return $rnd; -} - -// Tells if a token is OK. Using this function will destroy the token. -// true=token is OK. -function tokenOk($token) -{ - if (isset($_SESSION['tokens'][$token])) - { - unset($_SESSION['tokens'][$token]); // Token is used: destroy it. - return true; // Token is OK. - } - return false; // Wrong token, or already used. -} - /** * Daily RSS feed: 1 RSS entry per day giving all the links on that day. * Gives the last 7 days (which have links). @@ -687,12 +663,13 @@ function showLinkList($PAGE, $LINKSDB, $conf, $pluginManager) { /** * Render HTML page (according to URL parameters and user rights) * - * @param ConfigManager $conf Configuration Manager instance. - * @param PluginManager $pluginManager Plugin Manager instance, - * @param LinkDB $LINKSDB - * @param History $history instance + * @param ConfigManager $conf Configuration Manager instance. + * @param PluginManager $pluginManager Plugin Manager instance, + * @param LinkDB $LINKSDB + * @param History $history instance + * @param SessionManager $sessionManager SessionManager instance */ -function renderPage($conf, $pluginManager, $LINKSDB, $history) +function renderPage($conf, $pluginManager, $LINKSDB, $history, $sessionManager) { $updater = new Updater( read_updates_file($conf->get('resource.updates')), @@ -713,7 +690,7 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history) die($e->getMessage()); } - $PAGE = new PageBuilder($conf, $LINKSDB); + $PAGE = new PageBuilder($conf, $LINKSDB, $sessionManager->generateToken()); $PAGE->assign('linkcount', count($LINKSDB)); $PAGE->assign('privateLinkcount', count_private($LINKSDB)); $PAGE->assign('plugin_errors', $pluginManager->getErrors()); @@ -1109,13 +1086,13 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history) if (!empty($_POST['setpassword']) && !empty($_POST['oldpassword'])) { - if (!tokenOk($_POST['token'])) die(t('Wrong token.')); // Go away! + if (!$sessionManager->checkToken($_POST['token'])) die(t('Wrong token.')); // Go away! // Make sure old password is correct. $oldhash = sha1($_POST['oldpassword'].$conf->get('credentials.login').$conf->get('credentials.salt')); if ($oldhash!= $conf->get('credentials.hash')) { echo ''; - exit; + exit; } // Save new password // Salt renders rainbow-tables attacks useless. @@ -1149,7 +1126,7 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history) { if (!empty($_POST['title']) ) { - if (!tokenOk($_POST['token'])) { + if (!$sessionManager->checkToken($_POST['token'])) { die(t('Wrong token.')); // Go away! } $tz = 'UTC'; @@ -1225,7 +1202,7 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history) exit; } - if (!tokenOk($_POST['token'])) { + if (!$sessionManager->checkToken($_POST['token'])) { die(t('Wrong token.')); } @@ -1255,7 +1232,7 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history) if (isset($_POST['save_edit'])) { // Go away! - if (! tokenOk($_POST['token'])) { + if (! $sessionManager->checkToken($_POST['token'])) { die(t('Wrong token.')); } @@ -1355,7 +1332,7 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history) // -------- User clicked the "Delete" button when editing a link: Delete link from database. if ($targetPage == Router::$PAGE_DELETELINK) { - if (! tokenOk($_GET['token'])) { + if (! $sessionManager->checkToken($_GET['token'])) { die(t('Wrong token.')); } @@ -1572,7 +1549,7 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history) echo ''; exit; } - if (! tokenOk($_POST['token'])) { + if (! $sessionManager->checkToken($_POST['token'])) { die('Wrong token.'); } $status = NetscapeBookmarkUtils::import( @@ -1639,7 +1616,7 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history) // Get a fresh token if ($targetPage == Router::$GET_TOKEN) { header('Content-Type:text/plain'); - echo getToken($conf); + echo $sessionManager->generateToken($conf); exit; } @@ -1965,10 +1942,10 @@ function lazyThumbnail($conf, $url,$href=false) * Installation * This function should NEVER be called if the file data/config.php exists. * - * @param ConfigManager $conf Configuration Manager instance. + * @param ConfigManager $conf Configuration Manager instance. + * @param SessionManager $sessionManager SessionManager instance */ -function install($conf) -{ +function install($conf, $sessionManager) { // On free.fr host, make sure the /sessions directory exists, otherwise login will not work. if (endsWith($_SERVER['HTTP_HOST'],'.free.fr') && !is_dir($_SERVER['DOCUMENT_ROOT'].'/sessions')) mkdir($_SERVER['DOCUMENT_ROOT'].'/sessions',0705); @@ -2051,7 +2028,7 @@ function install($conf) exit; } - $PAGE = new PageBuilder($conf); + $PAGE = new PageBuilder($conf, null, $sessionManager->generateToken()); list($continents, $cities) = generateTimeZoneData(timezone_identifiers_list(), date_default_timezone_get()); $PAGE->assign('continents', $continents); $PAGE->assign('cities', $cities); @@ -2328,7 +2305,7 @@ $response = $app->run(true); if ($response->getStatusCode() == 404 && strpos($_SERVER['REQUEST_URI'], '/api/v1') === false) { // We use UTF-8 for proper international characters handling. header('Content-Type: text/html; charset=utf-8'); - renderPage($conf, $pluginManager, $linkDb, $history); + renderPage($conf, $pluginManager, $linkDb, $history, $sessionManager); } else { $app->respond($response); } diff --git a/tests/SessionManagerTest.php b/tests/SessionManagerTest.php new file mode 100644 index 0000000..3a27030 --- /dev/null +++ b/tests/SessionManagerTest.php @@ -0,0 +1,72 @@ +generateToken(); + + $this->assertEquals(1, $session['tokens'][$token]); + $this->assertEquals(40, strlen($token)); + } + + /** + * Generate and check a session token + */ + public function testGenerateAndCheckToken() + { + $session = []; + $conf = new FakeConfigManager(); + $sessionManager = new SessionManager($session, $conf); + + $token = $sessionManager->generateToken(); + + // ensure a token has been generated + $this->assertEquals(1, $session['tokens'][$token]); + $this->assertEquals(40, strlen($token)); + + // check and destroy the token + $this->assertTrue($sessionManager->checkToken($token)); + $this->assertFalse(isset($session['tokens'][$token])); + + // ensure the token has been destroyed + $this->assertFalse($sessionManager->checkToken($token)); + } + + /** + * Check an invalid session token + */ + public function testCheckInvalidToken() + { + $session = []; + $conf = new FakeConfigManager(); + $sessionManager = new SessionManager($session, $conf); + + $this->assertFalse($sessionManager->checkToken('4dccc3a45ad9d03e5542b90c37d8db6d10f2b38b')); + } +} From fd7d84616d53486c3a276a42da869390e1d7f5eb Mon Sep 17 00:00:00 2001 From: VirtualTam Date: Sun, 22 Oct 2017 19:54:44 +0200 Subject: [PATCH 21/77] Move session ID check to SessionManager Relates to https://github.com/shaarli/Shaarli/issues/324 Changed: - `is_session_id_valid()` -> `SessionManager::checkId()` - update tests Signed-off-by: VirtualTam --- application/SessionManager.php | 30 +++++++++++++++ application/Utils.php | 30 --------------- index.php | 2 +- tests/SessionManagerTest.php | 67 +++++++++++++++++++++++++++++++++- tests/UtilsTest.php | 58 ----------------------------- 5 files changed, 97 insertions(+), 90 deletions(-) diff --git a/application/SessionManager.php b/application/SessionManager.php index 2083df4..3aa4ddf 100644 --- a/application/SessionManager.php +++ b/application/SessionManager.php @@ -50,4 +50,34 @@ class SessionManager unset($this->session['tokens'][$token]); return true; } + + /** + * Validate session ID to prevent Full Path Disclosure. + * + * See #298. + * The session ID's format depends on the hash algorithm set in PHP settings + * + * @param string $sessionId Session ID + * + * @return true if valid, false otherwise. + * + * @see http://php.net/manual/en/function.hash-algos.php + * @see http://php.net/manual/en/session.configuration.php + */ + public static function checkId($sessionId) + { + if (empty($sessionId)) { + return false; + } + + if (!$sessionId) { + return false; + } + + if (!preg_match('/^[a-zA-Z0-9,-]{2,128}$/', $sessionId)) { + return false; + } + + return true; + } } diff --git a/application/Utils.php b/application/Utils.php index 2f38a8d..97b12fc 100644 --- a/application/Utils.php +++ b/application/Utils.php @@ -181,36 +181,6 @@ function generateLocation($referer, $host, $loopTerms = array()) return $finalReferer; } -/** - * Validate session ID to prevent Full Path Disclosure. - * - * See #298. - * The session ID's format depends on the hash algorithm set in PHP settings - * - * @param string $sessionId Session ID - * - * @return true if valid, false otherwise. - * - * @see http://php.net/manual/en/function.hash-algos.php - * @see http://php.net/manual/en/session.configuration.php - */ -function is_session_id_valid($sessionId) -{ - if (empty($sessionId)) { - return false; - } - - if (!$sessionId) { - return false; - } - - if (!preg_match('/^[a-zA-Z0-9,-]{2,128}$/', $sessionId)) { - return false; - } - - return true; -} - /** * Sniff browser language to set the locale automatically. * Note that is may not work on your server if the corresponding locale is not installed. diff --git a/index.php b/index.php index 9e69862..e1516d3 100644 --- a/index.php +++ b/index.php @@ -116,7 +116,7 @@ if (session_id() == '') { } // Regenerate session ID if invalid or not defined in cookie. -if (isset($_COOKIE['shaarli']) && !is_session_id_valid($_COOKIE['shaarli'])) { +if (isset($_COOKIE['shaarli']) && !SessionManager::checkId($_COOKIE['shaarli'])) { session_regenerate_id(true); $_COOKIE['shaarli'] = session_id(); } diff --git a/tests/SessionManagerTest.php b/tests/SessionManagerTest.php index 3a27030..9fa60dc 100644 --- a/tests/SessionManagerTest.php +++ b/tests/SessionManagerTest.php @@ -1,8 +1,12 @@ assertFalse($sessionManager->checkToken('4dccc3a45ad9d03e5542b90c37d8db6d10f2b38b')); } + + /** + * Test SessionManager::checkId with a valid ID - TEST ALL THE HASHES! + * + * This tests extensively covers all hash algorithms / bit representations + */ + public function testIsAnyHashSessionIdValid() + { + foreach (self::$sidHashes as $algo => $bpcs) { + foreach ($bpcs as $bpc => $hash) { + $this->assertTrue(SessionManager::checkId($hash)); + } + } + } + + /** + * Test checkId with a valid ID - SHA-1 hashes + */ + public function testIsSha1SessionIdValid() + { + $this->assertTrue(SessionManager::checkId(sha1('shaarli'))); + } + + /** + * Test checkId with a valid ID - SHA-256 hashes + */ + public function testIsSha256SessionIdValid() + { + $this->assertTrue(SessionManager::checkId(hash('sha256', 'shaarli'))); + } + + /** + * Test checkId with a valid ID - SHA-512 hashes + */ + public function testIsSha512SessionIdValid() + { + $this->assertTrue(SessionManager::checkId(hash('sha512', 'shaarli'))); + } + + /** + * Test checkId with invalid IDs. + */ + public function testIsSessionIdInvalid() + { + $this->assertFalse(SessionManager::checkId('')); + $this->assertFalse(SessionManager::checkId([])); + $this->assertFalse( + SessionManager::checkId('c0ZqcWF3VFE2NmJBdm1HMVQ0ZHJ3UmZPbTFsNGhkNHI=') + ); + } } diff --git a/tests/UtilsTest.php b/tests/UtilsTest.php index 840eaf2..6cd37a7 100644 --- a/tests/UtilsTest.php +++ b/tests/UtilsTest.php @@ -5,10 +5,6 @@ require_once 'application/Utils.php'; require_once 'application/Languages.php'; -require_once 'tests/utils/ReferenceSessionIdHashes.php'; - -// Initialize reference data before PHPUnit starts a session -ReferenceSessionIdHashes::genAllHashes(); /** @@ -16,9 +12,6 @@ ReferenceSessionIdHashes::genAllHashes(); */ class UtilsTest extends PHPUnit_Framework_TestCase { - // Session ID hashes - protected static $sidHashes = null; - // Log file protected static $testLogFile = 'tests.log'; @@ -30,13 +23,11 @@ class UtilsTest extends PHPUnit_Framework_TestCase */ protected static $defaultTimeZone; - /** * Assign reference data */ public static function setUpBeforeClass() { - self::$sidHashes = ReferenceSessionIdHashes::getHashes(); self::$defaultTimeZone = date_default_timezone_get(); // Timezone without DST for test consistency date_default_timezone_set('Africa/Nairobi'); @@ -221,56 +212,7 @@ class UtilsTest extends PHPUnit_Framework_TestCase $this->assertEquals('?', generateLocation($ref, 'localhost')); } - /** - * Test is_session_id_valid with a valid ID - TEST ALL THE HASHES! - * - * This tests extensively covers all hash algorithms / bit representations - */ - public function testIsAnyHashSessionIdValid() - { - foreach (self::$sidHashes as $algo => $bpcs) { - foreach ($bpcs as $bpc => $hash) { - $this->assertTrue(is_session_id_valid($hash)); - } - } - } - /** - * Test is_session_id_valid with a valid ID - SHA-1 hashes - */ - public function testIsSha1SessionIdValid() - { - $this->assertTrue(is_session_id_valid(sha1('shaarli'))); - } - - /** - * Test is_session_id_valid with a valid ID - SHA-256 hashes - */ - public function testIsSha256SessionIdValid() - { - $this->assertTrue(is_session_id_valid(hash('sha256', 'shaarli'))); - } - - /** - * Test is_session_id_valid with a valid ID - SHA-512 hashes - */ - public function testIsSha512SessionIdValid() - { - $this->assertTrue(is_session_id_valid(hash('sha512', 'shaarli'))); - } - - /** - * Test is_session_id_valid with invalid IDs. - */ - public function testIsSessionIdInvalid() - { - $this->assertFalse(is_session_id_valid('')); - $this->assertFalse(is_session_id_valid(array())); - $this->assertFalse( - is_session_id_valid('c0ZqcWF3VFE2NmJBdm1HMVQ0ZHJ3UmZPbTFsNGhkNHI=') - ); - } - /** * Test generateSecretApi. */ From fc2beb8c6aa4d423b55ba95809941f2eba6fea2a Mon Sep 17 00:00:00 2001 From: nodiscc Date: Mon, 23 Oct 2017 01:06:11 +0200 Subject: [PATCH 22/77] Changelog: link to CVE-2017-15215, give attribution --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 120c5d2..33feac2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,7 +40,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Security -- Vulnerability introduced in v0.9.1 fixed. +- Fixed reflected XSS vulnerability introduced in v0.9.1, discovered by @chb9 ([CVE-2017-15215](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2017-15215)). ## [v0.9.1](https://github.com/shaarli/Shaarli/releases/tag/v0.9.1) - 2017-08-23 From ae7c954b1279981cc23c9f67d88f55bfecc4d828 Mon Sep 17 00:00:00 2001 From: VirtualTam Date: Tue, 24 Oct 2017 22:01:02 +0200 Subject: [PATCH 23/77] Improve SessionManager tests Signed-off-by: VirtualTam --- tests/SessionManagerTest.php | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/SessionManagerTest.php b/tests/SessionManagerTest.php index 9fa60dc..a92c3cc 100644 --- a/tests/SessionManagerTest.php +++ b/tests/SessionManagerTest.php @@ -50,6 +50,29 @@ class SessionManagerTest extends TestCase $this->assertEquals(40, strlen($token)); } + /** + * Check a session token + */ + public function testCheckToken() + { + $token = '4dccc3a45ad9d03e5542b90c37d8db6d10f2b38b'; + $session = [ + 'tokens' => [ + $token => 1, + ], + ]; + $conf = new FakeConfigManager(); + $sessionManager = new SessionManager($session, $conf); + + + // check and destroy the token + $this->assertTrue($sessionManager->checkToken($token)); + $this->assertFalse(isset($session['tokens'][$token])); + + // ensure the token has been destroyed + $this->assertFalse($sessionManager->checkToken($token)); + } + /** * Generate and check a session token */ From d65342e304f92643ba922200953cfebc51e1e482 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Sat, 30 Sep 2017 11:04:13 +0200 Subject: [PATCH 24/77] Extract the title/charset during page download, and check content type Use CURLOPT_WRITEFUNCTION to check the response code and content type (only allow HTML). Also extract the title and charset during downloading chunk of data, and stop it when everything has been extracted. Closes #579 --- application/HttpUtils.php | 14 ++- application/LinkUtils.php | 89 ++++++++------ index.php | 14 +-- tests/LinkUtilsTest.php | 244 ++++++++++++++++++++++++++++++++++---- 4 files changed, 293 insertions(+), 68 deletions(-) diff --git a/application/HttpUtils.php b/application/HttpUtils.php index 0083596..2edf5ce 100644 --- a/application/HttpUtils.php +++ b/application/HttpUtils.php @@ -3,9 +3,11 @@ * GET an HTTP URL to retrieve its content * Uses the cURL library or a fallback method * - * @param string $url URL to get (http://...) - * @param int $timeout network timeout (in seconds) - * @param int $maxBytes maximum downloaded bytes (default: 4 MiB) + * @param string $url URL to get (http://...) + * @param int $timeout network timeout (in seconds) + * @param int $maxBytes maximum downloaded bytes (default: 4 MiB) + * @param callable|string $curlWriteFunction Optional callback called during the download (cURL CURLOPT_WRITEFUNCTION). + * Can be used to add download conditions on the headers (response code, content type, etc.). * * @return array HTTP response headers, downloaded content * @@ -29,7 +31,7 @@ * @see http://stackoverflow.com/q/9183178 * @see http://stackoverflow.com/q/1462720 */ -function get_http_response($url, $timeout = 30, $maxBytes = 4194304) +function get_http_response($url, $timeout = 30, $maxBytes = 4194304, $curlWriteFunction = null) { $urlObj = new Url($url); $cleanUrl = $urlObj->idnToAscii(); @@ -75,6 +77,10 @@ function get_http_response($url, $timeout = 30, $maxBytes = 4194304) curl_setopt($ch, CURLOPT_TIMEOUT, $timeout); curl_setopt($ch, CURLOPT_USERAGENT, $userAgent); + if (is_callable($curlWriteFunction)) { + curl_setopt($ch, CURLOPT_WRITEFUNCTION, $curlWriteFunction); + } + // Max download size management curl_setopt($ch, CURLOPT_BUFFERSIZE, 1024); curl_setopt($ch, CURLOPT_NOPROGRESS, false); diff --git a/application/LinkUtils.php b/application/LinkUtils.php index 976474d..c0dd32a 100644 --- a/application/LinkUtils.php +++ b/application/LinkUtils.php @@ -1,5 +1,54 @@ ). - * 3. Use a default charset (default: UTF-8). + * Extract charset from HTTP header if it's defined. * - * @param array $headers HTTP headers array. - * @param string $htmlContent HTML content where to look for charset. - * @param string $defaultCharset Default charset to apply if other methods failed. - * - * @return string Determined charset. - */ -function get_charset($headers, $htmlContent, $defaultCharset = 'utf-8') -{ - if ($charset = headers_extract_charset($headers)) { - return $charset; - } - - if ($charset = html_extract_charset($htmlContent)) { - return $charset; - } - - return $defaultCharset; -} - -/** - * Extract charset from HTTP headers if it's defined. - * - * @param array $headers HTTP headers array. + * @param string $header HTTP header Content-Type line. * * @return bool|string Charset string if found (lowercase), false otherwise. */ -function headers_extract_charset($headers) +function header_extract_charset($header) { - if (! empty($headers['Content-Type']) && strpos($headers['Content-Type'], 'charset=') !== false) { - preg_match('/charset="?([^; ]+)/i', $headers['Content-Type'], $match); - if (! empty($match[1])) { - return strtolower(trim($match[1])); - } + preg_match('/charset="?([^; ]+)/i', $header, $match); + if (! empty($match[1])) { + return strtolower(trim($match[1])); } return false; diff --git a/index.php b/index.php index fb00a9f..ac51038 100644 --- a/index.php +++ b/index.php @@ -1428,16 +1428,10 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history) // If this is an HTTP(S) link, we try go get the page to extract the title (otherwise we will to straight to the edit form.) if (empty($title) && strpos(get_url_scheme($url), 'http') !== false) { // Short timeout to keep the application responsive - list($headers, $content) = get_http_response($url, 4); - if (strpos($headers[0], '200 OK') !== false) { - // Retrieve charset. - $charset = get_charset($headers, $content); - // Extract title. - $title = html_extract_title($content); - // Re-encode title in utf-8 if necessary. - if (! empty($title) && strtolower($charset) != 'utf-8') { - $title = mb_convert_encoding($title, 'utf-8', $charset); - } + // The callback will fill $charset and $title with data from the downloaded page. + get_http_response($url, 25, 4194304, get_curl_download_callback($charset, $title)); + if (! empty($title) && strtolower($charset) != 'utf-8') { + $title = mb_convert_encoding($title, 'utf-8', $charset); } } diff --git a/tests/LinkUtilsTest.php b/tests/LinkUtilsTest.php index 7c0d4b0..ef650f4 100644 --- a/tests/LinkUtilsTest.php +++ b/tests/LinkUtilsTest.php @@ -28,28 +28,14 @@ class LinkUtilsTest extends PHPUnit_Framework_TestCase $this->assertFalse(html_extract_title($html)); } - /** - * Test get_charset() with all priorities. - */ - public function testGetCharset() - { - $headers = array('Content-Type' => 'text/html; charset=Headers'); - $html = 'stuff'; - $default = 'default'; - $this->assertEquals('headers', get_charset($headers, $html, $default)); - $this->assertEquals('html', get_charset(array(), $html, $default)); - $this->assertEquals($default, get_charset(array(), '', $default)); - $this->assertEquals('utf-8', get_charset(array(), '')); - } - /** * Test headers_extract_charset() when the charset is found. */ public function testHeadersExtractExistentCharset() { $charset = 'x-MacCroatian'; - $headers = array('Content-Type' => 'text/html; charset='. $charset); - $this->assertEquals(strtolower($charset), headers_extract_charset($headers)); + $headers = 'text/html; charset='. $charset; + $this->assertEquals(strtolower($charset), header_extract_charset($headers)); } /** @@ -57,11 +43,11 @@ class LinkUtilsTest extends PHPUnit_Framework_TestCase */ public function testHeadersExtractNonExistentCharset() { - $headers = array(); - $this->assertFalse(headers_extract_charset($headers)); + $headers = ''; + $this->assertFalse(header_extract_charset($headers)); - $headers = array('Content-Type' => 'text/html'); - $this->assertFalse(headers_extract_charset($headers)); + $headers = 'text/html'; + $this->assertFalse(header_extract_charset($headers)); } /** @@ -85,6 +71,131 @@ class LinkUtilsTest extends PHPUnit_Framework_TestCase $this->assertFalse(html_extract_charset($html)); } + /** + * Test the download callback with valid value + */ + public function testCurlDownloadCallbackOk() + { + $callback = get_curl_download_callback($charset, $title, 'ut_curl_getinfo_ok'); + $data = [ + 'HTTP/1.1 200 OK', + 'Server: GitHub.com', + 'Date: Sat, 28 Oct 2017 12:01:33 GMT', + 'Content-Type: text/html; charset=utf-8', + 'Status: 200 OK', + 'end' => 'th=device-width">Refactoring · GitHubRefactoring · GitHub', + 'end' => 'th=device-width">Refactoring · GitHubRefactoring · GitHub lg */ .linklist-filters { - margin: 10px 0; + margin: 5px 0; color: #252525; font-size: 0.9em; } @@ -454,7 +454,7 @@ body, .pure-g [class*="pure-u"] { } .linklist-pages { - margin: 10px 0; + margin: 5px 0; color: #252525; text-align: center; } @@ -469,7 +469,7 @@ body, .pure-g [class*="pure-u"] { } .linksperpage { - margin: 10px 0; + margin: 5px 0; text-align: right; color: #252525; font-size: 0.9em; @@ -506,9 +506,29 @@ body, .pure-g [class*="pure-u"] { * CONTENT - LINKLIST ITEMS */ .linklist-item { - margin: 0 0 15px 0; + margin: 0 0 10px 0; background: #f5f5f5; - box-shadow: 2px 2px 0.5em #797979; + box-shadow: 1px 1px 3px #797979; +} + +.linklist-item-buttons { + background: transparent; + position: relative; + width: 23px; + z-index: 99; +} + +.linklist-item-buttons-right { + float: right; + margin-right: -25px; +} + +.linklist-item-buttons * { + display: block; + float: left; + width:100%; + margin: auto; + text-align: center; } .linklist-item-title, .linklist-item-title h2 { @@ -526,7 +546,7 @@ body, .pure-g [class*="pure-u"] { line-height: 30px; } -.linklist-item-title a { +.linklist-item-title h2 a { font-size: 0.7em; color: #252525; text-decoration: none; @@ -538,11 +558,11 @@ body, .pure-g [class*="pure-u"] { color: #1b926c; } -.linklist-item-title a:visited .linklist-link { +.linklist-item-title h2 a:visited .linklist-link { color: #2a4c41; } -.linklist-item-title a:hover, .linklist-item-title .linklist-link:hover{ +.linklist-item-title h2 a:hover, .linklist-item-title .linklist-link:hover{ color: #252525; } @@ -554,8 +574,9 @@ body, .pure-g [class*="pure-u"] { color: #F89406; } -.linklist-item-title .fold-button { +.fold-button { display: none; + color: #252525; } .linklist-item-editbuttons { @@ -585,24 +606,12 @@ body, .pure-g [class*="pure-u"] { .linklist-item-description { position: relative; - padding: 10px; + padding: 0 10px; word-wrap: break-word; color: #252525; line-height: 1.3em; } - { - position: absolute; - left: 3px; - top: 0; - display: block; - content:""; - background: #F89406; - height: 95%; - width: 2px; - z-index: 1; -} - .linklist-item-description a { text-decoration: none; color: #1b926c; @@ -618,32 +627,36 @@ body, .pure-g [class*="pure-u"] { .linklist-item-thumbnail { position: relative; - margin-top: 10px; - padding: 10px; - float: left; + padding: 0 0 0 5px; + margin: 0; + float: right; z-index: 50; + height: 90px; } .linklist-item.private .linklist-item-title::before, -.linklist-item.private .linklist-item-description::before, -.linklist-item.private .linklist-item-thumbnail::before { +.linklist-item.private .linklist-item-description::before { position: absolute; left: 3px; top: 0; display: block; content:""; background: #F89406; - height: 95%; + height: 96%; width: 2px; z-index: 1; } +.linklist-item.private .linklist-item-description::before { + height: 100%; +} + .linklist-item.private .linklist-item-title::before { margin-top: 3px; } .linklist-item-infos { - padding: 8px 8px 5px 8px; + padding: 4px 8px 4px 8px; background: #ddd; color: #252525; } @@ -680,6 +693,8 @@ body, .pure-g [class*="pure-u"] { overflow: hidden; text-overflow: ellipsis; font-size: 0.8em; + height:23px; + line-height:23px; } .linklist-item-infos .mobile-buttons { @@ -693,6 +708,16 @@ body, .pure-g [class*="pure-u"] { height: 16px; } +.linklist-item-infos-controls-group { + display: inline-block; + border-right: 1px solid #5d5d5d; + padding-right: 6px; +} + +.ctrl-edit { + margin: 0 7px; +} + /** 64em -> lg **/ @media screen and (max-width: 64em) { .linklist-item-infos-url { @@ -1284,3 +1309,22 @@ form[name="linkform"].page-form { text-decoration: none; font-weight: bold; } + +/** + * Markdown + */ +.markdown p { + margin: 0 !important; +} + +.markdown p + p { + margin: 0.5em 0 0 0 !important; +} + +.markdown *:first-child { + margin-top: 0 !important; +} + +.markdown *:last-child { + margin-bottom: 5px !important; +} \ No newline at end of file diff --git a/tpl/default/js/shaarli.js b/tpl/default/js/shaarli.js index 09b07ee..cf628e8 100644 --- a/tpl/default/js/shaarli.js +++ b/tpl/default/js/shaarli.js @@ -378,7 +378,7 @@ window.onload = function () { var linkCheckboxes = document.querySelectorAll('.delete-checkbox'); var bar = document.getElementById('actions'); [].forEach.call(linkCheckboxes, function(checkbox) { - checkbox.style.display = 'block'; + checkbox.style.display = 'inline-block'; checkbox.addEventListener('click', function(event) { var count = 0; var linkCheckedCheckboxes = document.querySelectorAll('.delete-checkbox:checked'); diff --git a/tpl/default/linklist.html b/tpl/default/linklist.html index 5dab8e9..c666e30 100644 --- a/tpl/default/linklist.html +++ b/tpl/default/linklist.html @@ -53,9 +53,9 @@ {/loop}