├── .env
├── .github
└── workflows
│ └── ci.yml
├── .gitignore
├── README.md
├── bin
└── console
├── composer.json
├── composer.lock
├── config
├── bundles.php
├── packages
│ ├── cache.yaml
│ ├── doctrine.yaml
│ ├── framework.yaml
│ ├── prod
│ │ └── doctrine.yaml
│ ├── routing.yaml
│ ├── toolforge.yaml
│ └── twig.yaml
├── preload.php
├── routes
│ ├── annotations.yaml
│ └── framework.yaml
└── services.yaml
├── data
└── works.json
├── i18n
├── bn.json
├── de.json
├── en.json
├── pl.json
└── sr.json
├── phpcs.xml
├── public
├── img
│ └── loading.gif
├── index.php
├── scripts.js
├── style.css
└── toolinfo.json
├── src
├── Command
│ └── BuildCommand.php
├── Controller
│ └── HomeController.php
├── Kernel.php
└── WsCatBrowser.php
├── symfony.lock
└── templates
└── base.html.twig
/.env:
--------------------------------------------------------------------------------
1 | APP_ENV=prod
2 | APP_SECRET=changethis
3 |
4 | REPLICAS_HOST_S1=s1.analytics.db.svc.wikimedia.cloud
5 | REPLICAS_HOST_S2=s2.analytics.db.svc.wikimedia.cloud
6 | REPLICAS_HOST_S3=s3.analytics.db.svc.wikimedia.cloud
7 | REPLICAS_HOST_S4=s4.analytics.db.svc.wikimedia.cloud
8 | REPLICAS_HOST_S5=s5.analytics.db.svc.wikimedia.cloud
9 | REPLICAS_HOST_S6=s6.analytics.db.svc.wikimedia.cloud
10 | REPLICAS_HOST_S7=s7.analytics.db.svc.wikimedia.cloud
11 | REPLICAS_HOST_S8=s8.analytics.db.svc.wikimedia.cloud
12 | REPLICAS_PORT_S1=3306
13 | REPLICAS_PORT_S2=3306
14 | REPLICAS_PORT_S3=3306
15 | REPLICAS_PORT_S4=3306
16 | REPLICAS_PORT_S5=3306
17 | REPLICAS_PORT_S6=3306
18 | REPLICAS_PORT_S7=3306
19 | REPLICAS_PORT_S8=3306
20 |
21 | REPLICAS_USERNAME=uxxxx
22 | REPLICAS_PASSWORD=
23 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on: [push, pull_request]
4 |
5 | jobs:
6 | build:
7 |
8 | env:
9 | APP_ENV: test
10 |
11 | strategy:
12 | matrix:
13 | php: [ '7.2', '7.3', '7.4', '8.0' ]
14 |
15 | runs-on: ubuntu-latest
16 |
17 | steps:
18 | - name: Checkout
19 | uses: actions/checkout@v1
20 |
21 | - name: Set up PHP
22 | uses: shivammathur/setup-php@v2
23 | with:
24 | php-version: ${{matrix.php}}
25 | coverage: none
26 | extensions: ast
27 |
28 | - name: Install
29 | run: |
30 | composer install
31 |
32 | - name: Test
33 | run: |
34 | composer test
35 | git status
36 | git status | grep "nothing to commit, working tree clean"
37 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /nbproject
2 | /.idea
3 | /.env.local
4 | /vendor
5 | /var
6 | /public/categories*.json
7 | /public/works*.json
8 | /public/sites.json
9 |
10 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | Wikisource category browser
2 | ===========================
3 |
4 | This script is running at https://ws-cat-browser.toolforge.org/
5 | and is updated weekly.
6 |
7 | ## Missing languages?
8 |
9 | If your Wikisource is missing, please add relevant sitelinks to the following items on Wikdiata:
10 |
11 | 1. the [root category (Q1281)](https://www.wikidata.org/wiki/Q1281), and
12 | 2. the category for [validated indices Q15634466](https://www.wikidata.org/wiki/Q15634466)
13 | (e.g. `Index_Validated`, `Pagine_indice_SAL_100%`).
14 |
15 | Alternatively, you can [create a task on Phabricator](https://phabricator.wikimedia.org/maniphest/task/edit/form/1/?project=wikisource)
16 | and just tell us the names of the above categories.
17 |
18 | ## Tracking
19 | At some point, the git repository of this tool will track changes of (or at least additions to) validated works.
20 | For now, the `data/works.json` is manually updated with a [formatted](http://jsonformatter.curiousconcept.com/)
21 | version of the generated `works.json` file, so added and removed entries can be tracked.
22 |
23 | ## Development
24 |
25 | To work with this tool locally, edit the list of languages specified in `download_dumps.sh`,
26 | then run this script to download the relevant database dumps.
27 | Import these with something like the following:
28 |
29 | Then set `$dbs`, `$dbuser`, and `$dbpass` in `config.php` (copy it from `config_dist.php`)
30 | and run `php build.php`.
31 |
32 |
--------------------------------------------------------------------------------
/bin/console:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env php
2 | =7.2.5",
18 | "ext-ctype": "*",
19 | "ext-iconv": "*",
20 | "ext-json": "*",
21 | "symfony/console": "5.3.*",
22 | "symfony/dotenv": "5.3.*",
23 | "symfony/flex": "^1.3.1",
24 | "symfony/framework-bundle": "5.3.*",
25 | "symfony/runtime": "5.3.*",
26 | "symfony/twig-bundle": "5.3.*",
27 | "symfony/yaml": "5.3.*",
28 | "wikimedia/toolforge-bundle": "^1.4"
29 | },
30 | "require-dev": {
31 | "mediawiki/mediawiki-codesniffer": "^37.0",
32 | "mediawiki/minus-x": "^1.0"
33 | },
34 | "config": {
35 | "optimize-autoloader": true,
36 | "preferred-install": {
37 | "*": "dist"
38 | },
39 | "sort-packages": true,
40 | "platform": {
41 | "php": "7.2.31"
42 | }
43 | },
44 | "autoload": {
45 | "psr-4": {
46 | "App\\": "src/"
47 | }
48 | },
49 | "autoload-dev": {
50 | "psr-4": {
51 | "App\\Tests\\": "tests/"
52 | }
53 | },
54 | "replace": {
55 | "symfony/polyfill-ctype": "*",
56 | "symfony/polyfill-iconv": "*",
57 | "symfony/polyfill-php72": "*"
58 | },
59 | "scripts": {
60 | "auto-scripts": {
61 | "cache:clear": "symfony-cmd",
62 | "assets:install %PUBLIC_DIR%": "symfony-cmd"
63 | },
64 | "post-install-cmd": [
65 | "@auto-scripts"
66 | ],
67 | "post-update-cmd": [
68 | "@auto-scripts"
69 | ],
70 | "test": [
71 | "composer validate",
72 | "phpcs -s .",
73 | "./bin/console lint:twig ./templates",
74 | "./bin/console lint:yaml ./config",
75 | "minus-x check ."
76 | ]
77 | },
78 | "conflict": {
79 | "symfony/symfony": "*"
80 | },
81 | "extra": {
82 | "symfony": {
83 | "allow-contrib": false,
84 | "require": "5.3.*"
85 | }
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/config/bundles.php:
--------------------------------------------------------------------------------
1 | [ 'all' => true ],
5 | Symfony\Bundle\TwigBundle\TwigBundle::class => [ 'all' => true ],
6 | Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => [ 'all' => true ],
7 | Wikimedia\ToolforgeBundle\ToolforgeBundle::class => [ 'all' => true ],
8 | ];
9 |
--------------------------------------------------------------------------------
/config/packages/cache.yaml:
--------------------------------------------------------------------------------
1 | framework:
2 | cache:
3 | prefix_seed: ws-cat-browser
4 | pools:
5 | cache.replicas:
6 | adapter: cache.adapter.filesystem
7 | default_lifetime: 600
8 |
--------------------------------------------------------------------------------
/config/packages/doctrine.yaml:
--------------------------------------------------------------------------------
1 | doctrine:
2 | dbal:
3 | connections:
4 | toolforge_s1:
5 | host: '%env(REPLICAS_HOST_S1)%'
6 | port: '%env(REPLICAS_PORT_S1)%'
7 | user: '%env(REPLICAS_USERNAME)%'
8 | password: '%env(REPLICAS_PASSWORD)%'
9 | toolforge_s2:
10 | host: '%env(REPLICAS_HOST_S2)%'
11 | port: '%env(REPLICAS_PORT_S2)%'
12 | user: '%env(REPLICAS_USERNAME)%'
13 | password: '%env(REPLICAS_PASSWORD)%'
14 | toolforge_s3:
15 | host: '%env(REPLICAS_HOST_S3)%'
16 | port: '%env(REPLICAS_PORT_S3)%'
17 | user: '%env(REPLICAS_USERNAME)%'
18 | password: '%env(REPLICAS_PASSWORD)%'
19 | toolforge_s4:
20 | host: '%env(REPLICAS_HOST_S4)%'
21 | port: '%env(REPLICAS_PORT_S4)%'
22 | user: '%env(REPLICAS_USERNAME)%'
23 | password: '%env(REPLICAS_PASSWORD)%'
24 | toolforge_s5:
25 | host: '%env(REPLICAS_HOST_S5)%'
26 | port: '%env(REPLICAS_PORT_S5)%'
27 | user: '%env(REPLICAS_USERNAME)%'
28 | password: '%env(REPLICAS_PASSWORD)%'
29 | toolforge_s6:
30 | host: '%env(REPLICAS_HOST_S6)%'
31 | port: '%env(REPLICAS_PORT_S6)%'
32 | user: '%env(REPLICAS_USERNAME)%'
33 | password: '%env(REPLICAS_PASSWORD)%'
34 | toolforge_s7:
35 | host: '%env(REPLICAS_HOST_S7)%'
36 | port: '%env(REPLICAS_PORT_S7)%'
37 | user: '%env(REPLICAS_USERNAME)%'
38 | password: '%env(REPLICAS_PASSWORD)%'
39 | toolforge_s8:
40 | host: '%env(REPLICAS_HOST_S8)%'
41 | port: '%env(REPLICAS_PORT_S8)%'
42 | user: '%env(REPLICAS_USERNAME)%'
43 | password: '%env(REPLICAS_PASSWORD)%'
44 |
--------------------------------------------------------------------------------
/config/packages/framework.yaml:
--------------------------------------------------------------------------------
1 | # see https://symfony.com/doc/current/reference/configuration/framework.html
2 | framework:
3 | secret: '%env(APP_SECRET)%'
4 | http_method_override: false
5 |
6 | session:
7 | handler_id: null
8 | cookie_secure: auto
9 | cookie_samesite: lax
10 | storage_factory_id: session.storage.factory.native
11 |
12 | php_errors:
13 | log: true
14 |
15 | when@test:
16 | framework:
17 | test: true
18 |
--------------------------------------------------------------------------------
/config/packages/prod/doctrine.yaml:
--------------------------------------------------------------------------------
1 | framework:
2 | cache:
3 | pools:
4 | doctrine.result_cache_pool:
5 | adapter: cache.app
6 | doctrine.system_cache_pool:
7 | adapter: cache.system
8 |
--------------------------------------------------------------------------------
/config/packages/routing.yaml:
--------------------------------------------------------------------------------
1 | framework:
2 | router:
3 | utf8: true
4 |
5 | # Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
6 | # See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
7 | #default_uri: http://localhost
8 |
9 | when@prod:
10 | framework:
11 | router:
12 | strict_requirements: null
13 |
--------------------------------------------------------------------------------
/config/packages/toolforge.yaml:
--------------------------------------------------------------------------------
1 | toolforge:
2 | intuition:
3 | domain: 'ws-cat-browser'
4 |
--------------------------------------------------------------------------------
/config/packages/twig.yaml:
--------------------------------------------------------------------------------
1 | twig:
2 | default_path: '%kernel.project_dir%/templates'
3 |
4 | when@test:
5 | twig:
6 | strict_variables: true
7 |
--------------------------------------------------------------------------------
/config/preload.php:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | ./src/Controller/
8 |
9 |
10 | .
11 | ./vendor/
12 | ./var/
13 | ./bin/.phpunit/
14 | ./.phan/
15 |
16 |
--------------------------------------------------------------------------------
/public/img/loading.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wikisource/ws-cat-browser/ff043c8cb98a0c2641c9a3a31685101758b2d28b/public/img/loading.gif
--------------------------------------------------------------------------------
/public/index.php:
--------------------------------------------------------------------------------
1 | ')
36 | .addClass('alert-box info radius hide')
37 | .text('Error: this Wikisource language (' + lang + ') appears to not have any validated works.');
38 | $catlist.replaceWith($notice);
39 | $notice.removeClass("hide").fadeIn(400);
40 | });
41 | }
42 | });
43 | });
44 |
45 | function addCats(allCats, $parent, cat, catLabel) {
46 | $.each(cat, function(i, subcat){
47 | var title = subcat.replace(/_/g, " ");
48 | if (subcat.substr(0, catLabel.length + 1) === catLabel + ":") {
49 | title = '' + title.substr(catLabel.length + 1) + '';
50 | } else {
51 | var encodedCat = encodeURIComponent(subcat);
52 | title = "" + title + ""
53 | + ""
54 | + "
"
56 | + "";
57 | }
58 | var $newItem = $("
" + title + "
");
59 | $parent.append($newItem);
60 | $newItem.find("span").on("click", function(){
61 | var $sublist = $(this).next("ol");
62 | if ($sublist.is(":empty")) {
63 | $(this).addClass("open").removeClass("closed");
64 | addCats(allCats, $sublist, allCats[subcat], catLabel);
65 | } else {
66 | $(this).addClass("closed").removeClass("open");
67 | $sublist.children().remove();
68 | }
69 | })
70 | });
71 | }
--------------------------------------------------------------------------------
/public/style.css:
--------------------------------------------------------------------------------
1 | ol.c {
2 | border-left: thin solid lightblue;
3 | list-style-type: none;
4 | padding-left: 0.3em;
5 | }
6 | ol.c span { cursor:s-resize; }
7 | ol.c span.open { cursor:n-resize; }
8 | ol.c span:hover { background-color:#efefef; }
9 | ol.c a:hover { text-decoration:underline; }
10 | ol.c a.epub:hover { text-decoration:none; }
11 |
12 | p.loading { color:#999; }
13 |
--------------------------------------------------------------------------------
/public/toolinfo.json:
--------------------------------------------------------------------------------
1 | {
2 | "name" : "ws-cat-browser",
3 | "title" : "Wikisource category browser",
4 | "description" : "A generator for a tree browser for categories of validated Wikisource works, in multiple languages.",
5 | "url" : "https://ws-cat-browser.toolforge.org/",
6 | "keywords" : "wikisource, categories, ebooks, epub",
7 | "author" : "Sam Wilson",
8 | "repository" : "https://github.com/wikisource/ws-cat-browser.git"
9 | }
10 |
--------------------------------------------------------------------------------
/src/Command/BuildCommand.php:
--------------------------------------------------------------------------------
1 | replicasClient = $replicasClient;
39 | $this->cache = $cache;
40 | $this->wsCatBrowser = $wsCatBrowser;
41 | }
42 |
43 | public function configure() {
44 | $this->addOption(
45 | 'lang', 'l', InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED,
46 | 'Wikisource language code.'
47 | );
48 | }
49 |
50 | /**
51 | * @param InputInterface $input
52 | * @param OutputInterface $output
53 | * @return int
54 | */
55 | public function execute( InputInterface $input, OutputInterface $output ) {
56 | $timeStart = microtime( true );
57 | $this->out = $output;
58 |
59 | $siteInfo = $this->getSiteInfo();
60 | $this->out->writeln( 'Writing sites.json' );
61 | file_put_contents( $this->wsCatBrowser->getSitesFilename(), json_encode( $siteInfo ) );
62 |
63 | // For each site, build categories.json and works.json
64 | $langs = $input->getOption( 'lang' );
65 | foreach ( $siteInfo as $lang => $info ) {
66 | if ( $langs && !in_array( $lang, $langs ) ) {
67 | $this->out->writeln( 'Skipping ' . $lang );
68 | continue;
69 | }
70 | $db = $this->replicasClient->getConnection( $lang === 'www' ? 'sourceswiki' : $lang . 'wikisource' );
71 | $this->buildOneLang( $db, $lang, $info['index_cat'], $info['cat_label'], $info['index_ns'] );
72 | }
73 |
74 | // Report completion.
75 | $minutes = round( ( microtime( true ) - $timeStart ) / 60, 1 );
76 | $this->out->writeln( "Done. Total time: $minutes minutes." );
77 | return Command::SUCCESS;
78 | }
79 |
80 | /**
81 | * @return array
82 | */
83 | private function getSiteInfo() {
84 | $this->out->writeln( 'Getting site information . . . ' );
85 | $rootCatItem = 'Q1281';
86 | $validatedCatItem = 'Q15634466';
87 | $rootCats = $this->siteLinks( $rootCatItem );
88 | $validatedCats = $this->siteLinks( $validatedCatItem );
89 | $out = [];
90 | foreach ( $rootCats as $site => $rootCat ) {
91 | // If both a root cat and an Index cat exist.
92 | if ( isset( $validatedCats[$site] ) ) {
93 | $lang = $site === 'sourceswiki'
94 | ? $lang = 'www'
95 | : substr( $site, 0, -strlen( 'wikisource' ) );
96 | $nsInfo = $this->getNamespaceInfo( $lang );
97 | $catLabel = $nsInfo['Category']['*'];
98 | // Strip cat label from cats
99 | $rootCat = substr( $rootCat, strlen( $catLabel ) + 1 );
100 | $indexCat = substr( $validatedCats[$site], strlen( $catLabel ) + 1 );
101 | // Put it all together, replacing spaces with underscores.
102 | $out[$lang] = [
103 | 'cat_label' => $catLabel,
104 | 'cat_root' => str_replace( ' ', '_', $rootCat ),
105 | 'index_ns' => $nsInfo['Index']['id'],
106 | 'index_cat' => str_replace( ' ', '_', $indexCat ),
107 | ];
108 | }
109 | }
110 | $this->out->writeln( 'done' );
111 | return $out;
112 | }
113 |
114 | /**
115 | * @param string $lang
116 | * @return mixed
117 | */
118 | private function getNamespaceInfo( string $lang ) {
119 | return $this->cache->get( 'namespaces_' . $lang, function ( CacheItemInterface $cacheItem ) use ( $lang ) {
120 | $cacheItem->expiresAfter( new DateInterval( 'P7D' ) );
121 | $this->out->writeln( "Getting namespaces for $lang" );
122 | $url = "https://$lang.wikisource.org/w/api.php?action=query&meta=siteinfo&siprop=namespaces&format=json";
123 | $data = json_decode( file_get_contents( $url ), true );
124 | if ( !isset( $data['query']['namespaces'] ) ) {
125 | return false;
126 | }
127 | $desired = [ 'Index', 'Category' ];
128 | $out = [];
129 | foreach ( $data['query']['namespaces'] as $ns ) {
130 | if ( isset( $ns['canonical'] ) && in_array( $ns['canonical'], $desired ) ) {
131 | $out[$ns['canonical']] = $ns;
132 | }
133 | }
134 | return $out;
135 | } );
136 | }
137 |
138 | /**
139 | * Get sitelinks for the given item ID.
140 | * @param string $item Q-number.
141 | * @return string[] Page names, keyed by site name
142 | */
143 | private function siteLinks( $item ) {
144 | return $this->cache->get( 'site_links_' . $item, function ( ItemInterface $cacheItem ) use ( $item ) {
145 | $cacheItem->expiresAfter( new DateInterval( 'P7D' ) );
146 | $params = [
147 | 'action' => 'wbgetentities',
148 | 'format' => 'json',
149 | 'ids' => $item,
150 | 'props' => 'sitelinks',
151 | ];
152 | $url = 'https://www.wikidata.org/w/api.php?' . http_build_query( $params );
153 | $this->out->write( "Getting site links from Wikidata for $item . . . " );
154 | $data = json_decode( file_get_contents( $url ), true );
155 | $cats = [];
156 | if ( isset( $data['entities'][$item]['sitelinks'] ) ) {
157 | foreach ( $data['entities'][$item]['sitelinks'] as $sitelink ) {
158 | $cats[$sitelink['site']] = $sitelink['title'];
159 | }
160 | }
161 | $this->out->writeln( 'found ' . count( $cats ) );
162 | return $cats;
163 | } );
164 | }
165 |
166 | /**
167 | * @param Connection $db
168 | * @param string $lang
169 | * @param string $indexRoot
170 | * @param string $catLabel
171 | * @param string $indexNs
172 | * @return bool
173 | */
174 | private function buildOneLang( Connection $db, $lang, $indexRoot, $catLabel, $indexNs ) {
175 | echo "Getting list of validated works for '$lang' . . . ";
176 | $validatedWorks = $this->getValidatedWorks( $db, $indexRoot, $indexNs );
177 | file_put_contents( $this->wsCatBrowser->getDataFilename( 'works', $lang ), json_encode( $validatedWorks ) );
178 | echo "done\n";
179 |
180 | echo "Getting category data for '$lang' . . . ";
181 | $allCats = [];
182 | foreach ( $validatedWorks as $indexTitle => $workTitle ) {
183 | $catTree = $this->getAllCats( $db, $lang, $workTitle, 0, [], [], $catLabel );
184 | $allCats = array_map( 'array_unique', array_merge_recursive( $allCats, $catTree ) );
185 | }
186 | echo "done\n";
187 |
188 | echo "Sorting categories for '$lang' . . . ";
189 | foreach ( $allCats as $cat => $cats ) {
190 | sort( $allCats[$cat] );
191 | }
192 | echo "done\n";
193 |
194 | // Make sure the category list was successfully built before replacing the old JSON file.
195 | if ( count( $allCats ) > 0 ) {
196 | $catFile = $this->wsCatBrowser->getDataFilename( 'categories', $lang );
197 | echo "Writing $catFile\n";
198 | file_put_contents( $catFile, json_encode( $allCats ) );
199 | return true;
200 | } else {
201 | echo "No validated works found for $lang!\n";
202 | return false;
203 | }
204 | }
205 |
206 | /**
207 | * Get a list of validated works.
208 | * @param Connection $db
209 | * @param string $indexRoot
210 | * @param string $indexNs
211 | * @return array
212 | */
213 | private function getValidatedWorks( Connection $db, string $indexRoot, string $indexNs ) {
214 | $sql = 'SELECT '
215 | . ' indexpage.page_title AS indextitle,'
216 | . ' workpage.page_title AS worktitle'
217 | . ' FROM page AS indexpage '
218 | . ' JOIN categorylinks ON cl_from=indexpage.page_id '
219 | . ' JOIN pagelinks ON pl_from=indexpage.page_id '
220 | . ' JOIN page AS workpage ON workpage.page_title=pl_title '
221 | . ' WHERE '
222 | . ' workpage.page_title NOT LIKE "%/%" '
223 | . ' AND pl_namespace = 0 '
224 | . ' AND workpage.page_namespace = 0'
225 | . ' AND indexpage.page_namespace = :index_ns'
226 | . ' AND cl_to = :index_root';
227 | $stmt = $db->executeQuery( $sql, [
228 | 'index_ns' => $indexNs,
229 | 'index_root' => $indexRoot,
230 | ] );
231 | $out = [];
232 | foreach ( $stmt->fetchAllAssociative() as $res ) {
233 | $out[$res['indextitle']] = $res['worktitle'];
234 | }
235 | return $out;
236 | }
237 |
238 | /**
239 | * @param Connection $db
240 | * @param string $lang
241 | * @param string $baseCat
242 | * @param string $ns
243 | * @param array $catList
244 | * @param array $tracker
245 | * @param string $catLabel
246 | * @return array|mixed
247 | */
248 | private function getAllCats( Connection $db, $lang, $baseCat, $ns, $catList, $tracker, $catLabel ) {
249 | $cats = $this->getCats( $db, $baseCat, $ns, $catLabel );
250 | if ( empty( $cats ) ) {
251 | return $catList;
252 | }
253 | if ( $ns == 0 ) {
254 | $tracker = [];
255 | }
256 |
257 | // For each cat, create an element in the output array.
258 | foreach ( $cats as $cat ) {
259 | // echo "Getting supercats of $baseCat via $cat.\n";
260 | $tracker_tag = [ $baseCat, $cat ];
261 | if ( in_array( $tracker_tag, $tracker ) ) {
262 | echo "A category loop has been detected in $lang:\n\ndigraph G {\n";
263 | foreach ( $tracker as $trackerItem ) {
264 | echo '"' . str_replace( '"', '\"', $trackerItem[0] )
265 | . '" -> "' . str_replace( '"', '\"', $trackerItem[1] ) . '"' . "\n";
266 | }
267 | echo "}\n";
268 | continue;
269 | }
270 | array_push( $tracker, $tracker_tag );
271 | // Add all of $cat's parents to the $catList.
272 | $superCats = $this->getAllCats( $db, $lang, $cat, 14, $catList, $tracker, $catLabel );
273 | $catList = array_merge_recursive( $catList, $superCats );
274 | // Initialise $cat as a parent if it's not there yet.
275 | if ( !isset( $catList[$cat] ) ) {
276 | $catList[$cat] = [];
277 | }
278 | // Then add the $baseCat as a child.
279 | if ( !in_array( $baseCat, $catList[$cat] ) ) {
280 | array_push( $catList[$cat], $baseCat );
281 | }
282 | $catList = array_map( 'array_unique', $catList );
283 | }
284 | return array_map( 'array_unique', $catList );
285 | }
286 |
287 | /**
288 | * @param Connection $db
289 | * @param string $baseCat
290 | * @param string $ns
291 | * @param string $catLabel
292 | * @return array
293 | */
294 | private function getCats( Connection $db, $baseCat, $ns, $catLabel = 'Category' ) {
295 | // Get the starting categories.
296 | $sql = 'SELECT cl_to AS catname FROM page '
297 | . ' JOIN categorylinks ON cl_from=page_id'
298 | . ' WHERE page_title = :page_title'
299 | . ' AND page_namespace = :page_namespace ';
300 | $result = $db->executeQuery( $sql, [
301 | 'page_title' => str_replace( $catLabel . ':', '', $baseCat ),
302 | 'page_namespace' => $ns,
303 | ] );
304 | $cats = [];
305 | foreach ( $result->fetchAllAssociative() as $cat ) {
306 | $cats[] = $catLabel . ':' . $cat['catname'];
307 | }
308 | return $cats;
309 | }
310 |
311 | }
312 |
--------------------------------------------------------------------------------
/src/Controller/HomeController.php:
--------------------------------------------------------------------------------
1 | wsCatBrowser = $wsCatBrowser;
23 | }
24 |
25 | /**
26 | * @Route("/", name="home")
27 | * @param Request $request
28 | * @return Response
29 | */
30 | public function home( Request $request ) {
31 | $lang = $request->get( 'lang' );
32 | if ( !$lang ) {
33 | $lang = 'en';
34 | }
35 |
36 | $siteInfo = file_exists( $this->wsCatBrowser->getSitesFilename() )
37 | ? json_decode( file_get_contents( $this->wsCatBrowser->getSitesFilename() ), true )
38 | : [];
39 |
40 | $err = null;
41 | if ( !array_key_exists( $lang, $siteInfo ) ) {
42 | $err = [ 'language-not-found', [ $lang ] ];
43 | $lang = 'en';
44 | }
45 |
46 | return $this->render( 'base.html.twig', [
47 | 'site_info' => $siteInfo,
48 | 'lang' => $lang,
49 | 'suffix' => $lang === 'en' ? '' : '_' . $lang,
50 | 'err' => $err,
51 | ] );
52 | }
53 |
54 | /**
55 | * @Route("/meta.json", name="meta");
56 | * @param Request $request
57 | * @return JsonResponse
58 | */
59 | public function meta( Request $request ) {
60 | $siteInfo = json_decode( file_get_contents( $this->wsCatBrowser->getSitesFilename() ), true );
61 | $lang = $request->get( 'lang' );
62 | if ( !array_key_exists( $lang, $siteInfo ) ) {
63 | $lang = 'en';
64 | }
65 |
66 | $worksFile = $this->wsCatBrowser->getDataFilename( 'works', $lang );
67 | $categoriesFile = $this->wsCatBrowser->getDataFilename( 'categories', $lang );
68 | $metadata = [
69 | 'works_count' => count( (array)json_decode( file_get_contents( $worksFile ) ) ),
70 | 'last_modified' => date( 'Y-m-d H:i', filemtime( $categoriesFile ) ),
71 | 'category_label' => $siteInfo[$lang]['cat_label'],
72 | 'category_root' => $siteInfo[$lang]['cat_root'],
73 | ];
74 | return new JsonResponse( $metadata );
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/src/Kernel.php:
--------------------------------------------------------------------------------
1 | import( '../config/{packages}/*.yaml' );
18 | $container->import( '../config/{packages}/' . $this->environment . '/*.yaml' );
19 |
20 | if ( is_file( \dirname( __DIR__ ) . '/config/services.yaml' ) ) {
21 | $container->import( '../config/services.yaml' );
22 | $container->import( '../config/{services}_' . $this->environment . '.yaml' );
23 | } else {
24 | $container->import( '../config/{services}.php' );
25 | }
26 | }
27 |
28 | /**
29 | * @param RoutingConfigurator $routes
30 | */
31 | protected function configureRoutes( RoutingConfigurator $routes ): void {
32 | $routes->import( '../config/{routes}/' . $this->environment . '/*.yaml' );
33 | $routes->import( '../config/{routes}/*.yaml' );
34 |
35 | if ( is_file( \dirname( __DIR__ ) . '/config/routes.yaml' ) ) {
36 | $routes->import( '../config/routes.yaml' );
37 | } else {
38 | $routes->import( '../config/{routes}.php' );
39 | }
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/src/WsCatBrowser.php:
--------------------------------------------------------------------------------
1 | publicDir = $projectDir . '/public';
15 | }
16 |
17 | /**
18 | * Get the name of a xxx_xx.json file.
19 | * @param string $name
20 | * @param string $lang
21 | * @return string
22 | */
23 | public function getDataFilename( $name, $lang ): string {
24 | $suffix = ( $lang == 'en' ) ? '' : '_' . $lang;
25 | return $this->publicDir . '/' . $name . $suffix . '.json';
26 | }
27 |
28 | /**
29 | * Get the filesystem path to the sites.json file.
30 | * @return string
31 | */
32 | public function getSitesFilename(): string {
33 | return $this->publicDir . '/sites.json';
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/symfony.lock:
--------------------------------------------------------------------------------
1 | {
2 | "composer/semver": {
3 | "version": "3.2.5"
4 | },
5 | "composer/spdx-licenses": {
6 | "version": "1.5.5"
7 | },
8 | "doctrine/annotations": {
9 | "version": "1.0",
10 | "recipe": {
11 | "repo": "github.com/symfony/recipes",
12 | "branch": "master",
13 | "version": "1.0",
14 | "ref": "a2759dd6123694c8d901d0ec80006e044c2e6457"
15 | },
16 | "files": [
17 | "config/routes/annotations.yaml"
18 | ]
19 | },
20 | "doctrine/cache": {
21 | "version": "2.1.1"
22 | },
23 | "doctrine/collections": {
24 | "version": "1.6.8"
25 | },
26 | "doctrine/dbal": {
27 | "version": "3.1.2"
28 | },
29 | "doctrine/deprecations": {
30 | "version": "v0.5.3"
31 | },
32 | "doctrine/doctrine-bundle": {
33 | "version": "2.4",
34 | "recipe": {
35 | "repo": "github.com/symfony/recipes",
36 | "branch": "master",
37 | "version": "2.4",
38 | "ref": "bac5c852ff628886de2753215fe5eb1f9ce980fb"
39 | },
40 | "files": [
41 | "config/packages/doctrine.yaml",
42 | "config/packages/prod/doctrine.yaml",
43 | "config/packages/test/doctrine.yaml",
44 | "src/Entity/.gitignore",
45 | "src/Repository/.gitignore"
46 | ]
47 | },
48 | "doctrine/event-manager": {
49 | "version": "1.1.1"
50 | },
51 | "doctrine/lexer": {
52 | "version": "1.2.1"
53 | },
54 | "doctrine/persistence": {
55 | "version": "2.2.2"
56 | },
57 | "doctrine/sql-formatter": {
58 | "version": "1.1.1"
59 | },
60 | "krinkle/intuition": {
61 | "version": "v1.2.0"
62 | },
63 | "mediawiki/mediawiki-codesniffer": {
64 | "version": "v34.0.0"
65 | },
66 | "mediawiki/minus-x": {
67 | "version": "1.1.1"
68 | },
69 | "mediawiki/oauthclient": {
70 | "version": "1.1.0"
71 | },
72 | "psr/cache": {
73 | "version": "1.0.1"
74 | },
75 | "psr/container": {
76 | "version": "1.1.1"
77 | },
78 | "psr/event-dispatcher": {
79 | "version": "1.0.0"
80 | },
81 | "psr/log": {
82 | "version": "1.1.4"
83 | },
84 | "sebastian/diff": {
85 | "version": "3.0.3"
86 | },
87 | "squizlabs/php_codesniffer": {
88 | "version": "3.0",
89 | "recipe": {
90 | "repo": "github.com/symfony/recipes-contrib",
91 | "branch": "master",
92 | "version": "3.0",
93 | "ref": "0dc9cceda799fd3a08b96987e176a261028a3709"
94 | }
95 | },
96 | "symfony/cache": {
97 | "version": "v5.3.7"
98 | },
99 | "symfony/cache-contracts": {
100 | "version": "v2.4.0"
101 | },
102 | "symfony/config": {
103 | "version": "v5.3.4"
104 | },
105 | "symfony/console": {
106 | "version": "5.3",
107 | "recipe": {
108 | "repo": "github.com/symfony/recipes",
109 | "branch": "master",
110 | "version": "5.3",
111 | "ref": "da0c8be8157600ad34f10ff0c9cc91232522e047"
112 | },
113 | "files": [
114 | "bin/console"
115 | ]
116 | },
117 | "symfony/dependency-injection": {
118 | "version": "v5.3.7"
119 | },
120 | "symfony/deprecation-contracts": {
121 | "version": "v2.4.0"
122 | },
123 | "symfony/doctrine-bridge": {
124 | "version": "v5.3.7"
125 | },
126 | "symfony/dotenv": {
127 | "version": "v5.3.7"
128 | },
129 | "symfony/error-handler": {
130 | "version": "v5.3.7"
131 | },
132 | "symfony/event-dispatcher": {
133 | "version": "v5.3.7"
134 | },
135 | "symfony/event-dispatcher-contracts": {
136 | "version": "v2.4.0"
137 | },
138 | "symfony/filesystem": {
139 | "version": "v5.3.4"
140 | },
141 | "symfony/finder": {
142 | "version": "v5.3.7"
143 | },
144 | "symfony/flex": {
145 | "version": "1.0",
146 | "recipe": {
147 | "repo": "github.com/symfony/recipes",
148 | "branch": "master",
149 | "version": "1.0",
150 | "ref": "c0eeb50665f0f77226616b6038a9b06c03752d8e"
151 | },
152 | "files": [
153 | ".env"
154 | ]
155 | },
156 | "symfony/framework-bundle": {
157 | "version": "5.3",
158 | "recipe": {
159 | "repo": "github.com/symfony/recipes",
160 | "branch": "master",
161 | "version": "5.3",
162 | "ref": "414ba00ad43fa71be42c7906a551f1831716b03c"
163 | },
164 | "files": [
165 | "config/packages/cache.yaml",
166 | "config/packages/framework.yaml",
167 | "config/preload.php",
168 | "config/routes/framework.yaml",
169 | "config/services.yaml",
170 | "public/index.php",
171 | "src/Controller/.gitignore",
172 | "src/Kernel.php"
173 | ]
174 | },
175 | "symfony/http-client": {
176 | "version": "v5.3.7"
177 | },
178 | "symfony/http-client-contracts": {
179 | "version": "v2.4.0"
180 | },
181 | "symfony/http-foundation": {
182 | "version": "v5.3.7"
183 | },
184 | "symfony/http-kernel": {
185 | "version": "v5.3.7"
186 | },
187 | "symfony/polyfill-intl-grapheme": {
188 | "version": "v1.23.1"
189 | },
190 | "symfony/polyfill-intl-normalizer": {
191 | "version": "v1.23.0"
192 | },
193 | "symfony/polyfill-mbstring": {
194 | "version": "v1.23.1"
195 | },
196 | "symfony/polyfill-php73": {
197 | "version": "v1.23.0"
198 | },
199 | "symfony/polyfill-php80": {
200 | "version": "v1.23.1"
201 | },
202 | "symfony/polyfill-php81": {
203 | "version": "v1.23.0"
204 | },
205 | "symfony/process": {
206 | "version": "v5.3.7"
207 | },
208 | "symfony/routing": {
209 | "version": "5.3",
210 | "recipe": {
211 | "repo": "github.com/symfony/recipes",
212 | "branch": "master",
213 | "version": "5.3",
214 | "ref": "44633353926a0382d7dfb0530922c5c0b30fae11"
215 | },
216 | "files": [
217 | "config/packages/routing.yaml",
218 | "config/routes.yaml"
219 | ]
220 | },
221 | "symfony/runtime": {
222 | "version": "v5.3.4"
223 | },
224 | "symfony/service-contracts": {
225 | "version": "v2.4.0"
226 | },
227 | "symfony/string": {
228 | "version": "v5.3.7"
229 | },
230 | "symfony/translation-contracts": {
231 | "version": "v2.4.0"
232 | },
233 | "symfony/twig-bridge": {
234 | "version": "v5.3.7"
235 | },
236 | "symfony/twig-bundle": {
237 | "version": "5.3",
238 | "recipe": {
239 | "repo": "github.com/symfony/recipes",
240 | "branch": "master",
241 | "version": "5.3",
242 | "ref": "3dd530739a4284e3272274c128dbb7a8140a66f1"
243 | },
244 | "files": [
245 | "config/packages/twig.yaml",
246 | "templates/base.html.twig"
247 | ]
248 | },
249 | "symfony/var-dumper": {
250 | "version": "v5.3.7"
251 | },
252 | "symfony/var-exporter": {
253 | "version": "v5.3.7"
254 | },
255 | "symfony/yaml": {
256 | "version": "v5.3.6"
257 | },
258 | "twig/twig": {
259 | "version": "v3.3.2"
260 | },
261 | "wikimedia/toolforge-bundle": {
262 | "version": "1.4.1"
263 | }
264 | }
265 |
--------------------------------------------------------------------------------
/templates/base.html.twig:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | {{ msg('wikisource') }}
8 | ({{ msg('all-validated-works') }})
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | {{ msg('wikisource') }}
20 | {{ msg('all-validated-works') }}
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 | - {{ msg('languages') }}
30 | {% for l,info in site_info %}
31 | -
32 | {% if lang == l %}
33 | {{ l }}
34 | {% else %}
35 | {{ l }}
36 | {% endif %}
37 |
38 | {% endfor %}
39 |
40 |
41 | {% if err is defined and err %}
42 |
{{ msg(err[0], err[1]) }}
43 | {% endif %}
44 |
45 |
46 | {{ msg('introduction', [
47 | 'x',
48 | ''~lang~' Wikisource'
49 | ])}}
50 |
51 |
52 |
{{ msg('loading') }}
53 |
54 |
55 |
56 | This list was last updated at:
57 | y UTC.
58 | The above data is available in
59 | works{{ suffix }}.json
60 | and categories{{ suffix }}.json.
61 |
62 |
63 | If you don't see your language's Wikisource listed above,
64 | please make sure it is present as a sitelink on Wikidata for
65 | the root category (Q1281) and
66 | the category for validated indexes (Q15634466).
67 |
68 |
69 | For more information please see
70 | the code on Github
71 | or contact User:Samwilson.
72 |
73 |
74 |
75 |
76 |
77 |
78 |

79 |
80 |
81 |
82 |
83 |
86 |
87 |
88 |
--------------------------------------------------------------------------------