├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── .jshintrc ├── LICENSE ├── README.md ├── app ├── api.js ├── i18n.js ├── locales │ ├── en │ │ ├── admin.ini │ │ ├── messages.ini │ │ └── metabase.ini │ └── it │ │ ├── admin.ini │ │ ├── messages.ini │ │ └── metabase.ini ├── metabase.js ├── pages │ ├── assets │ │ ├── img │ │ │ ├── arr.png │ │ │ ├── arrow_drop_down.png │ │ │ ├── arrow_drop_down.svg │ │ │ ├── bars-solid.svg │ │ │ ├── category-network.svg │ │ │ ├── default_file_image.png │ │ │ ├── download-icon.svg │ │ │ ├── glam-logo-horizontal.png │ │ │ ├── glam-logo-horizontal.svg │ │ │ ├── glam-logo-no-v.png │ │ │ ├── glam-logo-no-v.svg │ │ │ ├── glam-logo-old.svg │ │ │ ├── glam-logo-white-old.svg │ │ │ ├── glam-logo-white.svg │ │ │ ├── glam-logo.png │ │ │ ├── glam-logo.svg │ │ │ ├── link-out.svg │ │ │ ├── metabase.svg │ │ │ ├── owner-logo-default.svg │ │ │ ├── page-views.svg │ │ │ ├── pencil.svg │ │ │ ├── question-icon.svg │ │ │ ├── search-24px.svg │ │ │ ├── suggestion.svg │ │ │ ├── trash.svg │ │ │ ├── usage.svg │ │ │ └── user-contribution.svg │ │ ├── owner-logo.svg │ │ ├── scripts │ │ │ ├── bar-chart.js │ │ │ ├── custom-selector.js │ │ │ ├── horiz-bar-chart.js │ │ │ ├── linechart.js │ │ │ ├── main.js │ │ │ └── utils.js │ │ └── style │ │ │ ├── admin-panel.css │ │ │ ├── annotation-switch.css │ │ │ ├── chart-page.css │ │ │ ├── file-page.css │ │ │ ├── header.css │ │ │ ├── homepage.css │ │ │ ├── linechart.css │ │ │ ├── recommender-page.css │ │ │ ├── search-page.css │ │ │ ├── style.css │ │ │ ├── unused-page.css │ │ │ ├── usage.css │ │ │ └── user-contribution.css │ ├── favicon.ico │ ├── index.html │ ├── js │ │ ├── admin-panel.js │ │ ├── contacts.js │ │ ├── customize-tool.js │ │ ├── edit-glam-panel.js │ │ ├── functions.js │ │ ├── language.js │ │ ├── loader.js │ │ └── new-glam-panel.js │ └── views │ │ ├── about.html │ │ ├── admin-panel.html │ │ ├── category-network │ │ ├── functions.js │ │ ├── index.html │ │ └── tpl │ │ │ ├── category-network.tpl │ │ │ └── unused-file-list.tpl │ │ ├── contacts.html │ │ ├── customize-tool.html │ │ ├── dashboard-metabase │ │ ├── functions.js │ │ └── index.html │ │ ├── edit-glam.html │ │ ├── file-page │ │ ├── file-dataviz.js │ │ ├── functions.js │ │ ├── index.html │ │ └── tpl │ │ │ └── file-template.tpl │ │ ├── index.html │ │ ├── new-glam.html │ │ ├── page-views │ │ ├── functions.js │ │ ├── index.html │ │ └── tpl │ │ │ └── views.tpl │ │ ├── recommender-page │ │ ├── functions.js │ │ ├── index.html │ │ └── recommender.tpl │ │ ├── search-page │ │ ├── functions.js │ │ └── index.html │ │ ├── templates │ │ ├── footer.html │ │ ├── glam-homepage.tpl │ │ ├── glam-preview.tpl │ │ ├── mobile-header.html │ │ ├── mobile-sidebar.html │ │ ├── secondary-sidebar.html │ │ └── sidebar.html │ │ ├── unused-files-page │ │ ├── categories.tpl │ │ ├── functions.js │ │ ├── index.html │ │ └── unused-file-list-dropdown.tpl │ │ ├── usage │ │ ├── functions.js │ │ ├── index.html │ │ └── tpl │ │ │ └── usage.tpl │ │ └── user-contributions │ │ ├── functions.js │ │ ├── index.html │ │ └── tpl │ │ └── user-contributions.tpl ├── routes.js └── server.js ├── config ├── config.example.json └── config.js ├── deploy ├── .gitignore ├── ansible.yml ├── cassandra-autossh.service ├── cassandra.conf ├── config ├── crontab ├── metabase.conf ├── pontoon.conf ├── pontoon.env └── pontoon.yml ├── docs ├── api.raml ├── architecture.png └── presentations │ ├── 2016-09-01-How-libraries-fall-in-love-with-Wikidata.pdf │ ├── 2017-11-08-Cassandra_Backend.pdf │ └── 2019-11-03-Wikidata_Zurich_Training.pdf ├── etl ├── .gitignore ├── SQL │ ├── dailyInsert.sql │ ├── db_init.sql │ ├── functions.sql │ └── maintenance.sql ├── cards │ ├── 10.json │ ├── 18.json │ ├── 19.json │ ├── 20.json │ ├── 37.json │ ├── 38.json │ ├── 39.json │ ├── 40.json │ ├── 44.json │ ├── 45.json │ ├── 7.json │ └── 9.json ├── dashboard.json ├── dashboard.py ├── etl.js ├── run.py ├── run_views.py ├── setup.js └── views.py ├── package.json ├── recommender ├── run.py └── similarity.py └── requirements.txt /.eslintignore: -------------------------------------------------------------------------------- 1 | Gruntfile.js 2 | *.html 3 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parserOptions": { 3 | "ecmaVersion": 2017, 4 | "sourceType": "module", 5 | "ecmaFeatures": { 6 | "jsx": true 7 | } 8 | }, 9 | "rules": { 10 | "semi": "error", 11 | "id-length": ["error", { 12 | "min": 2, 13 | "exceptions": ["e","h", "i", "j", "k", "l","m", "d", "x", "y","q","p","t","s","n","w", "a", "b", "c", "g"] 14 | }], 15 | "indent": ["error", 2, { 16 | "SwitchCase": 1, 17 | "VariableDeclarator": { 18 | "var": 2, 19 | "let": 2, 20 | "const": 3 21 | }, 22 | "MemberExpression": 1, 23 | "outerIIFEBody": 1, 24 | "ObjectExpression": 1 25 | }], 26 | "object-curly-newline": [ 27 | "error", 28 | { 29 | "ImportDeclaration": { 30 | "minProperties": 5, 31 | "multiline": true 32 | } 33 | } 34 | ], 35 | "no-duplicate-imports": [ 36 | "error", 37 | { 38 | "includeExports": true 39 | } 40 | ] 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | package-lock.json 3 | /app/pages/docs.html 4 | *.log 5 | /config/config.json 6 | /config/i18n.json 7 | /config/owner-logo.svg 8 | .vscode 9 | venv 10 | *.csv 11 | *.gz 12 | /cassandra-GLAM-tools.iml 13 | /.idea/ 14 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "esversion": 6, 3 | "evil": true, 4 | "loopfunc": true, 5 | "sub": true 6 | } 7 | -------------------------------------------------------------------------------- /app/i18n.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const ini = require('ini'); 3 | const mime = require('mime-types'); 4 | const path = require('path'); 5 | const ISO6391 = require('iso-639-1'); 6 | const Mustache = require('mustache'); 7 | Mustache.tags = ['§[', ']§']; 8 | 9 | const localesFolder = './locales'; 10 | 11 | let defaultLanguage = 'en'; 12 | let homeTitle = null; 13 | let ownerLogo = null; 14 | 15 | // Load owner logo if any 16 | try { 17 | ownerLogo = fs.readFileSync('../config/owner-logo.svg'); 18 | } catch { 19 | // pass 20 | } 21 | 22 | // Load config if any 23 | try { 24 | const i18nFile = fs.readFileSync('../config/i18n.json'); 25 | if (i18nFile) { 26 | const i18nConfig = JSON.parse(i18nFile); 27 | if (i18nConfig.defaultLanguage) { 28 | defaultLanguage = i18nConfig.defaultLanguage 29 | } 30 | if (i18nConfig.homeTitle) { 31 | homeTitle = i18nConfig.homeTitle; 32 | } 33 | } 34 | } catch { 35 | // pass 36 | } 37 | 38 | const messages = {}; 39 | 40 | // Load default language 41 | messages[defaultLanguage] = {}; 42 | fs.readdirSync(localesFolder + '/' + defaultLanguage).forEach(namespace => { 43 | const items = ini.parse(fs.readFileSync(localesFolder + '/' + defaultLanguage + '/' + namespace, 'utf-8')); 44 | messages[defaultLanguage][namespace.replace('.ini', '')] = items 45 | }); 46 | if (homeTitle) { 47 | messages[defaultLanguage]['messages']['home-slogan'] = homeTitle; 48 | } 49 | 50 | // Load other languages 51 | fs.readdirSync(localesFolder).forEach(language => { 52 | if (language === defaultLanguage) { 53 | return; 54 | } 55 | messages[language] = Object.assign({}, messages[defaultLanguage]); 56 | fs.readdirSync(localesFolder + '/' + language).forEach(namespace => { 57 | const items = ini.parse(fs.readFileSync(localesFolder + '/' + language + '/' + namespace, 'utf-8')); 58 | messages[language][namespace.replace('.ini', '')] = items 59 | }); 60 | if (homeTitle) { 61 | messages[language]['messages']['home-slogan'] = homeTitle; 62 | } 63 | }); 64 | 65 | function getLanguage(req, res) { 66 | if (req.query.lang && req.query.lang in messages) { 67 | // If possible set the new language 68 | if (res) { 69 | res.cookie('cassandra_lang', req.query.lang); 70 | } 71 | return req.query.lang; 72 | } 73 | if (req.cookies['cassandra_lang'] && req.cookies['cassandra_lang'] in messages) { 74 | return req.cookies['cassandra_lang']; 75 | } 76 | const accept = req.header('Accept-Language'); 77 | if (accept) { 78 | for (const lang of accept.split(';').join(',').split(',')) { 79 | if (lang in messages) { 80 | return lang; 81 | } 82 | } 83 | } 84 | return defaultLanguage; 85 | } 86 | 87 | exports.renderResponse = function (req, res, data) { 88 | const targetLanguage = getLanguage(req, res); 89 | if (res.recaptcha) { 90 | data = data.replace("", res.recaptcha); 91 | } 92 | return Mustache.render(data, messages[targetLanguage]); 93 | }; 94 | 95 | exports.sendFile = function (req, res, file) { 96 | fs.readFile(file, 'utf8', (err, data) => { 97 | if (err) { 98 | console.error(err); 99 | res.sendStatus(500); 100 | return; 101 | } 102 | res.send(exports.renderResponse(req, res, data)); 103 | }); 104 | }; 105 | 106 | exports.static = function (dir) { 107 | return function (req, res, next) { 108 | // Remove query string 109 | let pathname = decodeURIComponent(req.url.split('?').shift()); 110 | // Redirect homepage 111 | if (pathname === '/') 112 | pathname = '/index.html'; 113 | // Manage owner logo 114 | if (pathname === '/assets/img/owner-logo.svg' && ownerLogo) { 115 | res.setHeader('Content-Type', 'image/svg+xml'); 116 | res.send(ownerLogo); 117 | return; 118 | } 119 | const fileName = path.join(dir, pathname); 120 | const fileType = mime.lookup(fileName) || 'application/octet-stream'; 121 | const enconding = fileType.startsWith('text') || fileType.startsWith('application') ? 'utf8' : null; 122 | fs.readFile(fileName, enconding, (err, data) => { 123 | if (err) { 124 | next(); 125 | return; 126 | } 127 | res.setHeader('Content-Type', fileType); 128 | if (enconding) { 129 | res.send(exports.renderResponse(req, res, data)); 130 | } else { 131 | res.send(data); 132 | } 133 | }); 134 | }; 135 | }; 136 | 137 | exports.languages = function (req, res) { 138 | const targetLanguage = getLanguage(req, res); 139 | const languages = [targetLanguage]; 140 | for (const key in messages) { 141 | if (messages.hasOwnProperty(key) && targetLanguage !== key) { 142 | languages.push(key); 143 | } 144 | } 145 | res.send(ISO6391.getLanguages(languages)); 146 | }; 147 | -------------------------------------------------------------------------------- /app/locales/en/admin.ini: -------------------------------------------------------------------------------- 1 | title=Admin Control Panel 2 | list=List of Glams available in the system 3 | all=All 4 | running=Running 5 | pending=Pending 6 | paused=Paused 7 | failed=Failed 8 | running-desc=tool is collecting data. Everything is fine! 9 | pending-desc=tool is not started yet. It will soon! 10 | paused-desc=tool has been paused from collecting data 11 | failed-desc=tool tried to collect data, but failed 12 | add-new=add new glam 13 | tool-settings=settings 14 | tool-settings-title=GLAM tool settings 15 | edit-title=Edit GLAM stats monitor 16 | create-title=Create a new GLAM stats monitor 17 | save-error=OOPS! Something went wrong. Check the following things: 18 | save-error-1=GLAM ID must be unique (there cannot be another GLAM with the same ID) 19 | save-error-2=GLAM ID cannot include spaces 20 | save-error-3=Image URL must be a valid URL (starts with http:// or https://) 21 | edit-success=Glam edited correctly 22 | create-success=New glam added correctly 23 | view-list=view complete list 24 | form-id=Unique GLAM ID* 25 | form-fullname=Full Name* 26 | form-category=Category* 27 | form-image=Featured image URL* 28 | form-password=New password (leave blank for public GLAMs) 29 | form-id-desc=e.g. ZU, ETH or SNL 30 | form-fullname-desc=e.g. Canton of Zürich, ETH Library of Zurich or Swiss National Library 31 | form-category-desc=e.g. Historical images of buildings in the canton of Zürich 32 | form-image-desc=URL 33 | form-password-desc=Password 34 | submit=submit 35 | edit-password=edit password 36 | create=create 37 | keep-password=keep old password 38 | pause=pause 39 | restart=restart 40 | retry=retry 41 | edit=Edit 42 | cat=cat 43 | intro-body=Introduction text 44 | contacts-body=Contacts form text 45 | contacts-mail=Email for contacts form 46 | lastrun=last run 47 | logo-id=Owner logo 48 | logo-id-desc=Upload a file named owner-logo and format svg 49 | upload=upload 50 | upload-success=upload success 51 | upload-failed=upload failed 52 | set-title=Homepage title 53 | set-title-placeholder=Statistics for Galleries, Libraries Archives & Museums (GLAM) for measuring the impact of Wikimedia projects English 54 | owner-link=Owner link 55 | owner-name=Owner name 56 | default-lang=Default language 57 | recommender-langs=Languages order in recommender 58 | recommender-help=Insert languages separated from, in this format de, en, fr, it 59 | save=save 60 | save-success=Settings saved 61 | save-error=Settings error 62 | refresh-success=Refresh success 63 | refresh-error=Refresh error 64 | refresh=Update tool 65 | -------------------------------------------------------------------------------- /app/locales/en/metabase.ini: -------------------------------------------------------------------------------- 1 | views=Views 2 | upload=Uploads 3 | usage=Usage 4 | corr=Correlations 5 | corr-usage-desc=From -1 to 1. This number represents the correlation between number of views and media usage. Does high usage leads to more views? 1 yes, -1 no, 0 not relevant. 6 | corr-usage=Correlation views / usage 7 | corr-size-desc=From -1 to 1. This number represents the correlation between number of views and media size. Do bigger media lead to more views? 1 yes, -1 no, 0 not relevant. 8 | corr-size=Correlation views / size 9 | corr-old-desc=From -1 to 1. This number represents the correlation between number of views and media oldness. Do older media make more views? 1 yes, -1 no, 0 not relevant. 10 | corr-old=Correlation views / oldness 11 | uploaded-last-desc=Percentage of new media uploaded last year (rolling). 12 | uploaded-last=Yearly uploads in percentage 13 | perc-used-desc=Percentage of distinct media used to enrich Wikimedia pages. (Remember: a media may illustrate more than one page) 14 | perc-used=Media used 15 | views-change-desc=Percentage of last year's views compared to the previous one. 16 | views-change=Views change last year 17 | media-used=Media used 18 | enanched-desc=Sum of all Wikimedia pages (in all languages) with at least one media of the categories illustrated in a Wikipedia article. 19 | enanched=Total pages enhanced 20 | total-views-desc=Sum of all media views from 2015-01-01 (first data available). A view is recorded every time a device open a specific file from Wikimedia server. So it counts both punctual views to the files, both views on pages where the media is present. Actually this is the most precise statistic released by Wikimedia servers (for more information visit https://pageviews.toolforge.org/faq/) 21 | total-views=Total views from 2015 until today 22 | total-media-desc=Total number of all media uploaded to Wikimedia Commons and tracked in this project. 23 | total-media=Total media uploaded 24 | views-year-desc=Number of views per year. Cumulative views in purple and total of views per year in blue. The raw data starts in 2015 from Baglama, having a different statistical calculation method. The switch in a more sophisticated method starts when the first data set was recorded within the GLAM statistical tool, 25 | basically at the time, when the GLAM account was created. 26 | views-year=Media views 27 | visits-per-year=Views per year 28 | cumulative-usage=Cumulative 29 | media-used-desc=Media used to enrich Wikimedia pages. In blue year per year addition (the peak in the first year is because no historical data were available before we start to collect it). In purple cumulative usage. 30 | media-used-year=Media used 31 | new-usages=New usages 32 | media-added-desc=Track record of uploads. Total number of yearly new media uploads in blue. Cumulative media uploads in purple. 33 | Cumulative media uploads in purple. 34 | media-added=Media Uploads 35 | media-per-year=Uploads per year 36 | media-cumulated=Uploads cumulated 37 | year=Year -------------------------------------------------------------------------------- /app/locales/it/admin.ini: -------------------------------------------------------------------------------- 1 | title=Pannello di controllo amministratore 2 | list=Lista dei Glam disponibili nel sistema 3 | all=Tutti 4 | running=In esecuzione 5 | pending=In sospeso 6 | paused=In pausa 7 | failed=Falliti 8 | running-desc=lo strumento sta raccogliendo i dati. Va tutto bene! 9 | pending-desc=lo strumento non è ancora avviato. Lo sarà presto! 10 | paused-desc=lo strumento è stato messo in pausa dalla raccolta dei dati 11 | failed-desc=lo strumento ha provato a raccogliere i dati, ma ha fallito 12 | add-new=aggiungi un nuovo glam 13 | tool-settings=impostazioni 14 | tool-settings-title=Impostazioni GLAM tool 15 | edit-title=Modifica il monitor delle statistiche GLAM 16 | create-title=Crea un nuovo monitor delle statistiche GLAM 17 | save-error=OOPS! Qualcosa è andato storto. Controlla le seguenti cose: 18 | save-error-1=Il GLAM ID deve essere univoco (non ci può essere un altro GLAM con lo stesso ID) 19 | save-error-2=Il GLAM ID non può includere spazi 20 | save-error-3=Image URL dev'essere un URL valido (iniziare con http:// o https://) 21 | edit-success=Glam modificato correttamente 22 | create-success=Nuovo glam aggiunto correttamente 23 | view-list=vedi la lista completa 24 | form-id=GLAM ID univoco* 25 | form-fullname=Nome completo* 26 | form-category=Categoria* 27 | form-image=URL immagine in evidenza* 28 | form-password=Nuova password (lascia vuoto per GLAM pubblici) 29 | form-id-desc=Ad esempio: ZU, ETH o SNL 30 | form-fullname-desc=Ad esempio: Canton of Zürich, ETH Library of Zurich o Swiss National Library 31 | form-category-desc=Ad esempio: Historical images of buildings in the canton of Zürich 32 | form-image-desc=URL 33 | form-password-desc=Password 34 | submit=invia 35 | edit-password=modifica password 36 | create=crea 37 | keep-password=mantieni la vecchia password 38 | pause=pausa 39 | restart=riavvia 40 | retry=riprova 41 | edit=Modifica 42 | cat=cat 43 | intro-body=Testo dell'introduzione 44 | contacts-body=Testo della pagina contatti 45 | contacts-mail=Email per il form contattaci 46 | lastrun=ultima esecuzione 47 | logo-id=Logo del proprietario 48 | logo-id-desc=Carica un file chiamato owner-logo in formato svg 49 | upload=upload 50 | upload-success=File caricato con successo 51 | upload-failed=Caricamento fallito 52 | set-title=Titolo in homepage 53 | set-title-placeholder=Statistiche per Gallerie, Biblioteche, Archivi e Musei (GLAM) per misurare l'impatto dei progetti Wikimedia 54 | owner-link=Link del proprietario 55 | owner-name=Nome del proprietario 56 | default-lang=Lingua di default 57 | recommender-langs=Ordine delle lingue nel recommender 58 | recommender-help=Inserisci le lingue separate da virgole in questo formato de, en, fr, it 59 | save=salva 60 | save-success=Salvataggio effettuato 61 | save-error=Salvataggio fallito 62 | refresh-success=Aggiornamento effettuato 63 | refresh-error=Aggiornamento fallito 64 | refresh=Aggiorna tool -------------------------------------------------------------------------------- /app/locales/it/metabase.ini: -------------------------------------------------------------------------------- 1 | views=Visualizzazioni 2 | upload=Caricamenti 3 | usage=Utilizzo 4 | corr=Correlazioni 5 | corr-usage-desc=Da -1 a 1. Questo numero rappresenta la correlazione tra il numero di visualizzazioni e l'utilizzo dei media. L'utilizzo elevato porta a più visualizzazioni? 1 si, -1 no, 0 non rilevante. 6 | corr-usage=Visualizzazioni / utilizzo 7 | corr-size-desc=Da -1 a 1. Questo numero rappresenta la correlazione tra il numero di visualizzazioni e la dimensione del supporto. I media più grandi portano a più visualizzazioni? 1 si, -1 no, 0 non rilevante. 8 | corr-size=Visualizzazioni / dimensione 9 | corr-old-desc=Da -1 a 1. Questo numero rappresenta la correlazione tra il numero di visualizzazioni e la data di caricamento dei media. I media meno recenti fanno più visualizzazioni? 1 si, -1 no, 0 non rilevante. 10 | corr-old=Visualizzazioni / data 11 | uploaded-last-desc=Percentuale di nuovi media caricati lo scorso anno (a rotazione). 12 | uploaded-last=Variazione ultimo anno 13 | perc-used-desc=Percentuale di media utilizzati per arricchire le pagine Wikimedia. 14 | perc-used=Media utilizzati 15 | views-change-desc=Percentuale di visualizzazioni dell'anno scorso rispetto al precedente. 16 | views-change=Variazione ultimo anno 17 | media-used=Media utilizzati 18 | enanched-desc=Somma di tutte le pagine Wikimedia (in tutte le lingue) con almeno un media all'interno. 19 | enanched=Totale pagine migliorate 20 | total-views-desc=Somma di tutte le visualizzazioni dei media dal 01/01/2015 (primi dati disponibili). Viene registrata una vista ogni volta che un dispositivo scarica un file specifico dal server Wikimedia. Quindi conta sia le visualizzazioni puntuali, sia le visualizzazioni su pagine dove è presente il media (ma solo se caricato sul dispositivo). Ad oggi questa è la statistica più precisa rilasciata dai server Wikimedia. 21 | total-views=Visualizzazioni totali dal 2015 22 | total-media-desc=Il numero di tutti i media tracciati nel progetto. 23 | total-media=Numero di media caricati 24 | views-year-desc=Numero di visualizzazioni all'anno, in viola le visite cumulative, in blu le visite anno per anno. I dati iniziano dalla data meno recente nel nostro sistema (i dati grezzi iniziano dal 2015). 25 | views-year=Media di visualizzazioni per anno 26 | visits-per-year=Visualizzazioni per anno 27 | cumulative-usage=Utilizzo cumulativo 28 | media-used-desc=Media utilizzati per arricchire le pagine Wikimedia. In blu le aggiunte anno per anno (il picco nel primo anno è dovuto al fatto che non erano disponibili dati storici prima di iniziare a raccoglierli). In viola l'utilizzo cumulativo. 29 | media-used-year=Media usati per anno 30 | new-usages=Nuovi utilizzi 31 | media-added-desc=Traccia i caricamenti. In blu il numero di nuovi media caricati anno per anno. In viola lo stesso ma cumulativo. 32 | media-added=Media aggiunti per anno 33 | media-per-year=Media per anno 34 | media-cumulated=Media cumulativi 35 | year=Anno -------------------------------------------------------------------------------- /app/metabase.js: -------------------------------------------------------------------------------- 1 | const jwt = require('jsonwebtoken'); 2 | const puppeteer = require('puppeteer'); 3 | 4 | const screenshot = { 5 | fullPage: true, 6 | }; 7 | const lastScreenshot = {}; 8 | const lastScreenDate = {}; 9 | 10 | function getDashboardUrl(dashboard_id, metabase) { 11 | const payload = { 12 | resource: { dashboard: dashboard_id }, 13 | params: {}, 14 | exp: Math.round(Date.now() / 1000) + (10 * 60) // 10 minute expiration 15 | }; 16 | const token = jwt.sign(payload, metabase.secret); 17 | return metabase.proxy + "/metabase/embed/dashboard/" + token + "#bordered=true&titled=true"; 18 | }; 19 | 20 | function getDashboard(req, res, config, glam) { 21 | if (!glam['dashboard_id']) { 22 | res.sendStatus(404); 23 | } else { 24 | res.json({ 25 | iframeUrl: getDashboardUrl(glam['dashboard_id'], config.metabase) 26 | }); 27 | } 28 | } 29 | 30 | async function getPng(req, res, config, glam) { 31 | if (!glam['dashboard_id']) { 32 | res.sendStatus(404); 33 | return; 34 | } 35 | 36 | const id = glam['dashboard_id']; 37 | const url = getDashboardUrl(id, config.metabase); 38 | 39 | if (!lastScreenDate[id] || !lastScreenshot[id] || new Date() - lastScreenDate[id] > 3600000) { 40 | const browser = await puppeteer.launch({ headless: true }); 41 | const page = await browser.newPage(); 42 | await page.setViewport({ width: 1920, height: 1080 }); 43 | await page.goto(url + '#bordered=true&titled=true', { waitUntil: 'networkidle2', timeout: 0 }); 44 | await page.waitForTimeout(5000); 45 | const resultImage = await page.screenshot(screenshot); 46 | await browser.close(); 47 | lastScreenshot[id] = resultImage; 48 | lastScreenDate[id] = new Date(); 49 | } 50 | res.set('Content-Type', 'image/png'); 51 | res.setHeader('Content-Disposition', 'attachment; filename=\"' + glam['name'] + '-' + lastScreenDate[id].getTime() + '.png\"'); 52 | res.send(lastScreenshot[id]); 53 | } 54 | 55 | exports.getDashboard = getDashboard; 56 | exports.getPng = getPng; 57 | -------------------------------------------------------------------------------- /app/pages/assets/img/arr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/synapta/cassandra-GLAM-tools/613cc88f0570a09146e1d4e70ab4509050280139/app/pages/assets/img/arr.png -------------------------------------------------------------------------------- /app/pages/assets/img/arrow_drop_down.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/synapta/cassandra-GLAM-tools/613cc88f0570a09146e1d4e70ab4509050280139/app/pages/assets/img/arrow_drop_down.png -------------------------------------------------------------------------------- /app/pages/assets/img/arrow_drop_down.svg: -------------------------------------------------------------------------------- 1 | 2 | 20 | 22 | 23 | 25 | image/svg+xml 26 | 28 | 29 | 30 | 31 | 32 | 34 | 54 | 62 | 67 | 68 | -------------------------------------------------------------------------------- /app/pages/assets/img/bars-solid.svg: -------------------------------------------------------------------------------- 1 | 2 | 74 | -------------------------------------------------------------------------------- /app/pages/assets/img/category-network.svg: -------------------------------------------------------------------------------- 1 | Tavola disegno 116 -------------------------------------------------------------------------------- /app/pages/assets/img/default_file_image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/synapta/cassandra-GLAM-tools/613cc88f0570a09146e1d4e70ab4509050280139/app/pages/assets/img/default_file_image.png -------------------------------------------------------------------------------- /app/pages/assets/img/download-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 60 | -------------------------------------------------------------------------------- /app/pages/assets/img/glam-logo-horizontal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/synapta/cassandra-GLAM-tools/613cc88f0570a09146e1d4e70ab4509050280139/app/pages/assets/img/glam-logo-horizontal.png -------------------------------------------------------------------------------- /app/pages/assets/img/glam-logo-no-v.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/synapta/cassandra-GLAM-tools/613cc88f0570a09146e1d4e70ab4509050280139/app/pages/assets/img/glam-logo-no-v.png -------------------------------------------------------------------------------- /app/pages/assets/img/glam-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/synapta/cassandra-GLAM-tools/613cc88f0570a09146e1d4e70ab4509050280139/app/pages/assets/img/glam-logo.png -------------------------------------------------------------------------------- /app/pages/assets/img/link-out.svg: -------------------------------------------------------------------------------- 1 | Tavola disegno 121 -------------------------------------------------------------------------------- /app/pages/assets/img/metabase.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /app/pages/assets/img/page-views.svg: -------------------------------------------------------------------------------- 1 | Tavola disegno 116 copia 3 -------------------------------------------------------------------------------- /app/pages/assets/img/pencil.svg: -------------------------------------------------------------------------------- 1 | 2 | 61 | -------------------------------------------------------------------------------- /app/pages/assets/img/question-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 60 | -------------------------------------------------------------------------------- /app/pages/assets/img/search-24px.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/pages/assets/img/trash.svg: -------------------------------------------------------------------------------- 1 | 2 | 60 | -------------------------------------------------------------------------------- /app/pages/assets/img/usage.svg: -------------------------------------------------------------------------------- 1 | Tavola disegno 116 copia 2 -------------------------------------------------------------------------------- /app/pages/assets/img/user-contribution.svg: -------------------------------------------------------------------------------- 1 | Tavola disegno 116 copia -------------------------------------------------------------------------------- /app/pages/assets/scripts/horiz-bar-chart.js: -------------------------------------------------------------------------------- 1 | const TOP_WIKIS = []; 2 | 3 | const horizBarChartDraw = function (div, query, stats_data) { 4 | // get data 5 | d3.json(query, function (error, data) { 6 | // manage error 7 | if (error) throw error; 8 | // sort 9 | data = data.sort(function (a, b) { 10 | // put others column as last element 11 | if (a.wiki === 'others') { 12 | return -1; 13 | } 14 | if (b.wiki === 'others') { 15 | return 1; 16 | } 17 | // sort 18 | return a.usage - b.usage; 19 | }); 20 | // format the data 21 | data.forEach(function (d) { 22 | if(d.wiki === 'wikidatawiki'){ 23 | d.wiki = 'wikidata'; 24 | } 25 | if (d.wiki !== 'others') { 26 | TOP_WIKIS.push(d.wiki); 27 | } 28 | d.usage = +d.usage; 29 | }); 30 | // draw 31 | drawHorizBars(data, '#' + div, stats_data.totalPages); 32 | }); 33 | }; 34 | 35 | function drawHorizBars(data, div, totalPages) { 36 | // Graph dimensions 37 | var margin = {}; 38 | var kH; 39 | var availH; 40 | 41 | if ($(window).width() < 576) { 42 | // smartphones 43 | availH = $(div).outerHeight(); 44 | margin = { top: 40, right: 10, bottom: 40, left: 40 }; 45 | } else { 46 | // tablets and desktop 47 | availH = $(div).outerHeight() * 0.85; 48 | margin = { top: 40, right: 30, bottom: 20, left: 80 }; 49 | } 50 | 51 | var width = Math.round($(div).outerWidth()) - margin.left - margin.right, 52 | height = availH - margin.top - margin.bottom; 53 | 54 | var svg = d3.select(div).append("svg") 55 | .attr("width", width + margin.left + margin.right) 56 | .attr("height", height + 50 + margin.top + margin.bottom) 57 | .append("g") 58 | .attr("transform", "translate(" + margin.left + "," + margin.top + ")"); 59 | 60 | // SCALES 61 | var x = d3.scaleLinear().range([0, width]); 62 | var y = d3.scaleBand().range([height, 0]).padding(0.3); 63 | 64 | x.domain([0, d3.max(data, function(d) { return d.usage; })]); 65 | 66 | y.domain(data.map(function(d) { return d.wiki; })); 67 | 68 | var xAxis = d3.axisBottom().scale(x).tickValues(x.ticks(6).slice(1, -1).concat(x.domain())); 69 | 70 | var yAxis = d3.axisLeft() 71 | .scale(y) 72 | .ticks(10); 73 | 74 | var gX = svg.append("g") 75 | .attr("class", "x axis") 76 | .attr("transform", "translate(0," + height + ")") 77 | .call(xAxis) 78 | .selectAll("text") 79 | .style("text-anchor", "end") 80 | .attr("dx", ".7em") 81 | .attr("dy", ".7em"); 82 | 83 | var gY = svg.append("g") 84 | .attr("class", "y axis") 85 | .call(yAxis) 86 | .append("text") 87 | .attr("y", 6) 88 | .attr("dy", ".71em") 89 | .style("text-anchor", "end") 90 | .text("Value"); 91 | 92 | // Y axis label: 93 | svg.append("text") 94 | .attr("text-anchor", "end") 95 | .attr("y", -15) 96 | .attr("x", -1) 97 | .text("Projects") 98 | 99 | svg.append("text") 100 | .attr("text-anchor", "end") 101 | .attr("x", width) 102 | .attr("y", height + margin.top + 10) 103 | .text("Pages"); 104 | 105 | // append the rectangles for the bar chart 106 | var bars = svg.selectAll(".bar") 107 | .data(data) 108 | .enter().append("rect") 109 | .attr("class", "bar") 110 | .attr("id", d => d.wiki) 111 | .style("transition", "width 1s ease-in-out, stroke .3s") 112 | .attr("width", (d) => 0 ) 113 | .attr('fill', '#080d5a') 114 | .attr('stroke', '#080d5a') 115 | .attr('stroke-width', '3') 116 | .attr("x", function(d) { return 3 }) 117 | .attr("y", function(d) { return y(d.wiki); }) 118 | .attr("height", y.bandwidth()); 119 | 120 | 121 | // add labels 122 | var labels = svg.selectAll('.text') 123 | .data(data) 124 | .enter().append("text") 125 | .attr("class", "usage-bar-label") 126 | .attr("x", d => x(d.usage)) 127 | .attr("dx", ".6em") 128 | .attr("y", d => y(d.wiki) + y.bandwidth() / 2) 129 | .attr("dy", ".4em") 130 | .text(d => { 131 | let p = d.usage / totalPages * 100; 132 | return `${nFormatter(d.usage)} (${p.toFixed(2)}%)`; 133 | }); 134 | 135 | // animation 136 | setTimeout(function() { 137 | bars.attr("width", d => x(d.usage)); 138 | }, 100); 139 | 140 | // check for labels outside graph 141 | labels.each(function(d) { 142 | let bbox = d3.select(this).node().getBBox(); 143 | let threshold = width - margin.left; 144 | if (bbox.x + bbox.width > threshold) { 145 | d3.select(this) 146 | .attr("x", bbox.x - bbox.width * 1.5) 147 | .attr("fill", "#fff"); 148 | } 149 | }); 150 | 151 | window.highlightUsageBars = function(array) { 152 | array.forEach(function(el) { 153 | d3.select('#' + el).attr('stroke', 'red'); 154 | }); 155 | // check if used in other wikis 156 | let difference = array.filter(x => !TOP_WIKIS.includes(x)); 157 | if (difference.length > 0) { 158 | d3.select('#others').attr('stroke', 'red'); 159 | } 160 | }; 161 | 162 | window.turnOffUsageBars = function(array) { 163 | bars.attr('stroke', '#080d5a'); 164 | }; 165 | } 166 | -------------------------------------------------------------------------------- /app/pages/assets/scripts/main.js: -------------------------------------------------------------------------------- 1 | function how_to_read() { 2 | button = $("#how_to_read_button"); 3 | box = $(".how_to_read"); 4 | 5 | $("#how_to_read_button").click(function () { 6 | box.toggleClass("show"); 7 | //console.log("click"); 8 | }); 9 | //console.log("no_click"); 10 | } 11 | 12 | $(document).ready(function () { 13 | how_to_read(); 14 | //console.log("main"); 15 | }); 16 | -------------------------------------------------------------------------------- /app/pages/assets/style/admin-panel.css: -------------------------------------------------------------------------------- 1 | #admin-help { 2 | font-style: normal !important; 3 | display: inline-block; 4 | margin-left: .5rem; 5 | color: #000; 6 | width: 16px; 7 | height: 16px; 8 | border: 1px solid; 9 | border-radius: 100%; 10 | text-align: center; 11 | font-size: 12px; 12 | top: -1px; 13 | position: relative; 14 | } 15 | 16 | #glam-legend { 17 | position: absolute; 18 | border: 1px dashed black; 19 | background: #fff; 20 | z-index: 9; 21 | padding: 1rem 1rem 0; 22 | border-radius: 3px; 23 | box-shadow: 3px 3px 10px 0 rgba(0, 0, 0, .4); 24 | } 25 | 26 | #glam-legend ul { 27 | list-style-type: none; 28 | margin-left: -2rem; 29 | } 30 | 31 | #glam-legend li { 32 | padding: 0.5rem 0; 33 | } 34 | 35 | .glam-button { 36 | padding: 0.5rem 1rem; 37 | border: 1px solid; 38 | border-radius: 3px; 39 | margin: 0; 40 | transition: all .5s; 41 | text-transform: uppercase; 42 | white-space: nowrap; 43 | } 44 | 45 | .glam-button:hover { 46 | background-color: rgba(6, 69, 173, 0.2); 47 | text-transform: uppercase 48 | } 49 | 50 | .glam-block { 51 | width: 100%; 52 | padding: 1rem 2rem; 53 | border-radius: 3px; 54 | margin: 30px 0; 55 | border-style: solid; 56 | border-width: 1px 1px 1px 5px; 57 | background-color: rgba(64, 121, 140, 0.1); 58 | display: flex; 59 | align-items: center; 60 | justify-content: space-between; 61 | position: relative; 62 | transition: all .5s; 63 | } 64 | 65 | .glam-block.disabled { 66 | pointer-events: none; 67 | opacity: .5; 68 | } 69 | 70 | .glam-block .glam-controls-container { 71 | display: flex; 72 | align-items: baseline; 73 | } 74 | 75 | .glam-block .glam-controls { 76 | display: none; 77 | float: left; 78 | font-size: 1.2rem; 79 | font-weight: 900; 80 | padding: 0 .5rem; 81 | } 82 | 83 | .glam-block .glam-controls.edit { 84 | color: #000; 85 | margin: 0 .8rem; 86 | } 87 | 88 | .glam-block .glam-controls.command { 89 | text-transform: capitalize; 90 | color: firebrick; 91 | } 92 | 93 | .glam-block .glam-controls.command:hover { 94 | text-decoration: underline; 95 | cursor: pointer; 96 | } 97 | 98 | .glam-block .glam-controls.command.retry, 99 | .glam-block .glam-controls.command.restart { 100 | color: darkcyan; 101 | } 102 | 103 | .glam-block .info { 104 | padding-right: 1rem; 105 | } 106 | 107 | .glam-block .img > img { 108 | height: 150px; 109 | } 110 | 111 | .glam-block .bold-span { 112 | font-weight: 800; 113 | } 114 | 115 | .glam-block .status { 116 | font-size: 1.5rem; 117 | text-transform: uppercase; 118 | font-weight: 800; 119 | float: left; 120 | } 121 | 122 | .glam-block.running { 123 | border-color: #40798C; 124 | background-color: rgba(64, 121, 140, 0.1); 125 | } 126 | 127 | .glam-block.running .status { 128 | color: #40798C; 129 | } 130 | 131 | .glam-block.paused { 132 | border-color: #F7B32B; 133 | background-color: rgba(252, 246, 177, 0.1); 134 | } 135 | 136 | .glam-block.paused .status { 137 | color: #F7B32B; 138 | } 139 | 140 | .glam-block.failed { 141 | border-color: #8C271E; 142 | background-color: rgba(140, 39, 30, 0.1); 143 | } 144 | 145 | .glam-block.failed .status { 146 | color: #8C271E; 147 | } 148 | 149 | .glam-block.pending { 150 | border-color: #5B5B5B; 151 | background-color: rgba(91, 91, 91, 0.1); 152 | opacity: 0.9; 153 | } 154 | 155 | .glam-block.pending .status { 156 | color: #5B5B5B; 157 | } 158 | 159 | .glams-count { 160 | color: gray; 161 | } 162 | 163 | .glams-count ul { 164 | list-style-type: none; 165 | padding: 0; 166 | margin: 0; 167 | display: inline-block; 168 | } 169 | 170 | .glams-count ul li { 171 | display: inline-block; 172 | } 173 | 174 | .glams-count ul li.active-btn { 175 | font-weight: 600; 176 | color: var(--main); 177 | text-decoration: underline; 178 | } 179 | 180 | .glams-count .glam-filter { 181 | font-style: italic; 182 | color: var(--main); 183 | font-size: 1rem; 184 | text-decoration: underline; 185 | cursor: pointer; 186 | transition: all .5s; 187 | } 188 | 189 | .glams-count .glam-filter:hover { 190 | opacity: .5; 191 | } 192 | 193 | @media screen and (max-width: 991px) { 194 | .glam-block .glam-controls { 195 | font-size: 1rem; 196 | font-weight: 700; 197 | padding: 0; 198 | } 199 | 200 | .glam-block .img > img { 201 | width: 100%; 202 | height: auto; 203 | max-width: 180px; 204 | } 205 | } 206 | 207 | @media screen and (max-width: 576px) { 208 | .glam-block .glam-controls { 209 | font-size: 0.9rem; 210 | padding: 0; 211 | } 212 | 213 | .glam-block .glam-controls.edit { 214 | margin: 0 .5rem; 215 | } 216 | 217 | .glam-block .img > img { 218 | display: none; 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /app/pages/assets/style/annotation-switch.css: -------------------------------------------------------------------------------- 1 | /* The annotation_toggle_switch - the box around the annotation_toggle_slider */ 2 | .annotation_toggle_switch { 3 | position: relative; 4 | display: inline-block; 5 | width: 56px; 6 | height: 30px; 7 | margin: 0 1rem; 8 | } 9 | 10 | /* Hide default HTML checkbox */ 11 | .annotation_toggle_switch input { 12 | opacity: 0; 13 | width: 0; 14 | height: 0; 15 | } 16 | 17 | /* The annotation_toggle_slider */ 18 | .annotation_toggle_slider { 19 | position: absolute; 20 | cursor: pointer; 21 | top: 0; 22 | left: 0; 23 | right: 0; 24 | bottom: 0; 25 | /* background-color: #ccc; */ 26 | border: 1px solid #fff; 27 | -webkit-transition: .4s; 28 | transition: .4s; 29 | } 30 | 31 | .annotation_toggle_slider:before { 32 | position: absolute; 33 | content: ""; 34 | height: 20px; 35 | width: 20px; 36 | left: 4px; 37 | bottom: 4px; 38 | background-color: #fff; 39 | -webkit-transition: .4s; 40 | transition: .4s; 41 | } 42 | 43 | input:checked + .annotation_toggle_slider { 44 | background-color: var(--annotation-color); 45 | border: 1px solid var(--annotation-color); 46 | } 47 | 48 | input:focus + .annotation_toggle_slider { 49 | box-shadow: 0 0 1px var(--annotation-color); 50 | } 51 | 52 | input:checked + .annotation_toggle_slider:before { 53 | -webkit-transform: translateX(26px); 54 | -ms-transform: translateX(26px); 55 | transform: translateX(26px); 56 | } 57 | -------------------------------------------------------------------------------- /app/pages/assets/style/homepage.css: -------------------------------------------------------------------------------- 1 | #photos { 2 | /* Prevent vertical gaps */ 3 | line-height: 0; 4 | /* background-color: #E0E0E0; */ 5 | 6 | -webkit-column-count: 5; 7 | -webkit-column-gap: 0px; 8 | -moz-column-count: 5; 9 | -moz-column-gap: 0px; 10 | column-count: 5; 11 | column-gap: 0px; 12 | } 13 | 14 | #photos img { 15 | /* Just in case there are inline attributes */ 16 | width: 100% !important; 17 | height: auto !important; 18 | } 19 | 20 | #photos { 21 | -moz-column-count: 4; 22 | -webkit-column-count: 4; 23 | column-count: 4; 24 | } 25 | 26 | @media (max-width: 1000px) { 27 | #photos { 28 | -moz-column-count: 3; 29 | -webkit-column-count: 3; 30 | column-count: 3; 31 | } 32 | } 33 | @media (max-width: 800px) { 34 | #photos { 35 | -moz-column-count: 2; 36 | -webkit-column-count: 2; 37 | column-count: 2; 38 | } 39 | } 40 | @media (max-width: 400px) { 41 | #photos { 42 | -moz-column-count: 1; 43 | -webkit-column-count: 1; 44 | column-count: 1; 45 | } 46 | } 47 | /* Container holding the image and the text */ 48 | .glam-container { 49 | position: relative; 50 | margin: 0; 51 | padding: 0; 52 | margin-bottom: 30px; 53 | } 54 | 55 | /* Bottom right text */ 56 | .text-block { 57 | /* position: absolute; 58 | bottom: 20px; 59 | right: 30px; 60 | background-color: rgba(0, 0, 0, 0.7); 61 | color: white; 62 | padding: 10px; */ 63 | position: absolute; 64 | top: 0; 65 | left: 0; 66 | background-color: var(--main-tt); 67 | color: white; 68 | display: flex; 69 | width: 100%; 70 | height: 100%; 71 | justify-content: center; 72 | align-items: center; 73 | transition: all .6s; 74 | opacity: 1; 75 | } 76 | .glam-container .text-block { 77 | padding: 0 1em; 78 | } 79 | .text-block:hover { 80 | opacity: 0; 81 | } 82 | 83 | .text-block h4 { 84 | margin: 0; 85 | font-size: 1.3rem !important; 86 | } 87 | -------------------------------------------------------------------------------- /app/pages/assets/style/linechart.css: -------------------------------------------------------------------------------- 1 | .line { 2 | fill: none; 3 | stroke: var(--main); 4 | stroke-width: 2px; 5 | /* display: none; */ 6 | } 7 | 8 | .image_line { 9 | fill: none; 10 | stroke: var(--accent-green); 11 | stroke-width: 2px; 12 | } 13 | 14 | .grid line { 15 | stroke: lightgrey; 16 | stroke-opacity: 0.7; 17 | shape-rendering: crispEdges; 18 | } 19 | 20 | .grid path { 21 | stroke-width: 0; 22 | } 23 | 24 | .zoom-area { 25 | /* cursor: move; */ 26 | fill: none; 27 | pointer-events: all; 28 | } 29 | -------------------------------------------------------------------------------- /app/pages/assets/style/search-page.css: -------------------------------------------------------------------------------- 1 | #visualizations h1{ 2 | margin-right: 5rem 3 | } 4 | 5 | #visualizations .row{ 6 | margin-left: 5rem; 7 | max-height: 70vh; 8 | overflow-y: auto; 9 | } 10 | 11 | #resultsSearchBar { 12 | width: 40%; 13 | } 14 | 15 | .searchResults { 16 | display: flex; 17 | left: 1rem; 18 | background-color: #fff; 19 | color: var(--main); 20 | box-shadow: 0 -1px 15px 0px rgba(0,0,0,.1); 21 | border-top: 1px solid rgba(0,0,0,.1); 22 | padding: 1rem 1rem 0; 23 | bottom: 5rem; 24 | } -------------------------------------------------------------------------------- /app/pages/assets/style/usage.css: -------------------------------------------------------------------------------- 1 | /* Add shadow effect to chart. If you don't like it, get rid of it. */ 2 | svg { 3 | /* -webkit-filter: drop-shadow( 0px 3px 3px rgba(0,0,0,.3) ); */ 4 | /* filter: drop-shadow( 0px 3px 3px rgba(0,0,0,.25) ); */ 5 | } 6 | 7 | /* Make the percentage on the text labels bold*/ 8 | .labelName tspan { 9 | font-style: normal; 10 | font-weight: 700; 11 | } 12 | 13 | /* In biology we generally italicise species names. */ 14 | .labelName { 15 | font-size: 0.9em; 16 | font-style: italic; 17 | } 18 | 19 | /* #right_sidebar_list { 20 | height: 73vh !important; 21 | } */ 22 | 23 | .list_item .list_item_panel { 24 | padding: 0; 25 | max-height: 0; 26 | overflow: hidden; 27 | } 28 | 29 | .list_item_active .list_item_panel { 30 | padding: 1.5rem 0 0; 31 | max-height: 65vh; 32 | overflow-y : auto 33 | } 34 | 35 | .wiki_column table tbody tr:not(:last-child) { 36 | border-bottom: 1px solid rgba(100, 100, 100, .2); 37 | } 38 | 39 | .wiki_column table tbody tr td a:not(:last-child)::after { 40 | content: ","; 41 | } 42 | -------------------------------------------------------------------------------- /app/pages/assets/style/user-contribution.css: -------------------------------------------------------------------------------- 1 | #main_contributions_container { 2 | height: 90vh; 3 | width: 100%; 4 | } 5 | 6 | .zoom { 7 | /* cursor: move; */ 8 | fill: none; 9 | /* pointer-events: all; */ 10 | } 11 | -------------------------------------------------------------------------------- /app/pages/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/synapta/cassandra-GLAM-tools/613cc88f0570a09146e1d4e70ab4509050280139/app/pages/favicon.ico -------------------------------------------------------------------------------- /app/pages/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | GLAM stat tool (Cassandra) 5 | 6 | 7 | 8 | 12 | 16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 |
24 | 25 | 26 | 27 |
28 |
29 |
30 |

§[messages.home-slogan]§

31 |
32 |
33 |
34 | 39 |
40 |
41 |
42 |
43 |
44 | 45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 | 54 |
55 | 59 | 63 | 67 | 68 | 69 | 70 | 71 | 72 | -------------------------------------------------------------------------------- /app/pages/js/admin-panel.js: -------------------------------------------------------------------------------- 1 | $(function() { 2 | // Help 3 | $('#admin-help').mouseenter(function() { 4 | $('#glam-legend').stop().fadeIn(200); 5 | }).mouseleave(function() { 6 | $('#glam-legend').stop().fadeOut(200); 7 | }); 8 | // Get data 9 | $.getJSON('/api/admin/glams', function(items) { 10 | if (items.length > 0) { 11 | let running = 0; 12 | let paused = 0; 13 | let failed = 0; 14 | let pending = 0; 15 | $.get('/views/templates/glam-preview.tpl', function(tpl) { 16 | var template = Handlebars.compile(tpl); 17 | items.forEach(function(el, idx) { 18 | // create object 19 | let obj = {}; 20 | obj.glamID = el.name; 21 | obj.glamFullName = el.fullname; 22 | obj.image_url = el.image; 23 | obj.glamCategory = el.category.replace("Category:", ""); 24 | if (el.lastrun !== null) { 25 | obj.lastrun = moment(el.lastrun).format("MMM Do YY"); 26 | } 27 | obj.status = el.status; 28 | 29 | switch (obj.status) { 30 | case "running": 31 | running++; 32 | obj.command = "§[admin.pause]§"; 33 | obj.statusLoc = "§[admin.running]§"; 34 | obj.paused = false; 35 | break; 36 | case "pending": 37 | pending++; 38 | obj.command = "§[admin.pause]§"; 39 | obj.statusLoc = "§[admin.pending]§"; 40 | obj.paused = false; 41 | break; 42 | case "paused": 43 | paused++; 44 | obj.command = "§[admin.restart]§"; 45 | obj.paused = true; 46 | obj.statusLoc = "§[admin.paused]§"; 47 | break; 48 | case "failed": 49 | failed++; 50 | obj.command = "§[admin.retry]§"; 51 | obj.paused = true; 52 | obj.statusLoc = "§[admin.failed]§"; 53 | break; 54 | } 55 | // if (isEven(idx)) { 56 | // $('#glams-list-left').append(template(obj)); 57 | // } else { 58 | // $('#glams-list-right').append(template(obj)); 59 | // } 60 | $('#glams-list-unique').append(template(obj)); 61 | }); 62 | // Display counts 63 | $('#total-glams').html(items.length); 64 | $('#running-glams').html(running); 65 | $('#pending-glams').html(pending); 66 | $('#paused-glams').html(paused); 67 | $('#failed-glams').html(failed); 68 | if (is_touch_device()) { 69 | // show always 70 | $('.glam-controls').fadeIn(); 71 | } else { 72 | // show on hover 73 | $('.glam-block').mouseenter( function() { 74 | $(this).find('.glam-controls').fadeIn(200); 75 | }).mouseleave( function() { 76 | $(this).find('.glam-controls').fadeOut(100); 77 | }); 78 | } 79 | // on click pause/unpause 80 | $('.glam-block .glam-controls.command').click(function() { 81 | let pause = !$(this).data('glampaused'); 82 | $.ajax({ 83 | type: "PUT", 84 | url:'/api/admin/glams/' + $(this).data('glamid'), 85 | headers: { "Content-Type": "application/json" }, 86 | data: JSON.stringify({paused: pause}), 87 | success: function(data) { 88 | location.reload(); 89 | }, 90 | error: function(err) { 91 | alert('Something went wrong!'); 92 | $(this).removeClass('disabled'); 93 | } 94 | }); 95 | $(this).addClass('disabled'); 96 | }); 97 | }); 98 | } else { 99 | $('#glams-list').html('

§[messages.no-glams]§

'); 100 | } 101 | }); 102 | // filter functions 103 | $('.glam-filter').click(function() { 104 | let id = this.id.replace('-glams', ''); 105 | filterGlams(id); 106 | }); 107 | }); 108 | 109 | function isEven(number) { 110 | return number % 2 === 0; 111 | } 112 | 113 | function filterGlams(id) { 114 | let $btn = $('#' + id + '-btn'); 115 | $('.filt-btn').removeClass('active-btn'); 116 | switch (id) { 117 | case 'total': 118 | $('.glam-block').fadeIn(); 119 | break; 120 | case 'running': 121 | $('.glam-block.pending').fadeOut(500, function() { 122 | $('.glam-block.running').fadeIn(300); 123 | }); 124 | $('.glam-block.paused').fadeOut(400); 125 | $('.glam-block.failed').fadeOut(400); 126 | break; 127 | case 'pending': 128 | $('.glam-block.running').fadeOut(500, function() { 129 | $('.glam-block.pending').fadeIn(300); 130 | }); 131 | $('.glam-block.paused').fadeOut(400); 132 | $('.glam-block.failed').fadeOut(400); 133 | break; 134 | case 'paused': 135 | $('.glam-block.running').fadeOut(500, function() { 136 | $('.glam-block.paused').fadeIn(300); 137 | }); 138 | $('.glam-block.pending').fadeOut(400); 139 | $('.glam-block.failed').fadeOut(400); 140 | break; 141 | case 'failed': 142 | $('.glam-block.running').fadeOut(500, function() { 143 | $('.glam-block.failed').fadeIn(300); 144 | }); 145 | $('.glam-block.pending').fadeOut(400); 146 | $('.glam-block.paused').fadeOut(400); 147 | break; 148 | } 149 | $btn.addClass('active-btn'); 150 | } 151 | -------------------------------------------------------------------------------- /app/pages/js/contacts.js: -------------------------------------------------------------------------------- 1 | let recaptchaToken; 2 | 3 | $(function () { 4 | $("#send-message").click(function (e) { 5 | const firstName = $("#firstname").val(); 6 | const lastName = $("#lastname").val(); 7 | const userMail = $("#usermail").val(); 8 | const contactsBody = $("#contactsBody").val(); 9 | 10 | const re = /\S+@\S+\.\S+/; 11 | const validMail = re.test(userMail); 12 | 13 | e.preventDefault(); 14 | 15 | if (!(firstName && lastName && validMail && contactsBody)) { 16 | $("#validation-error").fadeIn(200); 17 | return; 18 | } 19 | 20 | $("#validation-error").fadeOut(200); 21 | $("#save-error").fadeOut(200); 22 | 23 | let mailFields = { 24 | firstName, lastName, userMail, contactsBody, 'g-recaptcha-response': recaptchaToken 25 | }; 26 | 27 | $.ajax({ 28 | type: "POST", 29 | url: "/api/sendMail", 30 | headers: { "Content-Type": "application/json" }, 31 | data: JSON.stringify(mailFields), 32 | success: function (data) { 33 | $("#save-error").fadeOut(200); 34 | $("#save-success").fadeIn(200); 35 | setTimeout(function () { window.location = "/";}, 3000); 36 | }, 37 | error: function (err) { 38 | $("#save-success").hide(); 39 | $("#save-error").fadeIn(200); 40 | } 41 | }); 42 | }); 43 | }); 44 | 45 | function setRecaptchaToken(token) { 46 | recaptchaToken = token; 47 | } 48 | -------------------------------------------------------------------------------- /app/pages/js/customize-tool.js: -------------------------------------------------------------------------------- 1 | // // Create NEW glam 2 | $(function () { 3 | $.getJSON("/api/languages", function (langs) { 4 | availableLanguages = langs; 5 | const selectLang = $("#defaultLang"); 6 | selectLang.html(""); 7 | availableLanguages.forEach(lang => { 8 | const opt = ``; 9 | selectLang.append(opt); 10 | }); 11 | }).done(() => { 12 | $.getJSON("/api/settings", function (res) { 13 | if (res) { 14 | if (res.defaultLanguage) { 15 | const selectLang = $("#defaultLang"); 16 | selectLang.val(res.defaultLanguage); 17 | } 18 | if (res.homeTitle) { 19 | $("#setHomeTitle").val(res.homeTitle); 20 | } else { 21 | $("#setHomeTitle").val("§[admin.set-title-placeholder]§"); 22 | } 23 | 24 | if (res.ownerUrl) { 25 | $("#ownerUrl").val(res.ownerUrl); 26 | } 27 | 28 | if (res.ownerName) { 29 | $("#ownerName").val(res.ownerName); 30 | } 31 | 32 | if (res.recommenderLangs) { 33 | $("#recommenderLangs").val(res.recommenderLangs); 34 | } 35 | } 36 | }); 37 | }); 38 | 39 | $("#uploadForm").submit(async function (e) { 40 | e.preventDefault(); 41 | const logoFile = document.getElementById("logoFile"); 42 | if (logoFile.files.length) { 43 | const buffer = await logoFile.files[0].arrayBuffer(); 44 | $.ajax({ 45 | type: "POST", 46 | url: "/api/admin/owner-logo", 47 | data: buffer, 48 | processData: false, 49 | contentType: "image/svg+xml", 50 | success: function (data) { 51 | $("#upload-error").fadeOut(200); 52 | $("#upload-success").fadeIn(200); 53 | }, 54 | error: function (err) { 55 | $("#upload-success").hide(); 56 | $("#upload-error").fadeIn(200); 57 | } 58 | }); 59 | } 60 | }); 61 | 62 | $("#save-settings").click(function (e) { 63 | e.preventDefault(); 64 | let settings = { 65 | homeTitle: $("#setHomeTitle").val(), 66 | defaultLanguage: $("#defaultLang").val(), 67 | ownerUrl: $("#ownerUrl").val(), 68 | ownerName: $("#ownerName").val(), 69 | introBody: $("#introBody").val(), 70 | contactsBody: $("#contactsBody").val(), 71 | contactsMail: $("#contactsMail").val(), 72 | recommenderLangs: $("#recommenderLangs").val() 73 | }; 74 | 75 | $.ajax({ 76 | type: "POST", 77 | url: "/api/admin/settings", 78 | headers: { "Content-Type": "application/json" }, 79 | data: JSON.stringify(settings), 80 | success: function (data) { 81 | $("#settings-error").fadeOut(200); 82 | $("#settings-success").fadeIn(200); 83 | }, 84 | error: function (err) { 85 | $("#settings-success").hide(); 86 | $("#settings-error").fadeIn(200); 87 | } 88 | }); 89 | }); 90 | 91 | $("#update-button").click(function (e) { 92 | e.preventDefault(); 93 | 94 | $.get("/api/admin/update-tool", function () { 95 | $("#refresh-error").fadeOut(200); 96 | $("#refresh-success").fadeIn(200); 97 | }).fail(function () { 98 | $("#refresh-success").fadeOut(200); 99 | $("#refresh-error").fadeIn(200); 100 | }); 101 | }); 102 | }); 103 | -------------------------------------------------------------------------------- /app/pages/js/edit-glam-panel.js: -------------------------------------------------------------------------------- 1 | var PWD_EDITED = false; 2 | 3 | // Edit glam 4 | $(function() { 5 | var id = window.location.href.toString().split('/')[5]; 6 | 7 | $.getJSON('/api/admin/glams/' + id, function(data) { 8 | $('#glamID').val(data.name); 9 | $('#glamFullName').val(data.fullname); 10 | $('#glamCategory').val(data.category.replace("Category:", "")); 11 | $('#featuredImageURL').val(data.image); 12 | }); 13 | 14 | 15 | $('#edit-glam-button').click(function(e) { 16 | if (validateGlam()) { 17 | e.preventDefault(); 18 | let glamData = { 19 | "name": $('#glamID').val(), 20 | "fullname": $('#glamFullName').val(), 21 | "category": $('#glamCategory').val(), 22 | "image": $('#featuredImageURL').val() 23 | }; 24 | 25 | if (PWD_EDITED) { 26 | glamData.password = $('#glamPassword').val(); 27 | } 28 | 29 | $.ajax({ 30 | type: "PUT", 31 | url:'/api/admin/glams/' + id, 32 | headers: { "Content-Type": "application/json" }, 33 | data: JSON.stringify(glamData), 34 | success: function(data) { 35 | $('#wrong-glam').fadeOut(200); 36 | $('#edit-glam-form').fadeOut(200, function() { 37 | $('#success-glam').fadeIn(200); 38 | }); 39 | }, 40 | error: function(err) { 41 | $('#wrong-glam').hide(); 42 | $('#wrong-glam').fadeIn(200); 43 | } 44 | }); 45 | } 46 | }); 47 | 48 | $('#edit-password-button').click(function(e) { 49 | e.preventDefault(); 50 | if ($('#password-field').is(':visible')) { 51 | $('#password-field').fadeOut(400); 52 | PWD_EDITED = false; 53 | $(this).text('§[admin.edit-password]§').removeClass('btn-warning').addClass('btn-danger'); 54 | } else { 55 | $('#password-field').fadeIn(400); 56 | PWD_EDITED = true; 57 | $(this).text('§[admin.keep-password]§').removeClass('btn-danger').addClass('btn-warning'); 58 | } 59 | }); 60 | }); 61 | 62 | function validateGlam() { 63 | if ($('#glamID').val() === "") return false; 64 | if ($('#glamFullName').val() === "") return false; 65 | if ($('#glamCategory').val() === "") return false; 66 | if ($('#featuredImageURL').val() === "") return false; 67 | return true; 68 | } 69 | -------------------------------------------------------------------------------- /app/pages/js/functions.js: -------------------------------------------------------------------------------- 1 | 2 | $(document).ready(function() { 3 | $.get('/api/glams', function (glams) { 4 | var photos = $('#photos'); 5 | if (glams.length > 0) { 6 | $.get('/views/templates/glam-homepage.tpl', function(tpl) { 7 | var template = Handlebars.compile(tpl); 8 | glams.forEach(function (glam, idx) { 9 | let obj = {}; 10 | obj.url = '/' + glam['name']; 11 | obj.category = glam['category']; 12 | obj.image_url = glam['image']; 13 | obj.title = glam['fullname']; 14 | 15 | switch (idx % 3) { 16 | case 0: 17 | // console.log(idx, ' first col'); 18 | $('#photos-1').append(template(obj)); 19 | break; 20 | case 1: 21 | // console.log(idx, ' snd col'); 22 | $('#photos-2').append(template(obj)); 23 | break; 24 | case 2: 25 | // console.log(idx, ' thirs col'); 26 | $('#photos-3').append(template(obj)); 27 | break; 28 | } 29 | }); 30 | }); 31 | } else { 32 | $('#photos-2').html('

§[messages.no-glams]§

'); 33 | } 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /app/pages/js/language.js: -------------------------------------------------------------------------------- 1 | $(function () { 2 | 3 | let availableLanguages = []; 4 | 5 | function populateSelectLang() { 6 | const selectLang = $("#change-lang-select"); 7 | if (!selectLang.length) { 8 | return; 9 | } 10 | selectLang.html(""); 11 | availableLanguages.forEach(lang => { 12 | const opt = ``; 13 | selectLang.append(opt); 14 | }); 15 | 16 | /*look for the "change-lang-parent" element*/ 17 | const selElemParent = document.getElementById("change-lang-parent"); 18 | const selElem = document.getElementById("change-lang-select"); 19 | 20 | /* reset value to the first option */ 21 | selectLang.val(availableLanguages[0].code); 22 | /* create a new DIV that will act as the selected item: */ 23 | let selDiv = document.createElement("DIV"); 24 | selDiv.setAttribute("class", "lang-selected"); 25 | /* show first option */ 26 | selDiv.innerHTML = selElem.options[0].innerHTML; 27 | selElemParent.appendChild(selDiv); 28 | /* create a new DIV that will contain the option list: */ 29 | let otherDiv = document.createElement("DIV"); 30 | otherDiv.setAttribute("class", "select-langs lang-hide"); 31 | for (let j = 1; j < selElem.length; j++) { 32 | /* for each option in the original select element, 33 | create a new DIV that will act as an option item: */ 34 | const optionDiv = document.createElement("DIV"); 35 | optionDiv.innerHTML = selElem.options[j].innerHTML; 36 | optionDiv.addEventListener("click", function (e) { 37 | /* when an item is clicked, update the original select box, and the selected item: */ 38 | let newSel; 39 | const prevOpt = this.parentNode.previousSibling; 40 | for (let i = 0; i < selElem.length; i++) { 41 | if (selElem.options[i].innerHTML === this.innerHTML) { 42 | selElem.selectedIndex = i; 43 | prevOpt.innerHTML = this.innerHTML; 44 | newSel = this.parentNode.getElementsByClassName("same-as-selected"); 45 | for (k = 0; k < newSel.length; k++) { 46 | newSel[k].removeAttribute("class"); 47 | } 48 | this.setAttribute("class", "same-as-selected"); 49 | const url = window.location.href.split("?lang=")[0]; 50 | const loc = `?lang=${selElem.value}`; 51 | window.location = url.endsWith("/") ? `${url}${loc}` : `${url}/${loc}`; 52 | break; 53 | } 54 | } 55 | prevOpt.click(); 56 | }); 57 | otherDiv.appendChild(optionDiv); 58 | } 59 | selElemParent.appendChild(otherDiv); 60 | selDiv.addEventListener("click", function (e) { 61 | /* when the select box is clicked, close any other select boxes, and open/close the current select box: */ 62 | e.stopPropagation(); 63 | closeAllSelect(this); 64 | this.nextSibling.classList.toggle("lang-hide"); 65 | this.classList.toggle("select-arrow-active"); 66 | }); 67 | } 68 | 69 | function renderChangeLang() { 70 | // if (availableLanguages.length) { 71 | // populateSelectLang(); 72 | // return; 73 | // } 74 | 75 | $.getJSON("/api/languages", function (langs) { 76 | availableLanguages = langs; 77 | populateSelectLang(); 78 | }); 79 | } 80 | 81 | 82 | function closeAllSelect(elem) { 83 | /* a function that will close all select boxes in the document, 84 | except the current select box: */ 85 | var x, y, i, arrNo = []; 86 | x = document.getElementsByClassName("select-langs"); 87 | y = document.getElementsByClassName("lang-selected"); 88 | for (i = 0; i < y.length; i++) { 89 | if (elem === y[i]) { 90 | arrNo.push(i); 91 | } else { 92 | y[i].classList.remove("select-arrow-active"); 93 | } 94 | } 95 | for (i = 0; i < x.length; i++) { 96 | if (arrNo.indexOf(i)) { 97 | x[i].classList.add("lang-hide"); 98 | } 99 | } 100 | } 101 | /* if the user clicks anywhere outside the select box, 102 | then close all select boxes: */ 103 | document.addEventListener("click", closeAllSelect); 104 | 105 | renderChangeLang(); 106 | }); 107 | -------------------------------------------------------------------------------- /app/pages/js/loader.js: -------------------------------------------------------------------------------- 1 | function is_touch_device() { 2 | var prefixes = " -webkit- -moz- -o- -ms- ".split(" "); 3 | var mq = function (query) { 4 | return window.matchMedia(query).matches; 5 | }; 6 | 7 | if ("ontouchstart" in window || (window.DocumentTouch && document instanceof DocumentTouch)) { 8 | return true; 9 | } 10 | 11 | // include the 'heartz' as a way to have a non matching MQ to help terminate the join 12 | // https://git.io/vznFH 13 | var query = ["(", prefixes.join("touch-enabled),("), "heartz", ")"].join(""); 14 | return mq(query); 15 | } 16 | // Load main sidebar 17 | $("#main-sidebar").load("/views/templates/sidebar.html", function () { 18 | $.getJSON("/api/settings", function (res) { 19 | if (res) { 20 | if (res.ownerUrl) { 21 | document.getElementById("owner-logo-url").href = res.ownerUrl; 22 | } else { 23 | document.getElementById("owner-logo-url").href = "https://www.wikimedia.ch/"; 24 | } 25 | if (res.ownerName) { 26 | document.getElementById("owner-logo-url").title = res.ownerName; 27 | } else { 28 | document.getElementById("owner-logo-url").title = "Wikimedia"; 29 | } 30 | if (res.introBody) { 31 | const intro = document.getElementById("custom-intro"); 32 | if (intro) { 33 | intro.innerHTML = res.introBody; 34 | } 35 | } 36 | if (res.contactsBody) { 37 | const intro = document.getElementById("contacts-intro"); 38 | if (intro) { 39 | intro.innerHTML = res.contactsBody; 40 | } 41 | } 42 | } 43 | }); 44 | 45 | // Load secondary sidebar 46 | $("#secondary-sidebar").load("/views/templates/secondary-sidebar.html", function () { 47 | // Fill GLAMS list 48 | $.get("/api/glams", function (glams) { 49 | if (glams.length > 0) { 50 | glams.forEach(function (g) { 51 | // create list element with link 52 | var list_element = $("
  • "); 53 | let a = $(""); 54 | // set attrs 55 | a.html(g["fullname"]); 56 | a.attr("href", "/" + g["name"]); 57 | a.attr("alt", g["category"]); 58 | // append 59 | list_element.append(a); 60 | $("#secondary-sidebar > .institutions-list").append(list_element); 61 | }); 62 | } 63 | }); 64 | // Set mouse handler 65 | $(".institutions-menu") 66 | .mouseenter(function () { 67 | $("#secondary-sidebar").css("left", "var(--sidebar-width)"); 68 | $(this).css("opacity", ".4"); 69 | }) 70 | .mouseleave(function () { 71 | if ($("#secondary-sidebar:hover").length === 0) { 72 | $("#secondary-sidebar").css("left", "0"); 73 | $(".institutions-menu").css("opacity", "1"); 74 | } 75 | }); 76 | // Set mouse handlers 77 | $("#secondary-sidebar").mouseleave(function () { 78 | if ($(".institutions-menu:hover").length === 0) { 79 | $(this).css("left", "0"); 80 | $(".institutions-menu").css("opacity", "1"); 81 | } 82 | }); 83 | }); 84 | 85 | document.getElementById("owner-logo-image").src = "/assets/owner-logo.svg"; 86 | 87 | document.getElementById("owner-logo-image").onerror = function () { 88 | document.getElementById("owner-logo-image").src = "/assets/img/owner-logo-default.svg"; 89 | }; 90 | }); 91 | 92 | // Load mobile header bar 93 | $("#mobile-header-bar").load("/views/templates/mobile-header.html", function () { 94 | // attach event to burger menu 95 | $(".left.sidebar").first().sidebar("attach events", "#sidebar-toggler", "show"); 96 | // no pointer events while menu is open (avoids to trigger click on logo) 97 | $(".left.sidebar").sidebar("setting", "onShow", function () { 98 | $("#mobile-header-bar").addClass("no-pointer-events"); 99 | }); 100 | $(".left.sidebar").sidebar("setting", "onHidden", function () { 101 | $("#mobile-header-bar").removeClass("no-pointer-events"); 102 | }); 103 | }); 104 | 105 | // Load mobile sidebar 106 | $("#mobile-sidebar").load("/views/templates/mobile-sidebar.html"); 107 | 108 | $("#main-footer").load("/views/templates/footer.html"); 109 | 110 | $(function () { 111 | $(".get-chart-info").click(function () { 112 | $(this).closest(".chart-preview-inner").css("transform", "rotateY(180deg)"); 113 | }); 114 | $(".close-chart-info").click(function () { 115 | $(this).closest(".chart-preview-inner").css("transform", "rotateY(0deg)"); 116 | }); 117 | $(".chart-preview-back").click(function () { 118 | $(this).closest(".chart-preview-inner").css("transform", "rotateY(0deg)"); 119 | }); 120 | }); 121 | -------------------------------------------------------------------------------- /app/pages/js/new-glam-panel.js: -------------------------------------------------------------------------------- 1 | // // Create NEW glam 2 | $(function() { 3 | $('#create-new-glam-button').click(function(e) { 4 | if (validateGlam()) { 5 | e.preventDefault(); 6 | let glamData = { 7 | "name": $('#glamID').val(), 8 | "fullname": $('#glamFullName').val(), 9 | "category": $('#glamCategory').val(), 10 | "image": $('#featuredImageURL').val(), 11 | "password": $('#glamPassword').val() 12 | }; 13 | 14 | $.ajax({ 15 | type: "POST", 16 | url:'/api/admin/glams', 17 | headers: { "Content-Type": "application/json" }, 18 | data: JSON.stringify(glamData), 19 | success: function(data) { 20 | $('#wrong-glam').fadeOut(200); 21 | $('#new-glam-form').fadeOut(200, function() { 22 | $('#success-glam').fadeIn(200); 23 | }); 24 | }, 25 | error: function(err) { 26 | $('#wrong-glam').hide(); 27 | $('#wrong-glam').fadeIn(200); 28 | } 29 | }); 30 | } 31 | }); 32 | }); 33 | 34 | function validateGlam() { 35 | if ($('#glamID').val() === "") return false; 36 | if ($('#glamFullName').val() === "") return false; 37 | if ($('#glamCategory').val() === "") return false; 38 | if ($('#featuredImageURL').val() === "") return false; 39 | return true; 40 | } 41 | -------------------------------------------------------------------------------- /app/pages/views/about.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | §[messages.glam-tool]§ (Cassandra) 5 | 6 | 7 | 8 | 12 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 |
    24 | 25 |
    26 | 27 | 28 | 29 | 30 | 31 |
    32 |
    33 |
    34 |

    §[messages.introduction]§

    35 |
    36 |
    37 |
    38 | 39 |
    40 |
    41 |
    62 |
    63 |
    64 |
    65 | 69 | 73 | 77 | 81 | 85 | 86 | 87 | 88 | 89 | -------------------------------------------------------------------------------- /app/pages/views/category-network/tpl/category-network.tpl: -------------------------------------------------------------------------------- 1 | {{#each nodes}} 2 |
    3 |
    4 | 50 |
    51 |
    52 |
    53 | §[messages.level]§ 54 |
    55 |
    59 | {{group}} 60 |
    61 |
    62 |
    63 |
    64 | §[messages.files]§ 65 |
    66 |
    70 | {{files}} 71 |
    72 |
    73 |
    74 |
    75 |
    76 |
    77 |
    78 | {{/each}} -------------------------------------------------------------------------------- /app/pages/views/category-network/tpl/unused-file-list.tpl: -------------------------------------------------------------------------------- 1 |
    2 |
    3 | 4 | 5 | 21 | 22 |
    23 |
    24 |
    -------------------------------------------------------------------------------- /app/pages/views/dashboard-metabase/functions.js: -------------------------------------------------------------------------------- 1 | const glam = window.location.href.toString().split("/")[3]; 2 | let SUBCATEGORY; 3 | 4 | function setCategoryCb(category) { 5 | SUBCATEGORY = category; 6 | } 7 | 8 | $(document).ready(function () { 9 | const baseurl = document.location.href; 10 | let h = baseurl.split("/"); 11 | let cat = h[5]; 12 | if (cat) { 13 | const newUrl = h.slice(0, 5).join("/"); 14 | if (newUrl) { 15 | window.location = newUrl; 16 | } 17 | } 18 | setCategory(setCategoryCb); 19 | $("#download_dashboard_link").attr("href", "/api/" + glam + "/dashboard/download"); 20 | $.getJSON("/api/" + glam + "/dashboard", function (res) { 21 | const iframe = document.getElementById("dashboard-metabase"); 22 | iframe.src = res.iframeUrl; 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /app/pages/views/file-page/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | §[messages.file-overview]§ | §[messages.glam-tool]§ (Cassandra) 5 | 6 | 7 | 8 | 12 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 |
    26 |
    27 | 28 |
    29 | 32 | 38 |
    39 | 40 |
    41 |
    42 | 43 |
    44 |
    45 | 46 |
    47 |

    §[messages.usage]§

    48 |
    49 |
    50 |
    51 |
    52 | 53 |
    54 |

    §[messages.views]§

    55 |
    56 |
    57 |
    58 |
    59 |
    60 |
    61 |
    62 |
    63 |
    64 | 65 |
    66 |
    67 |
    68 |
    §[messages.file-details]§
    69 |
    70 |
    71 |
    §[messages.file]§
    72 |

    73 | 74 | §[messages.go]§ 75 |

    76 |
    77 |
    78 |
    §[messages.category-in-commons]§
    79 |

    80 | 81 | §[messages.go]§ 82 |

    83 |
    84 |
    85 |
    86 |
    87 | 88 | 92 | 96 | 100 | 104 | 108 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | -------------------------------------------------------------------------------- /app/pages/views/file-page/tpl/file-template.tpl: -------------------------------------------------------------------------------- 1 |
    2 |
    3 | {{image}} 4 | 5 | 6 | {{#if thumbnail_url}} 7 |
    8 | {{image_name}} 9 |
    10 | {{/if}} 11 |

    12 | 17 | {{image_name}} 18 | 19 |

    20 |
    21 |
    22 |

    23 | §[messages.part-of]§ 24 | 25 | {{cat_number}} 26 | 27 | {{cat_title}} 28 |

    29 | 30 | 31 | {{#each cats}} 32 | 33 | 36 | 37 | {{/each}} 38 | 39 |
    34 | {{cat_name}} 35 |
    40 |
    41 | {{#if usage}} 42 |
    43 |

    44 | §[messages.used-in]§ 45 | 46 | {{usage}} 47 | 48 | §[messages.pages-of]§ 49 | 50 | {{projects}} 51 | 52 | §[messages.projects]§ 53 |

    54 | 55 | 56 | {{#each wikis}} 57 | 58 | 63 | 70 | 71 | {{/each}} 72 | 73 |
    59 | 60 | {{wiki_name}} 61 | 62 | 64 | {{#each wiki_links}} 65 | 66 | {{wiki_page}} 67 | 68 | {{/each}} 69 |
    74 |
    75 | {{else if recommender.length}} 76 |
    77 |

    78 | §[messages.related-wikidata-entities]§ 79 |

    80 | 81 | 82 | {{#each recommender}} 83 | 84 | 89 | {{#each wikis}} 90 | 95 | {{/each}} 96 | 97 | {{/each}} 98 | 99 |
    85 | 86 | {{label}} 87 | 88 | 91 | 92 | {{site}} 93 | 94 |
    100 |
    101 | {{/if}} 102 |
    103 |

    104 | §[messages.views-stats]§ 105 |

    106 | 107 | 108 | 109 | 112 | 117 | 118 | 119 | 122 | 127 | 128 | 129 | 132 | 137 | 138 | 139 |
    110 | §[messages.views-total]§ 111 | 113 | 114 | {{tot}} 115 | 116 |
    120 | §[messages.daily-average]§ 121 | 123 | 124 | {{av}} 125 | 126 |
    130 | §[messages.daily-median]§ 131 | 133 | 134 | {{median}} 135 | 136 |
    140 |
    141 |
    -------------------------------------------------------------------------------- /app/pages/views/page-views/tpl/views.tpl: -------------------------------------------------------------------------------- 1 | {{#each files}} 2 |
    3 |
    4 | 32 |
    33 |
    34 |
    35 | 36 | §[messages.total]§ 37 | 38 |
    39 |
    43 | {{tot}} 44 |
    45 |
    46 |
    47 |
    48 | 49 | §[messages.avg-day]§ 50 | 51 |
    52 |
    56 | {{av}} 57 |
    58 |
    59 |
    60 |
    61 | 62 | §[messages.median]§ 63 | 64 |
    65 |
    69 | {{median}} 70 |
    71 |
    72 |
    73 |
    74 |
    75 |
    76 | {{/each}} -------------------------------------------------------------------------------- /app/pages/views/recommender-page/recommender.tpl: -------------------------------------------------------------------------------- 1 |
    2 |
    3 | 4 | §[messages.file]§: 5 | 6 | 11 |

    {{image_name}}

    12 |
    13 |
    19 |
    20 |
    21 |
    22 |
    23 | 28 | {{image_name}} 29 | 30 |
    31 |
    32 |
    33 |
    §[messages.wikidata-suggestion]§
    34 |
    §[messages.wikipedia-pages]§
    35 |
    36 | {{#each wikis}} 37 |
    38 |
    39 | {{label}} 40 |
    41 | {{#each media}} 42 | {{#if lang}} 43 |
    44 | {{lang}} : 45 | {{label}} 50 |
    51 | {{/if}} 52 | {{/each}} 53 |
    54 | {{/each}} 55 |
    56 |
    57 |
    58 |
    -------------------------------------------------------------------------------- /app/pages/views/search-page/functions.js: -------------------------------------------------------------------------------- 1 | let page = 0; 2 | let limit = false; 3 | function search(append) { 4 | const baseUrl = window.location.href.toString(); 5 | const urlSplit = baseUrl.split("/"); 6 | const db = urlSplit[3]; 7 | const query = urlSplit[5] && !urlSplit[5].includes("?lang") ? urlSplit[5] : ""; 8 | 9 | const params = "?page=" + page + "&limit=100"; 10 | const url = "/api/" + db + "/search/%25" + query + "%25" + params; 11 | if (page === 0) { 12 | $("#resultsSearch") 13 | .off("scroll") 14 | .scroll(loadMoreOnScroll.bind($("#resultsSearch"))); 15 | } 16 | $("#searchFilesBar").val(decodeURI(query).replace("_", " ")); 17 | // let template_source = "/views/category-network/tpl/unused-file-list-dropdown.tpl"; 18 | let target = "#resultsSearch"; 19 | let tpl = 20 | " {{#each files}}\n" + 21 | "
    \n" + 22 | ' {{file}}\n' + 23 | "
    \n" + 24 | " {{/each}}"; 25 | 26 | $.getJSON(url, d => { 27 | let template = Handlebars.compile(tpl); 28 | let temp = []; 29 | if (d.length === 0) { 30 | // show "no more elements" 31 | $("#resultsSearch").append('

    §[messages.no-more]§

    '); 32 | limit = true; 33 | // remove handler 34 | $("#resultsSearch").off("scroll", loadMoreOnScroll); 35 | } else { 36 | d.forEach(file => { 37 | let url = "/" + db + "/file/" + file; 38 | temp.push({ 39 | url: url, 40 | file: cleanImageName(file.replace(/_/g, " ")) 41 | }); 42 | }); 43 | if (append) { 44 | $(target).append(template({ files: temp })); 45 | } else { 46 | $(target).html(template({ files: temp })); 47 | } 48 | } 49 | }); 50 | } 51 | 52 | function searchFiles(force) { 53 | let search = $("#searchFilesBar").val(); 54 | if (event && event.keyCode === 13) { 55 | force = true; 56 | } 57 | if (force) { 58 | if (search.length >= 3) { 59 | let db = window.location.href.split("/")[3]; 60 | search = search.replace(/\s/g, "_"); 61 | window.location.href = "/" + db + "/search/" + search; 62 | } else { 63 | $("#resultsSearchBar").popover("show"); 64 | } 65 | } 66 | } 67 | 68 | function loadMoreOnScroll() { 69 | if ( 70 | $("#resultsSearch").scrollTop() + $("#resultsSearch").innerHeight() >= 71 | $("#resultsSearch")[0].scrollHeight && 72 | !limit 73 | ) { 74 | // if reached end of div and there are more elements to load 75 | // calc new page number 76 | page++; 77 | search(true); 78 | } 79 | } 80 | 81 | $(function () { 82 | search(); 83 | setCategory(); 84 | let db = window.location.href.toString().split("/")[3]; 85 | $("#institutionId").attr("href", "/" + db); 86 | }); 87 | -------------------------------------------------------------------------------- /app/pages/views/search-page/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | §[messages.file-overview]§ | §[messages.glam-tool]§ (Cassandra) 5 | 6 | 7 | 8 | 12 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 |
    27 |
    28 | 29 |
    30 | 33 | 39 |
    47 | 55 | 58 |
    59 |
    60 | 61 |
    62 |
    63 |

    §[messages.files]§

    64 |
    65 |
    66 |
    67 |
    68 |
    69 | 70 |
    71 |
    72 |
    73 |
    §[messages.file-search]§
    74 |
    75 |
    76 |
    §[messages.category-in-commons]§
    77 |

    78 | 79 | §[messages.go]§ 80 |

    81 |
    82 |
    83 |
    84 |
    85 | 86 | 90 | 94 | 98 | 102 | 106 | 107 | 108 | 109 | 110 | 111 | -------------------------------------------------------------------------------- /app/pages/views/templates/footer.html: -------------------------------------------------------------------------------- 1 |
    2 |

    §[messages.developed-by]§

    3 |

    4 | Wikimedia CH 5 |

    6 |

    7 | Synapta 8 |

    9 |
    10 | -------------------------------------------------------------------------------- /app/pages/views/templates/glam-homepage.tpl: -------------------------------------------------------------------------------- 1 | 2 |
    3 | 4 |
    5 |

    6 | {{title}} 7 |

    8 |
    9 |
    10 |
    -------------------------------------------------------------------------------- /app/pages/views/templates/glam-preview.tpl: -------------------------------------------------------------------------------- 1 |
    2 |
    3 |
    4 |

    5 | {{statusLoc}} 6 |

    7 | 8 | §[admin.edit]§ 9 | 10 | 15 | {{command}} 16 | 17 |
    18 |

    19 | {{glamFullName}} 20 | ( 21 | {{glamID}} 22 | ) 23 |

    24 |

    25 | 26 | §[admin.cat]§: 27 | 28 | 29 | {{glamCategory}} 30 | 31 |

    32 | {{#if lastrun}} 33 |

    34 | 35 | §[admin.lastrun]§: 36 | 37 | {{lastrun}} 38 |

    39 | {{/if}} 40 |
    41 |
    42 | {{glamFullName}} 43 |
    44 | 45 |
    -------------------------------------------------------------------------------- /app/pages/views/templates/mobile-header.html: -------------------------------------------------------------------------------- 1 | 4 | 7 | -------------------------------------------------------------------------------- /app/pages/views/templates/mobile-sidebar.html: -------------------------------------------------------------------------------- 1 | 28 | -------------------------------------------------------------------------------- /app/pages/views/templates/secondary-sidebar.html: -------------------------------------------------------------------------------- 1 |
    2 | 3 |
    4 | -------------------------------------------------------------------------------- /app/pages/views/templates/sidebar.html: -------------------------------------------------------------------------------- 1 | 6 |
    7 |
    §[messages.institutions]§
    8 |
    9 | §[messages.about-project]§ 16 | 23 | 35 | 52 | 57 | -------------------------------------------------------------------------------- /app/pages/views/unused-files-page/categories.tpl: -------------------------------------------------------------------------------- 1 | {{#each nodes}} 2 |
    3 |
    4 |
    5 | 11 | {{name}} 12 | 13 |
    14 |
    15 |
    16 |
    17 | 18 | §[messages.level]§ 19 | 20 |
    21 |
    25 | {{group}} 26 |
    27 |
    28 |
    29 |
    30 | 31 | §[messages.files]§ 32 | 33 |
    34 |
    38 | {{files}} 39 |
    40 |
    41 |
    42 |
    43 |
    44 |
    45 |
    46 | {{/each}} -------------------------------------------------------------------------------- /app/pages/views/unused-files-page/functions.js: -------------------------------------------------------------------------------- 1 | const glam = window.location.href.toString().split("/")[3]; 2 | const db = window.location.href.toString().split("/")[3]; 3 | const query = window.location.href.toString().split("/")[5]; 4 | const unused = window.location.href.toString().split("/")[6] === 'unused' ? 'true' : 'false'; 5 | let page = 0; 6 | let ACTIVE_ITEM_ID; 7 | let SORT_BY = "by_name"; 8 | let limit = true; 9 | 10 | if (unused === 'false') { 11 | $('#page-title').text('§[messages.used-files]§'); 12 | } 13 | 14 | function getUnusedUrl() { 15 | let queryS = `?unused=${unused}`; 16 | if (query) { 17 | queryS = `?unused=${unused}&cat=${query}`; 18 | } 19 | return "/api/" + db + "/category" + queryS; 20 | } 21 | 22 | function highlight() { 23 | if (ACTIVE_ITEM_ID !== undefined) { 24 | $("#" + ACTIVE_ITEM_ID) 25 | .closest(".list_item") 26 | .addClass("list_item_active"); 27 | showUnusedFilesItem(); 28 | } 29 | $(".list_item").on("click", function () { 30 | let element = $(this).find(".id").attr("id"); 31 | if ($(this).hasClass("list_item_active")) { 32 | // reset 33 | resetHighlighted(); 34 | ACTIVE_ITEM_ID = undefined; 35 | } else { 36 | // reset 37 | resetHighlighted(); 38 | // highlight item 39 | $(this).addClass("list_item_active"); 40 | ACTIVE_ITEM_ID = element; 41 | showUnusedFilesItem(); 42 | } 43 | }); 44 | } 45 | 46 | function resetHighlighted() { 47 | // reset item highlight 48 | $(".list_item").removeClass("list_item_active"); 49 | $(".viewFiles").addClass("hiddenBtn"); 50 | hideUnusedFilesItem(); 51 | } 52 | 53 | function sorting_table() { 54 | $("#by_total").on("click", function () { 55 | if ($("#by_total").hasClass("active_order")) { 56 | //console.log("già selezionato"); 57 | } else { 58 | $("#by_level").toggleClass("active_order"); 59 | $("#by_total").toggleClass("active_order"); 60 | getCategories("desc_order"); 61 | $("#by_total").css("cursor", "default"); 62 | $("#by_level").css("cursor", "pointer"); 63 | } 64 | }); 65 | 66 | $("#by_level").on("click", function () { 67 | if ($("#by_level").hasClass("active_order")) { 68 | //console.log("già selezionato") 69 | } else { 70 | $("#by_level").toggleClass("active_order"); 71 | $("#by_total").toggleClass("active_order"); 72 | $("#by_level").css("cursor", "default"); 73 | $("#by_total").css("cursor", "pointer"); 74 | getCategories("by_name"); 75 | } 76 | }); 77 | } 78 | 79 | function unusedFilesLink(id, size, pageSel) { 80 | const queryS = `?unused=${unused}&limit=${size}&page=${pageSel}`; 81 | return "/api/" + db + "/category/" + encodeURIComponent(id) + "/" + queryS; 82 | } 83 | function getFiles(id, target, templateSource, total) { 84 | $.get(templateSource, tpl => { 85 | $.getJSON(unusedFilesLink(id, total < 1500 ? total : 1500, 0), d => { 86 | let template = Handlebars.compile(tpl); 87 | let temp = []; 88 | d.forEach(file => { 89 | let url = "/" + glam + "/file/" + file; 90 | temp.push({ 91 | url: url, 92 | file: cleanImageName(file.replace(/[-_]/g, " ")) 93 | }); 94 | }); 95 | 96 | $(target).html(template({ files: temp })); 97 | }); 98 | }); 99 | } 100 | 101 | function showUnusedFilesItem() { 102 | let id = $("#" + ACTIVE_ITEM_ID).data("category"); 103 | let tot = $("#" + ACTIVE_ITEM_ID).data("total"); 104 | let target = "#category" + ACTIVE_ITEM_ID; 105 | let template_source = "/views/unused-files-page/unused-file-list-dropdown.tpl"; 106 | getFiles(id, target, template_source, tot); 107 | $("#files" + ACTIVE_ITEM_ID).show(); 108 | } 109 | 110 | function hideUnusedFilesItem() { 111 | let target = "#category" + ACTIVE_ITEM_ID; 112 | $(target).html(""); 113 | $("#files" + ACTIVE_ITEM_ID).hide(); 114 | } 115 | 116 | function getCategories(order) { 117 | if (order === undefined) { 118 | order = SORT_BY; 119 | } else { 120 | SORT_BY = order; 121 | } 122 | let data_source = getUnusedUrl(); 123 | let target = "#resultsSearch"; 124 | $.getJSON(data_source, function (d) { 125 | sortNodes(d, order); 126 | if (d.nodes.length > 0) { 127 | let template_source = "/views/unused-files-page/categories.tpl"; 128 | $.get(template_source, function (tpl) { 129 | let template = Handlebars.compile(tpl); 130 | $(target).html(template(d)); 131 | sorting_table(); 132 | highlight(); 133 | }); 134 | } else { 135 | $(target).html("

    §[messages.no-files]§

    "); 136 | } 137 | }); 138 | } 139 | 140 | $(document).ready(function () { 141 | setCategory(); 142 | $("#institutionId").attr("href", "/" + db); 143 | getCategories(); 144 | }); 145 | -------------------------------------------------------------------------------- /app/pages/views/unused-files-page/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | §[messages.file-overview]§ | §[messages.glam-tool]§ (Cassandra) 5 | 6 | 7 | 8 | 12 | 16 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 |
    30 |
    31 | 32 |
    33 | 36 | 42 |
    43 | 44 |
    45 |
    46 |

    §[messages.unused-files]§

    47 |
    48 |
    49 |
    50 | §[messages.categories]§ 51 | §[messages.by-level]§ 52 | §[messages.by-total]§ 53 |
    54 |
    55 |
    56 |
    57 | 58 |
    59 |
    60 |
    61 |
    §[messages.file-search]§
    62 |
    63 |
    64 |
    §[messages.category-in-commons]§
    65 |

    66 | 67 | §[messages.go]§ 68 |

    69 |
    70 |
    71 |
    72 |
    73 | 77 | 78 | 79 | 83 | 87 | 91 | 95 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | -------------------------------------------------------------------------------- /app/pages/views/unused-files-page/unused-file-list-dropdown.tpl: -------------------------------------------------------------------------------- 1 |
    2 | {{#each files}} 3 | 8 | {{/each}} 9 |
    -------------------------------------------------------------------------------- /app/pages/views/usage/tpl/usage.tpl: -------------------------------------------------------------------------------- 1 | {{#each files}} 2 |
    3 |
    4 | 32 |
    33 |
    34 |
    35 | 36 | §[messages.usage]§ 37 | 38 |
    39 |
    43 | {{usage}} 44 |
    45 |
    46 |
    47 |
    48 | 49 | §[messages.projects]§ 50 | 51 |
    52 |
    56 | {{projects}} 57 |
    58 |
    59 |
    60 |
    61 |
    62 |
    63 |
    64 | 65 | 66 | {{#each wikis}} 67 | 68 | 75 | 85 | 86 | {{/each}} 87 | 88 |
    69 | 72 | {{wiki_name}} 73 | 74 | 76 | {{#each wiki_links}} 77 | 81 | {{wiki_page}} 82 | 83 | {{/each}} 84 |
    89 |
    90 |
    91 |
    92 |
    93 | {{/each}} -------------------------------------------------------------------------------- /app/pages/views/user-contributions/functions.js: -------------------------------------------------------------------------------- 1 | let ACTIVE_ITEM_ID; 2 | let SUBCATEGORY; 3 | 4 | function getUrl() { 5 | const url = window.location.href.toString(); 6 | const urlSplit = url.split("/"); 7 | const db = urlSplit[3]; 8 | const subcat = urlSplit[5] && !urlSplit[5].includes("?lang") ? urlSplit[5] : ""; 9 | let groupby = $("#groupby-select").val(); 10 | let query = subcat ? "?groupby=" + groupby + "&cat=" + subcat : "?groupby=" + groupby; 11 | return "/api/" + db + "/file/upload-date" + query; 12 | } 13 | 14 | function getUrlDataset() { 15 | const url = window.location.href.toString(); 16 | const urlSplit = url.split("/"); 17 | const db = urlSplit[3]; 18 | const subcat = urlSplit[5] && !urlSplit[5].includes("?lang") ? urlSplit[5] : ""; 19 | let groupby = $("#groupby-select").val(); 20 | let query = subcat ? "?groupby=" + groupby + "&cat=" + subcat : "?groupby=" + groupby; 21 | return "/api/" + db + "/file/upload-date/dataset" + query; 22 | } 23 | 24 | function getFileName(subcat) { 25 | let groupby = $("#groupby-select").val(); 26 | return subcat + " - user_contributions - " + groupby + "ly.csv"; 27 | } 28 | 29 | function getUrlAll() { 30 | const url = window.location.href.toString(); 31 | const urlSplit = url.split("/"); 32 | const db = urlSplit[3]; 33 | const subcat = urlSplit[5] && !urlSplit[5].includes("?lang") ? urlSplit[5] : ""; 34 | 35 | let groupby = $("#groupby-select").val(); 36 | let query = subcat ? "?groupby=" + groupby + "&cat=" + subcat : "?groupby=" + groupby; 37 | return "/api/" + db + "/file/upload-date-all" + query; 38 | } 39 | 40 | function pad(str, max) { 41 | str = str.toString(); 42 | return str.length < max ? pad("0" + str, max) : str; 43 | } 44 | 45 | function sidebar(type) { 46 | const template_source = "/views/user-contributions/tpl/user-contributions.tpl"; 47 | const target = "#right_sidebar_list"; 48 | 49 | $.get(template_source, function (tpl) { 50 | $.getJSON(getUrl(), function (data) { 51 | data.forEach(function (d) { 52 | let total = 0; 53 | 54 | d.files.forEach(function (d) { 55 | total += +d.count; 56 | }); 57 | d.total = total; 58 | 59 | d.user_id = d.user.replace(/\s/g, "_"); 60 | }); 61 | 62 | if (type === "by_num") { 63 | data = data.sort(function (a, b) { 64 | return b.total - a.total; 65 | }); 66 | } else { 67 | data = data.sort(function (a, b) { 68 | if (a.user < b.user) { 69 | return -1; 70 | } 71 | if (a.user > b.user) { 72 | return 1; 73 | } 74 | return 0; 75 | }); 76 | } 77 | 78 | data.forEach(function (d) { 79 | d.total = nFormatter(d.total); 80 | }); 81 | 82 | let template = Handlebars.compile(tpl); 83 | $(target).html(template({ users: data })); 84 | 85 | highlight(); 86 | }); 87 | }); 88 | } 89 | 90 | function sorting_sidebar() { 91 | let byNumEl = $("#by_num"); 92 | let byNameEl = $("#by_name"); 93 | byNumEl.on("click", function () { 94 | if (byNumEl.hasClass("active_order")) { 95 | //console.log("già selezionato") 96 | } else { 97 | byNameEl.toggleClass("active_order"); 98 | byNumEl.toggleClass("active_order"); 99 | sidebar("by_num"); 100 | byNumEl.css("cursor", "default"); 101 | byNameEl.css("cursor", "pointer"); 102 | } 103 | }); 104 | 105 | byNameEl.on("click", function () { 106 | if (byNameEl.hasClass("active_order")) { 107 | //console.log("già selezionato") 108 | } else { 109 | byNameEl.toggleClass("active_order"); 110 | byNumEl.toggleClass("active_order"); 111 | sidebar("by_name"); 112 | byNameEl.css("cursor", "default"); 113 | byNumEl.css("cursor", "pointer"); 114 | } 115 | }); 116 | } 117 | 118 | function setCategoryCb(category) { 119 | SUBCATEGORY = category; 120 | download(category); 121 | } 122 | 123 | function download(category) { 124 | // remove old link 125 | $("#download_dataset a").remove(); 126 | // recreate download link based on timespan 127 | $( 128 | '§[messages.download-dataset]§' 133 | ).appendTo("#download_dataset"); 134 | } 135 | 136 | function highlight() { 137 | if (ACTIVE_ITEM_ID !== undefined) { 138 | $("#" + ACTIVE_ITEM_ID) 139 | .closest(".list_item") 140 | .addClass("list_item_active"); 141 | } 142 | 143 | $(".list_item").on("click", function () { 144 | let element = $(this).find(".item").attr("id"); 145 | 146 | // highlight Sidebar and show bars 147 | if ($(this).hasClass("list_item_active")) { 148 | hideUserContributionsBars(); 149 | $(".list_item").removeClass("list_item_active"); 150 | ACTIVE_ITEM_ID = undefined; 151 | } else { 152 | showUserContributionsBars(element); 153 | $(".list_item").removeClass("list_item_active"); 154 | ACTIVE_ITEM_ID = element; 155 | $(this).addClass("list_item_active"); 156 | } 157 | }); 158 | } 159 | 160 | $(document).ready(function () { 161 | setCategory(setCategoryCb); 162 | sidebar("by_num"); 163 | dataviz(); 164 | how_to_read(); 165 | switch_page(); 166 | sorting_sidebar(); 167 | $("#groupby-select").change(function () { 168 | dataviz(); 169 | download(SUBCATEGORY); 170 | }); 171 | }); 172 | -------------------------------------------------------------------------------- /app/pages/views/user-contributions/tpl/user-contributions.tpl: -------------------------------------------------------------------------------- 1 | {{#each users}} 2 |
    3 |
    4 |
    5 | 6 | {{user}} 7 | 8 | 19 |
    20 |
    21 |
    22 |
    23 | 24 | §[messages.files]§ 25 | 26 |
    27 |
    31 | {{total}} 32 |
    33 |
    34 |
    35 |
    36 |
    37 |
    38 | {{/each}} -------------------------------------------------------------------------------- /app/server.js: -------------------------------------------------------------------------------- 1 | var express = require('express'); 2 | var apicache = require('apicache').options({ debug: false }).middleware; 3 | var morgan = require('morgan'); 4 | var cookieParser = require('cookie-parser'); 5 | var Sentry = require('@sentry/node'); 6 | var config = require('../config/config.json'); 7 | 8 | var app = express(); 9 | 10 | if (typeof config.raven !== 'undefined') { 11 | Sentry.init({dsn: config.raven.glamtoolsweb.DSN}); 12 | app.use(Sentry.Handlers.requestHandler()); 13 | } 14 | 15 | app.use(morgan('common')); 16 | app.use(express.raw({type: 'image/svg+xml', limit: '1mb'})); 17 | app.use(express.json()); 18 | app.use(cookieParser()); 19 | 20 | require('./routes.js')(app, apicache); 21 | 22 | if (typeof config.raven !== 'undefined') { 23 | app.use(Sentry.Handlers.errorHandler()); 24 | } 25 | 26 | var port = process.argv[2] ? parseInt(process.argv[2]) : 8081; 27 | 28 | var server = app.listen(port, function() { 29 | var host = server.address().address; 30 | var port = server.address().port; 31 | console.log('Server listening at http://%s:%s', host, port); 32 | }); 33 | -------------------------------------------------------------------------------- /config/config.example.json: -------------------------------------------------------------------------------- 1 | { 2 | "postgres": { 3 | "user": "cassandra", 4 | "password": "MYPOSTGRESPASSWORD", 5 | "host": "localhost", 6 | "port": 5432 7 | }, 8 | "metabase": { 9 | "url": "http://localhost:3000", 10 | "proxy": "http://localhost:8081", 11 | "username": "", 12 | "password": "", 13 | "database": { 14 | "user": "metabase", 15 | "password": "MYPOSTGRESPASSWORD", 16 | "host": "localhost", 17 | "port": 5432 18 | }, 19 | "secret": "" 20 | }, 21 | "mongodb": { 22 | "url": "mongodb://localhost:27017", 23 | "database": "cassandra", 24 | "collection": "glams" 25 | }, 26 | "wmflabs": { 27 | "host": "localhost", 28 | "port": 3306, 29 | "user": "MYWMFUSER", 30 | "password": "MYWMFPASSWORD", 31 | "database": "commonswiki_p" 32 | }, 33 | "admin": { 34 | "username": "admin", 35 | "password": "MYADMINPASSWORD" 36 | }, 37 | "glamUser": { 38 | "users": [ 39 | { 40 | "username": "glam", 41 | "password": "MYUSERPASSWORD" 42 | } 43 | ] 44 | }, 45 | "limits": { 46 | "categories": 1000, 47 | "images": 500000 48 | }, 49 | "mailgun": { 50 | "auth": { 51 | "api_key": "", 52 | "domain": "" 53 | }, 54 | "host": "api.eu.mailgun.net", 55 | "mailFrom": "", 56 | "mailTo": "" 57 | }, 58 | "recaptcha": { 59 | "siteKey": "", 60 | "secretKey": "" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /config/config.js: -------------------------------------------------------------------------------- 1 | var MongoClient = require('mongodb').MongoClient; 2 | var { Pool, Client } = require('pg'); 3 | var fs = require('fs'); 4 | 5 | var config = JSON.parse(fs.readFileSync("../config/config.json")); 6 | 7 | config.glamUser['realm'] = 'User area'; 8 | var glamUser = config.glamUser; 9 | glamUser.users.push(config.admin); 10 | config.admin['realm'] = 'Admin area'; 11 | 12 | exports.admin = config.admin; 13 | exports.glamUser = glamUser; 14 | exports.limits = config.limits; 15 | exports.wmflabs = config.wmflabs; 16 | exports.metabase = config.metabase; 17 | exports.recaptcha = config.recaptcha; 18 | 19 | const client = new MongoClient(config['mongodb']['url'], { useNewUrlParser: true }); 20 | 21 | var glams = {}; 22 | 23 | function isConnected(client) { 24 | return !!client && !!client.topology && client.topology.isConnected(); 25 | } 26 | 27 | function loadGlams(callback) { 28 | let query = () => { 29 | const db = client.db(config['mongodb']['database']); 30 | const collection = db.collection(config['mongodb']['collection']); 31 | collection.find({}).toArray((err, docs) => { 32 | docs.forEach((element) => { 33 | let glam = { 34 | 'name': element['name'], 35 | 'fullname': element['fullname'], 36 | 'category': element['category'], 37 | 'image': element['image'], 38 | connection: new Pool({ 39 | 'user': config['postgres']['user'], 40 | 'password': config['postgres']['password'], 41 | 'host': config['postgres']['host'], 42 | 'port': config['postgres']['port'], 43 | 'database': element['database'] 44 | }) 45 | }; 46 | 47 | if (element['lastrun']) { 48 | glam['lastrun'] = element['lastrun']; 49 | } else { 50 | glam['lastrun'] = null; 51 | } 52 | 53 | if (element['status']) { 54 | glam['status'] = element['status']; 55 | } else { 56 | glam['status'] = null; 57 | } 58 | 59 | if (element['dashboard_id']) { 60 | glam['dashboard_id'] = element['dashboard_id']; 61 | } else { 62 | glam['dashboard_id'] = null; 63 | } 64 | 65 | if (element['http-auth']) { 66 | glam['http-auth'] = element['http-auth']; 67 | glam['http-auth']['realm'] = element['name'] + " stats"; 68 | } 69 | 70 | // Glams are never deleted 71 | glams[glam['name']] = glam; 72 | }); 73 | 74 | if (callback !== undefined) 75 | callback(); 76 | }); 77 | }; 78 | 79 | if (!isConnected(client)) { 80 | client.connect(query); 81 | } else { 82 | query(); 83 | } 84 | } 85 | 86 | function insertGlam(glam) { 87 | let query = () => { 88 | const db = client.db(config['mongodb']['database']); 89 | const collection = db.collection(config['mongodb']['collection']); 90 | collection.insertOne(glam, () => { 91 | loadGlams(); 92 | }); 93 | }; 94 | 95 | if (!isConnected(client)) { 96 | client.connect(query); 97 | } else { 98 | query(); 99 | } 100 | } 101 | 102 | function updateGlam(glam) { 103 | let query = () => { 104 | const db = client.db(config['mongodb']['database']); 105 | const collection = db.collection(config['mongodb']['collection']); 106 | collection.updateOne({ name: glam['name'] }, { $set: glam }, () => { 107 | loadGlams(); 108 | }); 109 | }; 110 | 111 | if (!isConnected(client)) { 112 | client.connect(query); 113 | } else { 114 | query(); 115 | } 116 | } 117 | 118 | exports.glams = glams; 119 | exports.loadGlams = loadGlams; 120 | exports.insertGlam = insertGlam; 121 | exports.updateGlam = updateGlam; -------------------------------------------------------------------------------- /deploy/.gitignore: -------------------------------------------------------------------------------- 1 | id_rsa_cassandra 2 | inventory.ini -------------------------------------------------------------------------------- /deploy/cassandra-autossh.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=AutoSSH for Cassandra 3 | 4 | [Service] 5 | User=glam 6 | Group=glam 7 | Restart=always 8 | RestartSec=15s 9 | ExecStart=/usr/bin/autossh -N wmflabs 10 | 11 | [Install] 12 | WantedBy=multi-user.target 13 | -------------------------------------------------------------------------------- /deploy/cassandra.conf: -------------------------------------------------------------------------------- 1 | [program:cassandra] 2 | directory=/home/glam/cassandra-GLAM-tools/app 3 | command=node server.js 4 | priority=10 5 | exitcodes=0,2 6 | stopsignal=QUIT 7 | user=glam 8 | log_stdout=true 9 | log_stderr=true 10 | logfile_maxbytes=10MB 11 | logfile_backups=10 -------------------------------------------------------------------------------- /deploy/config: -------------------------------------------------------------------------------- 1 | Host wmflabs 2 | HostName login.toolforge.org 3 | User MYWMFLOGIN 4 | Port 22 5 | IdentityFile ~/.ssh/id_rsa_cassandra 6 | LocalForward 3306 commonswiki.analytics.db.svc.wikimedia.cloud:3306 7 | -------------------------------------------------------------------------------- /deploy/crontab: -------------------------------------------------------------------------------- 1 | # GLAM tool crontab 2 | */5 * * * * glam python3 /home/glam/cassandra-GLAM-tools/etl/run.py >> /var/log/cassandra/etl.log 2>&1 3 | 0 18 * * * glam python3 /home/glam/cassandra-GLAM-tools/etl/run_views.py >> /var/log/cassandra/views.log 2>&1 4 | 30 3 * * 6 glam python3 /home/glam/cassandra-GLAM-tools/recommender/run.py >> /var/log/cassandra/recommender.log 2>&1 5 | -------------------------------------------------------------------------------- /deploy/metabase.conf: -------------------------------------------------------------------------------- 1 | [program:metabase] 2 | directory=/home/glam/metabase 3 | command=java -jar metabase.jar 4 | priority=20 5 | exitcodes=0,2 6 | stopsignal=QUIT 7 | user=glam 8 | log_stdout=true 9 | log_stderr=true 10 | logfile_maxbytes=10MB 11 | logfile_backups=10 -------------------------------------------------------------------------------- /deploy/pontoon.conf: -------------------------------------------------------------------------------- 1 | [program:pontoon] 2 | directory=/home/glam/pontoon 3 | command=pipenv run gunicorn pontoon.wsgi 4 | priority=20 5 | exitcodes=0,2 6 | stopsignal=QUIT 7 | user=glam 8 | log_stdout=true 9 | log_stderr=true 10 | logfile_maxbytes=10MB 11 | logfile_backups=10 -------------------------------------------------------------------------------- /deploy/pontoon.env: -------------------------------------------------------------------------------- 1 | SECRET_KEY=MYRANDOMKEY 2 | DATABASE_URL=postgres://pontoon:MYPOSTGRESPASSWORD@localhost/pontoon 3 | SITE_URL=https://host.example.com -------------------------------------------------------------------------------- /deploy/pontoon.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - hosts: pontoon 3 | remote_user: root 4 | vars: 5 | install_path: /home/glam/pontoon 6 | postgres_password: supersecret 7 | 8 | tasks: 9 | - name: Create user glam 10 | user: 11 | name: glam 12 | shell: /bin/bash 13 | password: '!' 14 | 15 | - name: Update apt cache 16 | apt: update_cache=yes 17 | 18 | - name: Install dependencies 19 | package: 20 | name: "{{ item }}" 21 | state: present 22 | loop: 23 | - apt-transport-https 24 | - gnupg2 25 | - git 26 | - python3-pip 27 | - postgresql-12 28 | - autossh 29 | - supervisor 30 | - libxml2-dev 31 | - libxslt1-dev 32 | - python3-dev 33 | - libmemcached-dev 34 | - build-essential 35 | - libpq-dev 36 | 37 | - name: Add NodeSource Node.js repository 38 | shell: "curl -fsSL https://deb.nodesource.com/setup_12.x | bash -" 39 | 40 | - name: Update apt cache 41 | apt: update_cache=yes 42 | 43 | - name: Install Node.js 44 | package: 45 | name: nodejs 46 | state: present 47 | 48 | - name: Install Pipenv 49 | shell: "pip3 install pipenv" 50 | 51 | - name: Download Pontoon 52 | git: 53 | repo: "https://github.com/mozilla/pontoon.git" 54 | dest: "{{ install_path }}" 55 | become: yes 56 | become_user: glam 57 | 58 | - name: Install Python dependencies 59 | command: "pipenv run pip install -r requirements.txt" 60 | args: 61 | chdir: "{{ install_path }}" 62 | become: yes 63 | become_user: glam 64 | 65 | - name: Install Node.js dependencies 66 | command: "npm install" 67 | args: 68 | chdir: "{{ install_path }}" 69 | become: yes 70 | become_user: glam 71 | 72 | - name: Install Node.js frontend dependencies 73 | command: "npm install" 74 | args: 75 | chdir: "{{ install_path }}/frontend" 76 | become: yes 77 | become_user: glam 78 | 79 | - name: Run webpack 80 | command: "./node_modules/.bin/webpack" 81 | args: 82 | chdir: "{{ install_path }}" 83 | become: yes 84 | become_user: glam 85 | 86 | - name: Build Node.js frontend dependencies 87 | command: "npm run build" 88 | args: 89 | chdir: "{{ install_path }}/frontend" 90 | become: yes 91 | become_user: glam 92 | 93 | - name: Create PostgreSQL user 94 | command: psql -c "CREATE USER pontoon WITH PASSWORD '{{ postgres_password }}' SUPERUSER;" 95 | become: yes 96 | become_user: postgres 97 | 98 | - name: Create PostgreSQL database 99 | command: psql -c "CREATE DATABASE pontoon;" 100 | become: yes 101 | become_user: postgres 102 | 103 | - name: Create PostgreSQL privileges 104 | command: psql -c "GRANT ALL PRIVILEGES ON DATABASE pontoon to pontoon;" 105 | become: yes 106 | become_user: postgres 107 | 108 | - name: Copy env file 109 | copy: 110 | src: pontoon.env 111 | dest: "{{ install_path }}/.env" 112 | become: yes 113 | become_user: glam 114 | 115 | - name: Set PostgreSQL password 116 | replace: 117 | path: "{{ install_path }}/.env" 118 | regexp: 'MYPOSTGRESPASSWORD' 119 | replace: "{{ postgres_password }}" 120 | 121 | - name: Set random key 122 | replace: 123 | path: "{{ install_path }}/.env" 124 | regexp: 'MYRANDOMKEY' 125 | replace: "{{ lookup('password', '/dev/null chars=ascii_lowercase,digits length=32') }}" 126 | 127 | - name: Move static files 128 | command: "pipenv run python manage.py collectstatic --noinput" 129 | args: 130 | chdir: "{{ install_path }}" 131 | become: yes 132 | become_user: glam 133 | 134 | - name: Run migrations 135 | command: "pipenv run python manage.py migrate" 136 | args: 137 | chdir: "{{ install_path }}" 138 | become: yes 139 | become_user: glam 140 | 141 | - name: Copy supervisor config 142 | copy: 143 | src: pontoon.conf 144 | dest: /etc/supervisor/conf.d/pontoon.conf 145 | 146 | - name: Restart supervisor 147 | shell: "supervisorctl reread && supervisorctl update" 148 | -------------------------------------------------------------------------------- /docs/architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/synapta/cassandra-GLAM-tools/613cc88f0570a09146e1d4e70ab4509050280139/docs/architecture.png -------------------------------------------------------------------------------- /docs/presentations/2016-09-01-How-libraries-fall-in-love-with-Wikidata.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/synapta/cassandra-GLAM-tools/613cc88f0570a09146e1d4e70ab4509050280139/docs/presentations/2016-09-01-How-libraries-fall-in-love-with-Wikidata.pdf -------------------------------------------------------------------------------- /docs/presentations/2017-11-08-Cassandra_Backend.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/synapta/cassandra-GLAM-tools/613cc88f0570a09146e1d4e70ab4509050280139/docs/presentations/2017-11-08-Cassandra_Backend.pdf -------------------------------------------------------------------------------- /docs/presentations/2019-11-03-Wikidata_Zurich_Training.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/synapta/cassandra-GLAM-tools/613cc88f0570a09146e1d4e70ab4509050280139/docs/presentations/2019-11-03-Wikidata_Zurich_Training.pdf -------------------------------------------------------------------------------- /etl/.gitignore: -------------------------------------------------------------------------------- 1 | temp/* 2 | -------------------------------------------------------------------------------- /etl/SQL/dailyInsert.sql: -------------------------------------------------------------------------------- 1 | create or replace function dailyInsert(text, date,integer, integer, integer) 2 | returns integer as $$ 3 | declare id integer; 4 | begin 5 | select media_id into id from images where img_name=$1; 6 | insert into visualizations values(id,$2,$3,$4,$5); 7 | return id; 8 | EXCEPTION 9 | WHEN OTHERS THEN RETURN -1; 10 | end; 11 | $$language plpgsql; 12 | -------------------------------------------------------------------------------- /etl/SQL/db_init.sql: -------------------------------------------------------------------------------- 1 | CREATE table IF NOT EXISTS categories (page_title varchar(255) primary key, cat_subcats int, cat_files int, cl_to varchar(255)[], cat_level int[]); 2 | CREATE table IF NOT EXISTS images (img_name varchar(255) primary key, img_user_text varchar(255), img_timestamp timestamp without time zone,img_size bigint,cl_to varchar(255)[],media_id serial unique,is_alive boolean); 3 | CREATE table IF NOT EXISTS visualizations (media_id int references images(media_id), access_date date, accesses bigint, wm_accesses bigint, nwm_accesses bigint, primary key(media_id,access_date)); 4 | CREATE table IF NOT EXISTS annotations (annotation_date date primary key, annotation_value varchar(255), annotation_position varchar(255)); 5 | CREATE table IF NOT EXISTS usages (gil_wiki varchar(20),gil_page_title varchar(255),gil_to varchar(255), first_seen date,last_seen date, is_alive boolean, primary key(gil_to,gil_page_title,first_seen,gil_wiki)); 6 | CREATE INDEX IF NOT EXISTS ad_GBy on visualizations(access_date); 7 | create materialized view if not exists visualizations_sum as select sum(accesses) as accesses_sum, access_date from visualizations group by access_date; 8 | create materialized view if not exists visualizations_stats as select i.img_name as img_name, sum(v.accesses) as tot, avg(v.accesses) as avg, PERCENTILE_CONT(0.5) WITHIN GROUP(ORDER by v.accesses) as median from images as i, visualizations as v where i.media_id = v.media_id and i.is_alive = true group by i.img_name; 9 | CREATE TABLE IF NOT EXISTS recommendations (img_name varchar(255) NOT NULL, site varchar(20) NOT NULL, title varchar(255) NOT NULL, url varchar(255) NOT NULL, score float4 NULL, last_update date NOT NULL, hidden bool NOT NULL DEFAULT false, CONSTRAINT recommendations_fk FOREIGN KEY (img_name) REFERENCES images(img_name)); 10 | CREATE UNIQUE INDEX recommendations_img_name_idx ON recommendations USING btree (img_name, site, title); 11 | GRANT USAGE ON SCHEMA public TO metabase; 12 | GRANT SELECT ON ALL TABLES IN SCHEMA public TO metabase; -------------------------------------------------------------------------------- /etl/SQL/functions.sql: -------------------------------------------------------------------------------- 1 | create or replace function AddCategory(title varchar(255),subcats int ,files int,_to varchar(255),_level int) 2 | returns void as $$ 3 | declare 4 | t varchar(255)[]; 5 | l int[]; 6 | begin 7 | IF EXISTS (SELECT 1 FROM categories WHERE page_title = title) THEN 8 | update categories set cl_to=cl_to||_to,cat_level=cat_level||_level where page_title=title; 9 | else 10 | t[0]:=_to; 11 | l[0]:=_level; 12 | insert into categories values(title,subcats,files,t,l); 13 | end if; 14 | EXCEPTION 15 | WHEN others THEN 16 | RAISE NOTICE 'Error on %',title; 17 | end; 18 | $$language plpgsql; 19 | 20 | create or replace function AddImage(_name varchar(255),_user varchar(255) ,_timestamp timestamp without time zone,size bigint,_to varchar(255)) 21 | returns void as $$ 22 | declare 23 | t varchar(255)[]; 24 | opened boolean; 25 | begin 26 | SELECT is_alive into opened FROM images WHERE img_name = _name; 27 | t[0]:=_to; 28 | IF FOUND THEN 29 | if opened then 30 | update images set cl_to=cl_to||_to where img_name=_name; 31 | else 32 | update images set cl_to=t, is_alive=true where img_name=_name; 33 | end if; 34 | else 35 | insert into images(img_name,img_user_text,img_timestamp,img_size,cl_to,is_alive) values(_name,_user,_timestamp,size,t,true); 36 | end if; 37 | EXCEPTION 38 | WHEN others THEN 39 | RAISE NOTICE 'Error on %',_name; 40 | end; 41 | $$language plpgsql; 42 | 43 | 44 | create or replace function AddUsage(_wiki varchar(20),_page varchar(255) ,_to varchar(255)) 45 | returns void as $$ 46 | declare 47 | appeared date; 48 | begin 49 | SELECT first_seen into appeared FROM usages WHERE gil_page_title = _page and gil_to=_to and gil_wiki=_wiki and is_alive=true; 50 | IF FOUND THEN 51 | update usages set last_seen=CURRENT_DATE where gil_page_title=_page and gil_to=_to and gil_wiki=_wiki and first_seen=appeared; 52 | else 53 | insert into usages(gil_wiki,gil_page_title,gil_to,first_seen,last_seen,is_alive) values(_wiki,_page,_to,CURRENT_DATE,CURRENT_DATE,true); 54 | end if; 55 | end; 56 | $$language plpgsql; -------------------------------------------------------------------------------- /etl/SQL/maintenance.sql: -------------------------------------------------------------------------------- 1 | create table if not exists dailyImageUsage(img_name varchar(255),count_date date, count bigint, primary key (img_name, count_date)); 2 | grant select on dailyimageusage to metabase; 3 | create index if not exists dailyimageusage_img_name_idx on dailyimageusage (img_name); 4 | create or replace function doMaintenance() 5 | returns void as $$ 6 | begin 7 | insert into dailyImageUsage(img_name, count_date, count) select gil_to, CURRENT_DATE, count(*) from usages where last_seen=current_date group by gil_to on conflict do nothing; 8 | update usages set is_alive=false where last_seen<(current_date-5); 9 | end; 10 | $$language plpgsql; 11 | -------------------------------------------------------------------------------- /etl/cards/10.json: -------------------------------------------------------------------------------- 1 | {"description": "§[metabase.corr-old-desc]§", "archived": false, "collection_position": null, "table_id": null, "result_metadata": [{"base_type": "type/Float", "display_name": "Corr Coef Using PGSQL Func", "name": "Corr Coef Using PGSQL Func", "special_type": null, "fingerprint": {"global": {"distinct-count": 1, "nil%": 0.0}, "type": {"type/Number": {"min": 0.0776270546580604, "max": 0.0776270546580604, "avg": 0.0776270546580604, "sd": null, "q1": 0.0776270546580604, "q3": 0.0776270546580604}}}}], "creator": {"email": "alessio@synapta.it", "first_name": "Alessio", "last_login": "2021-01-25T09:56:39.29734Z", "is_qbnewb": false, "is_superuser": true, "id": 2, "last_name": "Melandri", "date_joined": "2020-05-19T09:02:11.385428Z", "common_name": "Alessio Melandri"}, "can_write": true, "database_id": 3, "enable_embedding": false, "collection_id": 16, "query_type": "native", "name": "§[& metabase.corr-old]§", "dashboard_count": 1, "read_permissions": null, "creator_id": 2, "updated_at": "2021-05-23T06:12:44.313438Z", "made_public_by_id": null, "embedding_params": null, "cache_ttl": null, "dataset_query": {"type": "native", "native": {"query": "SELECT corr(average, oldness) as \"Corr Coef Using PGSQL Func\" FROM(\nSELECT AVG(accesses) as average, EXTRACT(day from NOW()-img_timestamp) as oldness\nFROM images i, visualizations v\nWHERE i.media_id = v.media_id\ngroup by i.img_name\n) as a\n\n \n"}, "database": 3}, "id": 10, "display": "scalar", "visualization_settings": {"graph.dimensions": ["average"], "graph.metrics": ["img_size"], "graph.x_axis.scale": "pow", "graph.y_axis.scale": "pow"}, "collection": {"id": 16, "name": "Correlations", "description": null, "color": "#509EE3", "archived": false, "location": "/7/", "personal_owner_id": null, "slug": "correlations"}, "created_at": "2020-06-03T14:04:18.236465Z", "public_uuid": null} -------------------------------------------------------------------------------- /etl/cards/18.json: -------------------------------------------------------------------------------- 1 | {"description": "§[metabase.uploaded-last-desc]§", "archived": false, "collection_position": null, "table_id": null, "result_metadata": [{"base_type": "type/Decimal", "display_name": "?column?", "name": "?column?", "special_type": null, "fingerprint": {"global": {"distinct-count": 1, "nil%": 0.0}, "type": {"type/Number": {"min": 0.03210463733650416, "max": 0.03210463733650416, "avg": 0.03210463733650416, "sd": null, "q1": 0.03210463733650416, "q3": 0.03210463733650416}}}}], "creator": {"email": "alessio@synapta.it", "first_name": "Alessio", "last_login": "2021-01-25T09:56:39.29734Z", "is_qbnewb": false, "is_superuser": true, "id": 2, "last_name": "Melandri", "date_joined": "2020-05-19T09:02:11.385428Z", "common_name": "Alessio Melandri"}, "can_write": true, "database_id": 3, "enable_embedding": false, "collection_id": 14, "query_type": "native", "name": "§[metabase.uploaded-last]§", "dashboard_count": 1, "read_permissions": null, "creator_id": 2, "updated_at": "2021-05-23T06:12:41.572739Z", "made_public_by_id": null, "embedding_params": null, "cache_ttl": null, "dataset_query": {"type": "native", "native": {"query": "SELECT (COUNT(*) FILTER(WHERE img_timestamp > (now() - interval '1 year')))/COUNT(*)::numeric\nFROM images"}, "database": 3}, "id": 18, "display": "scalar", "visualization_settings": {"column_settings": {"[\"name\",\"?column?\"]": {"scale": 100, "suffix": "%", "prefix": "+"}}}, "collection": {"id": 14, "name": "Upload", "description": null, "color": "#509EE3", "archived": false, "location": "/7/", "personal_owner_id": null, "slug": "upload"}, "created_at": "2020-12-07T17:34:53.572179Z", "public_uuid": null} -------------------------------------------------------------------------------- /etl/cards/19.json: -------------------------------------------------------------------------------- 1 | {"description": "§[metabase.perc-used-desc]§", "archived": false, "collection_position": null, "table_id": null, "result_metadata": [{"base_type": "type/Decimal", "display_name": "?column?", "name": "?column?", "special_type": null, "fingerprint": {"global": {"distinct-count": 1, "nil%": 0.0}, "type": {"type/Number": {"min": 0.45422116527942924, "max": 0.45422116527942924, "avg": 0.45422116527942924, "sd": null, "q1": 0.45422116527942924, "q3": 0.45422116527942924}}}}], "creator": {"email": "alessio@synapta.it", "first_name": "Alessio", "last_login": "2021-01-25T09:56:39.29734Z", "is_qbnewb": false, "is_superuser": true, "id": 2, "last_name": "Melandri", "date_joined": "2020-05-19T09:02:11.385428Z", "common_name": "Alessio Melandri"}, "can_write": true, "database_id": 3, "enable_embedding": false, "collection_id": 13, "query_type": "native", "name": "§[metabase.perc-used]§", "dashboard_count": 1, "read_permissions": null, "creator_id": 2, "updated_at": "2021-05-23T06:12:41.34413Z", "made_public_by_id": null, "embedding_params": null, "cache_ttl": null, "dataset_query": {"type": "native", "native": {"query": "SELECT COUNT(DISTINCT gil_to)/(SELECT COUNT(*) from images)::numeric\nFROM usages"}, "database": 3}, "id": 19, "display": "scalar", "visualization_settings": {"column_settings": {"[\"name\",\"?column?\"]": {"scale": 100, "suffix": "%"}}}, "collection": {"id": 13, "name": "Usage", "description": null, "color": "#509EE3", "archived": false, "location": "/7/", "personal_owner_id": null, "slug": "usage"}, "created_at": "2020-12-07T17:38:52.198419Z", "public_uuid": null} -------------------------------------------------------------------------------- /etl/cards/20.json: -------------------------------------------------------------------------------- 1 | {"description": "§[metabase.views-change-desc]§", "archived": false, "collection_position": null, "table_id": null, "result_metadata": [{"base_type": "type/Decimal", "display_name": "?column?", "name": "?column?", "special_type": null, "fingerprint": {"global": {"distinct-count": 1, "nil%": 0.0}, "type": {"type/Number": {"min": -0.006787059042099267, "max": -0.006787059042099267, "avg": -0.006787059042099267, "sd": null, "q1": -0.006787059042099267, "q3": -0.006787059042099267}}}}], "creator": {"email": "alessio@synapta.it", "first_name": "Alessio", "last_login": "2021-01-25T09:56:39.29734Z", "is_qbnewb": false, "is_superuser": true, "id": 2, "last_name": "Melandri", "date_joined": "2020-05-19T09:02:11.385428Z", "common_name": "Alessio Melandri"}, "can_write": true, "database_id": 3, "enable_embedding": false, "collection_id": 12, "query_type": "native", "name": "§[metabase.views-change]§", "dashboard_count": 1, "read_permissions": null, "creator_id": 2, "updated_at": "2021-05-23T06:12:46.273915Z", "made_public_by_id": null, "embedding_params": null, "cache_ttl": null, "dataset_query": {"database": 3, "native": {"query": "with a as (\n select sum(accesses) prev_year\n from visualizations\n where date_trunc('year', access_date) = date_trunc('year', now() - interval '2 year')\n group by date_trunc('year', access_date)\n), b as (\n select sum(accesses) as current_year\n from visualizations\n where date_trunc('year', access_date) = date_trunc('year', now() - interval '1 year')\n group by date_trunc('year', access_date)\n)\nselect (current_year- prev_year)/prev_year::numeric\nfrom a,b"}, "type": "native"}, "id": 20, "display": "scalar", "visualization_settings": {"column_settings": {"[\"name\",\"?column?\"]": {"scale": 100, "suffix": "%"}}}, "collection": {"id": 12, "name": "§[metabase.views]§", "description": null, "color": "#509EE3", "archived": false, "location": "/7/", "personal_owner_id": null, "slug": "views"}, "created_at": "2020-12-07T17:54:02.190531Z", "public_uuid": null} -------------------------------------------------------------------------------- /etl/cards/37.json: -------------------------------------------------------------------------------- 1 | {"description": "§[metabase.total-views-desc]§", "archived": false, "collection_position": null, "table_id": null, "result_metadata": [{"base_type": "type/Decimal", "display_name": "sum", "name": "sum", "special_type": null, "fingerprint": {"global": {"distinct-count": 1, "nil%": 0.0}, "type": {"type/Number": {"min": 33151052.0, "max": 33151052.0, "avg": 33151052.0, "sd": null, "q1": 33151052.0, "q3": 33151052.0}}}}], "creator": {"email": "alessio@synapta.it", "first_name": "Alessio", "last_login": "2021-01-25T09:56:39.29734Z", "is_qbnewb": false, "is_superuser": true, "id": 2, "last_name": "Melandri", "date_joined": "2020-05-19T09:02:11.385428Z", "common_name": "Alessio Melandri"}, "can_write": true, "database_id": 3, "enable_embedding": false, "collection_id": 12, "query_type": "native", "name": "§[metabase.total-views]§", "dashboard_count": 1, "read_permissions": null, "creator_id": 2, "updated_at": "2021-05-23T06:12:44.09671Z", "made_public_by_id": null, "embedding_params": null, "cache_ttl": null, "dataset_query": {"database": 3, "native": {"query": "SELECT SUM(accesses)\nFROM visualizations"}, "type": "native"}, "id": 37, "display": "scalar", "visualization_settings": {}, "collection": {"id": 12, "name": "Views", "description": null, "color": "#509EE3", "archived": false, "location": "/7/", "personal_owner_id": null, "slug": "views"}, "created_at": "2021-01-13T09:40:38.895889Z", "public_uuid": null} -------------------------------------------------------------------------------- /etl/cards/38.json: -------------------------------------------------------------------------------- 1 | {"description": "§[metabase.views-year-desc]§", "archived": false, "collection_position": null, "table_id": null, "result_metadata": [{"base_type": "type/DateTimeWithLocalTZ", "display_name": "§[metabase.year]§", "name": "§[metabase.year]§", "special_type": null, "fingerprint": {"global": {"distinct-count": 7, "nil%": 0.0}, "type": {"type/DateTime": {"earliest": "2015-01-01T00:00:00+01:00", "latest": "2021-01-01T00:00:00+01:00"}}}}, {"base_type": "type/Decimal", "display_name": "§[visits-per-year]§", "name": "§[metabase.visits-per-year]§", "special_type": null, "fingerprint": {"global": {"distinct-count": 7, "nil%": 0.0}, "type": {"type/Number": {"min": 1840097.0, "max": 6792721.0, "avg": 4735864.571428572, "sd": 1504320.9550392781, "q1": 4530486.5, "q3": 5409653.5}}}}, {"base_type": "type/Decimal", "display_name": "§[metabase.cumulative-usage]§", "name": "§[metabase.cumulative-usage]§", "special_type": null, "fingerprint": {"global": {"distinct-count": 7, "nil%": 0.0}, "type": {"type/Number": {"min": 6792721.0, "max": 33151052.0, "avg": 21432633.85714286, "sd": 9825542.78939938, "q1": 13519908.25, "q3": 30180265.0}}}}], "creator": {"email": "alessio@synapta.it", "first_name": "Alessio", "last_login": "2021-01-25T09:56:39.29734Z", "is_qbnewb": false, "is_superuser": true, "id": 2, "last_name": "Melandri", "date_joined": "2020-05-19T09:02:11.385428Z", "common_name": "Alessio Melandri"}, "can_write": true, "database_id": 3, "enable_embedding": false, "collection_id": 12, "query_type": "native", "name": "§[metabase.views-year]§", "dashboard_count": 1, "read_permissions": null, "creator_id": 2, "updated_at": "2021-05-23T06:12:44.105668Z", "made_public_by_id": null, "embedding_params": null, "cache_ttl": null, "dataset_query": {"database": 3, "native": {"query": "SELECT d_year as \"§[metabase.year]§\", visits_per_year as \"§[metabase.visits-per-year]§\", sum(visits_per_year) OVER (ORDER BY d_year) as \"§[metabase.cumulative-usage]§\"\nfrom (\nSELECT date_trunc('year', access_date) AS d_year, SUM(accesses) AS visits_per_year\nFROM visualizations\ngroup by d_year) as a\ngroup by d_year, visits_per_year"}, "type": "native"}, "id": 38, "display": "line", "visualization_settings": {"graph.dimensions": ["§[metabase.year]§"], "graph.metrics": ["§[metabase.visits-per-year]§", "§[metabase.cumulative-usage]§"], "graph.show_values": false, "series_settings": {"§[metabase.cumulative-usage]§": {"color": "#A989C5"}, "§[metabase.visits-per-year]§": {"color": "#509EE3"}}}, "collection": {"id": 12, "name": "§[metabase.views]§", "description": null, "color": "#509EE3", "archived": false, "location": "/7/", "personal_owner_id": null, "slug": "views"}, "created_at": "2021-01-13T20:49:13.257604Z", "public_uuid": null} -------------------------------------------------------------------------------- /etl/cards/39.json: -------------------------------------------------------------------------------- 1 | {"description": "§[metabase.enanched-desc]§", "archived": false, "collection_position": null, "table_id": null, "result_metadata": [{"base_type": "type/BigInteger", "display_name": "count", "name": "count", "special_type": "type/Quantity", "fingerprint": {"global": {"distinct-count": 1, "nil%": 0.0}, "type": {"type/Number": {"min": 814.0, "max": 814.0, "avg": 814.0, "sd": null, "q1": 814.0, "q3": 814.0}}}}], "creator": {"email": "alessio@synapta.it", "first_name": "Alessio", "last_login": "2021-01-25T09:56:39.29734Z", "is_qbnewb": false, "is_superuser": true, "id": 2, "last_name": "Melandri", "date_joined": "2020-05-19T09:02:11.385428Z", "common_name": "Alessio Melandri"}, "can_write": true, "database_id": 3, "enable_embedding": false, "collection_id": 13, "query_type": "native", "name": "§[metabase.enanched]§", "dashboard_count": 1, "read_permissions": null, "creator_id": 2, "updated_at": "2021-05-23T06:12:41.32512Z", "made_public_by_id": null, "embedding_params": null, "cache_ttl": null, "dataset_query": {"type": "native", "native": {"query": "select count(*)\nfrom usages\nwhere is_alive is true"}, "database": 3}, "id": 39, "display": "scalar", "visualization_settings": {}, "collection": {"id": 13, "name": "Usage", "description": null, "color": "#509EE3", "archived": false, "location": "/7/", "personal_owner_id": null, "slug": "usage"}, "created_at": "2021-01-18T15:18:59.673031Z", "public_uuid": null} -------------------------------------------------------------------------------- /etl/cards/40.json: -------------------------------------------------------------------------------- 1 | {"description": "§[metabase.media-used-desc]§", "archived": false, "collection_position": null, "table_id": null, "result_metadata": [{"base_type": "type/DateTimeWithLocalTZ", "display_name": "§[metabase.year]§", "name": "§[metabase.year]§", "special_type": null, "fingerprint": {"global": {"distinct-count": 4, "nil%": 0.0}, "type": {"type/DateTime": {"earliest": "2017-01-01T00:00:00+01:00", "latest": "2020-01-01T00:00:00+01:00"}}}}, {"base_type": "type/BigInteger", "display_name": "§[metabase.new-usages]§", "name": "§[metabase.new-usages]§", "special_type": null, "fingerprint": {"global": {"distinct-count": 4, "nil%": 0.0}, "type": {"type/Number": {"min": 5.0, "max": 736.0, "avg": 220.75, "sd": 347.2188310945515, "q1": 13.5, "q3": 428.0}}}}, {"base_type": "type/Decimal", "display_name": "§[metabase.cumulative-usage]§", "name": "§[metabase.cumulative-usage]§", "special_type": null, "fingerprint": {"global": {"distinct-count": 4, "nil%": 0.0}, "type": {"type/Number": {"min": 736.0, "max": 883.0, "avg": 805.25, "sd": 77.62463097067408, "q1": 738.5, "q3": 872.0}}}}], "creator": {"email": "alessio@synapta.it", "first_name": "Alessio", "last_login": "2021-01-25T09:56:39.29734Z", "is_qbnewb": false, "is_superuser": true, "id": 2, "last_name": "Melandri", "date_joined": "2020-05-19T09:02:11.385428Z", "common_name": "Alessio Melandri"}, "can_write": true, "database_id": 3, "enable_embedding": false, "collection_id": 13, "query_type": "native", "name": "§[metabase.media-used-year]§", "dashboard_count": 1, "read_permissions": null, "creator_id": 2, "updated_at": "2021-05-23T06:12:41.309301Z", "made_public_by_id": null, "embedding_params": null, "cache_ttl": null, "dataset_query": {"database": 3, "native": {"query": "SELECT d_year as \"§[metabase.year]§\", new_usage as \"§[metabase.new-usages]§\", sum(new_usage) OVER (ORDER BY d_year) as \"§[metabase.cumulative-usage]§\"\nfrom (\nSELECT date_trunc('year', first_seen) AS d_year, COUNT(*) AS new_usage\nFROM usages\ngroup by d_year) as a\ngroup by d_year, new_usage"}, "type": "native"}, "id": 40, "display": "line", "visualization_settings": {"graph.dimensions": ["§[metabase.year]§"], "graph.metrics": ["§[metabase.new-usages]§", "§[metabase.cumulative-usage]§"], "graph.show_values": false, "series_settings": {"§[metabase.new-usages]§": {"color": "#509EE3"}, "§[metabase.cumulative-usage]§": {"color": "#A989C5"}}}, "collection": {"id": 13, "name": "Usage", "description": null, "color": "#509EE3", "archived": false, "location": "/7/", "personal_owner_id": null, "slug": "usage"}, "created_at": "2021-01-18T15:32:16.244733Z", "public_uuid": null} -------------------------------------------------------------------------------- /etl/cards/44.json: -------------------------------------------------------------------------------- 1 | {"description": "§[metabase.total-media-desc]§", "archived": false, "collection_position": null, "table_id": null, "result_metadata": [{"base_type": "type/BigInteger", "display_name": "count", "name": "count", "special_type": "type/Quantity", "fingerprint": {"global": {"distinct-count": 1, "nil%": 0.0}, "type": {"type/Number": {"min": 841.0, "max": 841.0, "avg": 841.0, "sd": null, "q1": 841.0, "q3": 841.0}}}}], "creator": {"email": "alessio@synapta.it", "first_name": "Alessio", "last_login": "2021-01-25T09:56:39.29734Z", "is_qbnewb": false, "is_superuser": true, "id": 2, "last_name": "Melandri", "date_joined": "2020-05-19T09:02:11.385428Z", "common_name": "Alessio Melandri"}, "can_write": true, "database_id": 3, "enable_embedding": false, "collection_id": 14, "query_type": "native", "name": "§[metabase.total-media]§", "dashboard_count": 1, "read_permissions": null, "creator_id": 2, "updated_at": "2021-05-23T06:12:41.564956Z", "made_public_by_id": null, "embedding_params": null, "cache_ttl": null, "dataset_query": {"type": "native", "native": {"query": "SELECT COUNT(*)\nFROM images"}, "database": 3}, "id": 44, "display": "scalar", "visualization_settings": {}, "collection": {"id": 14, "name": "Upload", "description": null, "color": "#509EE3", "archived": false, "location": "/7/", "personal_owner_id": null, "slug": "upload"}, "created_at": "2021-01-19T10:03:24.77234Z", "public_uuid": null} -------------------------------------------------------------------------------- /etl/cards/45.json: -------------------------------------------------------------------------------- 1 | {"description": "§[metabase.media-added-desc]§", "archived": false, "collection_position": null, "table_id": null, "result_metadata": [{"base_type": "type/DateTime", "display_name": "§[metabase.year]§", "name": "§[metabase.year]§", "special_type": null, "fingerprint": {"global": {"distinct-count": 16, "nil%": 0.0}, "type": {"type/DateTime": {"earliest": "2005-01-01T00:00:00+01:00", "latest": "2020-01-01T00:00:00+01:00"}}}}, {"base_type": "type/BigInteger", "display_name": "§[metabase.media-per-year]§", "name": "§[metabase.media-per-year]§", "special_type": null, "fingerprint": {"global": {"distinct-count": 16, "nil%": 0.0}, "type": {"type/Number": {"min": 1.0, "max": 233.0, "avg": 52.5625, "sd": 53.95920526966027, "q1": 25.5, "q3": 61.0}}}}, {"base_type": "type/Decimal", "display_name": "§[metabase.media-cumulated]§", "name": "§[metabase.media-cumulated]§", "special_type": null, "fingerprint": {"global": {"distinct-count": 16, "nil%": 0.0}, "type": {"type/Number": {"min": 1.0, "max": 841.0, "avg": 471.4375, "sd": 286.42066237150794, "q1": 285.0, "q3": 711.0}}}}], "creator": {"email": "alessio@synapta.it", "first_name": "Alessio", "last_login": "2021-01-25T09:56:39.29734Z", "is_qbnewb": false, "is_superuser": true, "id": 2, "last_name": "Melandri", "date_joined": "2020-05-19T09:02:11.385428Z", "common_name": "Alessio Melandri"}, "can_write": true, "database_id": 3, "enable_embedding": false, "collection_id": 14, "query_type": "native", "name": "§[metabase.media-added]§", "dashboard_count": 1, "read_permissions": null, "creator_id": 2, "updated_at": "2021-05-23T06:12:41.452581Z", "made_public_by_id": null, "embedding_params": null, "cache_ttl": null, "dataset_query": {"database": 3, "native": {"query": "SELECT d_year as \"§[metabase.year]§\", media_year as \"§[metabase.media-per-year]§\", sum(media_year) OVER (ORDER BY d_year) as \"§[metabase.media-cumulated]§\"\nfrom (\nSELECT date_trunc('year', img_timestamp) AS d_year, COUNT(*) AS media_year\nFROM images\ngroup by d_year) as a\ngroup by d_year, media_year"}, "type": "native"}, "id": 45, "display": "line", "visualization_settings": {"graph.dimensions": ["§[metabase.year]§"], "graph.metrics": ["§[metabase.media-per-year]§", "§[metabase.media-cumulated]§"], "graph.show_values": false, "series_settings": {"§[metabase.media-per-year]§": {"color": "#509EE3"}, "§[metabase.media-cumulated]§": {"color": "#A989C5"}}}, "collection": {"id": 14, "name": "Upload", "description": null, "color": "#509EE3", "archived": false, "location": "/7/", "personal_owner_id": null, "slug": "upload"}, "created_at": "2021-01-19T10:13:27.009467Z", "public_uuid": null} -------------------------------------------------------------------------------- /etl/cards/7.json: -------------------------------------------------------------------------------- 1 | {"description": "§[metabase.corr-size-desc]§", "archived": false, "collection_position": null, "table_id": null, "result_metadata": [{"base_type": "type/Float", "display_name": "Corr Coef Using PGSQL Func", "name": "Corr Coef Using PGSQL Func", "special_type": null, "fingerprint": {"global": {"distinct-count": 1, "nil%": 0.0}, "type": {"type/Number": {"min": -0.03958996396310031, "max": -0.03958996396310031, "avg": -0.03958996396310031, "sd": null, "q1": -0.03958996396310031, "q3": -0.03958996396310031}}}}], "creator": {"email": "alessio@synapta.it", "first_name": "Alessio", "last_login": "2021-01-25T09:56:39.29734Z", "is_qbnewb": false, "is_superuser": true, "id": 2, "last_name": "Melandri", "date_joined": "2020-05-19T09:02:11.385428Z", "common_name": "Alessio Melandri"}, "can_write": true, "database_id": 3, "enable_embedding": false, "collection_id": 16, "query_type": "native", "name": "§[& metabase.corr-size]§", "dashboard_count": 1, "read_permissions": null, "creator_id": 2, "updated_at": "2021-05-23T06:12:44.61295Z", "made_public_by_id": null, "embedding_params": null, "cache_ttl": null, "dataset_query": {"type": "native", "native": {"query": "SELECT corr(average, img_size) as \"Corr Coef Using PGSQL Func\" FROM(\nSELECT AVG(accesses) as average, img_size\nFROM images i, visualizations v\nWHERE i.media_id = v.media_id\ngroup by img_name\n) as a\n\n \n"}, "database": 3}, "id": 7, "display": "scalar", "visualization_settings": {"graph.dimensions": ["average"], "graph.metrics": ["img_size"], "graph.x_axis.scale": "pow", "graph.y_axis.scale": "pow"}, "collection": {"id": 16, "name": "Correlations", "description": null, "color": "#509EE3", "archived": false, "location": "/7/", "personal_owner_id": null, "slug": "correlations"}, "created_at": "2020-06-03T13:51:16.813633Z", "public_uuid": null} -------------------------------------------------------------------------------- /etl/cards/9.json: -------------------------------------------------------------------------------- 1 | {"description": "§[metabase.corr-usage-desc]§", "archived": false, "collection_position": null, "table_id": null, "result_metadata": [{"base_type": "type/Float", "display_name": "Corr Coef Using PGSQL Func", "name": "Corr Coef Using PGSQL Func", "special_type": null, "fingerprint": {"global": {"distinct-count": 1, "nil%": 0.0}, "type": {"type/Number": {"min": 0.6663149842984744, "max": 0.6663149842984744, "avg": 0.6663149842984744, "sd": null, "q1": 0.6663149842984744, "q3": 0.6663149842984744}}}}], "creator": {"email": "alessio@synapta.it", "first_name": "Alessio", "last_login": "2021-01-25T09:56:39.29734Z", "is_qbnewb": false, "is_superuser": true, "id": 2, "last_name": "Melandri", "date_joined": "2020-05-19T09:02:11.385428Z", "common_name": "Alessio Melandri"}, "can_write": true, "database_id": 3, "enable_embedding": false, "collection_id": 16, "query_type": "native", "name": "§[& metabase.corr-usage]§", "dashboard_count": 1, "read_permissions": null, "creator_id": 2, "updated_at": "2021-05-23T06:14:31.679824Z", "made_public_by_id": null, "embedding_params": null, "cache_ttl": null, "dataset_query": {"type": "native", "native": {"query": "SELECT corr(average, usage) as \"Corr Coef Using PGSQL Func\" FROM(\nSELECT MIN(\"avg\") as average, AVG(u.\"count\") as usage\nFROM visualizations_stats v, dailyimageusage u\nWHERE u.img_name = v.img_name\ngroup by u.img_name\n) as a\n"}, "database": 3}, "id": 9, "display": "scalar", "visualization_settings": {"graph.dimensions": ["average"], "graph.metrics": ["img_size"], "graph.x_axis.scale": "pow", "graph.y_axis.scale": "pow"}, "collection": {"id": 16, "name": "Correlations", "description": null, "color": "#509EE3", "archived": false, "location": "/7/", "personal_owner_id": null, "slug": "correlations"}, "created_at": "2020-06-03T13:58:33.081456Z", "public_uuid": null} -------------------------------------------------------------------------------- /etl/dashboard.py: -------------------------------------------------------------------------------- 1 | import json 2 | import pymongo 3 | import requests 4 | import sys 5 | 6 | config_file = '../config/config.json' 7 | 8 | config = json.load(open(config_file)) 9 | 10 | try: 11 | sys.argv[1] 12 | except IndexError: 13 | print('Please provide a GLAM') 14 | sys.exit(1) 15 | 16 | mongo_client = pymongo.MongoClient(config['mongodb']['url']) 17 | mongo_db = mongo_client[config['mongodb']['database']] 18 | mongo_collection = mongo_db[config['mongodb']['collection']] 19 | 20 | # Find GLAM 21 | glam = mongo_collection.find_one({"name": sys.argv[1]}) 22 | 23 | if glam is None: 24 | print('Cannot find GLAM', sys.argv[1]) 25 | sys.exit(1) 26 | 27 | # Get Metabase session 28 | r = requests.post(config['metabase']['url'] + '/api/session', json={ 29 | "username": config['metabase']['username'], 30 | "password": config['metabase']['password'] 31 | }) 32 | headers = {"X-Metabase-Session": r.json()['id']} 33 | 34 | # Create database 35 | database = requests.post(config['metabase']['url'] + '/api/database', headers=headers, json={ 36 | "name": glam['fullname'], 37 | "engine": "postgres", 38 | "details": { 39 | "dbname": glam['database'], 40 | "host": config['metabase']['database']['host'], 41 | "port": config['metabase']['database']['port'], 42 | "user": config['metabase']['database']['user'], 43 | "password": config['metabase']['database']['password'], 44 | "ssl": True 45 | } 46 | }) 47 | database_id = database.json()['id'] 48 | 49 | # Create collection 50 | collection = requests.post(config['metabase']['url'] + '/api/collection', headers=headers, json={ 51 | "name": glam['fullname'], 52 | "color": "#509EE3" 53 | }) 54 | collection_id = collection.json()['id'] 55 | 56 | # Create dashboard 57 | new_dashboard = requests.post(config['metabase']['url'] + '/api/dashboard', headers=headers, json={ 58 | "name": glam['fullname'], 59 | "description": None, 60 | "parameters": [], 61 | "collection_id": collection_id 62 | }) 63 | dashboard_id = new_dashboard.json()['id'] 64 | 65 | # Get reference dashboard 66 | with open('dashboard.json', 'r') as f: 67 | dashboard = json.load(f) 68 | 69 | # For all the cards 70 | for card in dashboard['ordered_cards']: 71 | 72 | # Create a new card 73 | if card['card_id'] is not None: 74 | with open('cards/' + str(card['card_id']) + '.json', 'r') as f: 75 | new_card_dict = json.load(f) 76 | 77 | new_card_dict['dataset_query']['database'] = database_id 78 | 79 | new_card = requests.post(config['metabase']['url'] + '/api/card', headers=headers, json={ 80 | "visualization_settings": new_card_dict['visualization_settings'], 81 | "collection_id": collection_id, 82 | "name": new_card_dict['name'], 83 | "description": new_card_dict['description'], 84 | "dataset_query": new_card_dict['dataset_query'], 85 | "display": new_card_dict['display'] 86 | }) 87 | 88 | card_id = new_card.json()['id'] 89 | 90 | else: 91 | card_id = None 92 | 93 | # Add card to dashboard 94 | requests.post(config['metabase']['url'] + '/api/dashboard/' + str(dashboard_id) + '/cards', headers=headers, json={ 95 | "sizeX": card['sizeX'], 96 | "sizeY": card['sizeY'], 97 | "row": card['row'], 98 | "col": card['col'], 99 | "series": card['series'], 100 | "parameter_mappings": card['parameter_mappings'], 101 | "visualization_settings": card['visualization_settings'], 102 | "cardId": card_id 103 | }) 104 | 105 | # Enable embedding 106 | requests.put(config['metabase']['url'] + '/api/dashboard/' + str(dashboard_id), headers=headers, json={ 107 | "enable_embedding": True 108 | }) 109 | 110 | # Save dashboard_id 111 | mongo_collection.update_one({'_id': glam['_id']}, { 112 | '$set': {'dashboard_id': dashboard_id}}) 113 | -------------------------------------------------------------------------------- /etl/run.py: -------------------------------------------------------------------------------- 1 | import fcntl 2 | import json 3 | import logging 4 | import os 5 | import subprocess 6 | import sys 7 | import time 8 | from datetime import datetime, timedelta 9 | from subprocess import SubprocessError 10 | 11 | import psycopg2 12 | import pymongo 13 | from psycopg2 import ProgrammingError 14 | 15 | import sentry_sdk 16 | from sentry_sdk.integrations.logging import LoggingIntegration 17 | 18 | config_file = '../config/config.json' 19 | 20 | logging.basicConfig(level=logging.INFO, 21 | format='%(asctime)s %(levelname)s %(message)s') 22 | 23 | 24 | def update(collection, glam): 25 | collection.update_one({'_id': glam['_id']}, { 26 | '$set': {'lastrun': datetime.utcnow(), 27 | 'status': 'running'}}) 28 | 29 | 30 | def fail(collection, glam): 31 | collection.update_one({'_id': glam['_id']}, { 32 | '$set': {'status': 'failed'}}) 33 | 34 | 35 | def views_date(): 36 | date = datetime.utcnow() - timedelta(days=1) 37 | return date.strftime("%Y-%m-%d") 38 | 39 | 40 | def setup(name): 41 | logging.info('Running setup.js for %s', name) 42 | subprocess.run(['node', 'setup.js', name], check=True) 43 | logging.info('Subprocess setup.js completed') 44 | 45 | 46 | def dashboard(name): 47 | logging.info('Running dashboard.py for %s', name) 48 | subprocess.run(['python3', 'dashboard.py', name], check=True) 49 | logging.info('Subprocess dashboard.py completed') 50 | 51 | 52 | def etl(name): 53 | logging.info('Running etl.js for %s', name) 54 | subprocess.run(['node', 'etl.js', name], check=True) 55 | logging.info('Subprocess etl.js completed') 56 | 57 | 58 | def process_glam(collection, glam): 59 | if datetime.utcnow() < glam['lastrun'] + timedelta(days=1): 60 | logging.info('Glam %s is already updated', glam['name']) 61 | return 62 | 63 | # mediacounts are available around 2:00 UTC 64 | if datetime.utcnow().hour <= 2: 65 | logging.info('Glam %s update delayed', glam['name']) 66 | return 67 | 68 | success = True 69 | logging.info('Running scheduler for %s', glam['name']) 70 | 71 | # Run etl.js 72 | try: 73 | etl(glam['name']) 74 | except SubprocessError as e: 75 | success = False 76 | logging.error('Subprocess etl.js failed') 77 | 78 | if e.returncode == 65: 79 | logging.error('Glam %s is now failed', glam['name']) 80 | fail(collection, glam) 81 | return 82 | 83 | if success: 84 | logging.info('Completed scheduler for %s', glam['name']) 85 | update(collection, glam) 86 | else: 87 | logging.error('Failed scheduler for %s', glam['name']) 88 | 89 | 90 | def create_database(config, database): 91 | connstring = "dbname=template1 user=" + config['postgres']['user'] + \ 92 | " password=" + config['postgres']['password'] + \ 93 | " host=" + config['postgres']['host'] + \ 94 | " port=" + str(config['postgres']['port']) 95 | conn = psycopg2.connect(connstring) 96 | conn.autocommit = True 97 | curse = conn.cursor() 98 | try: 99 | curse.execute("CREATE DATABASE " + database + " WITH OWNER = " + config['postgres']['user'] + " " + 100 | "ENCODING = 'UTF8' " + 101 | "CONNECTION LIMIT = -1 TEMPLATE template0;") 102 | curse.execute("GRANT CONNECT ON DATABASE " + database + " TO metabase;") 103 | except ProgrammingError: 104 | # the database is already available 105 | pass 106 | finally: 107 | conn.close() 108 | 109 | 110 | def main(): 111 | config = json.load(open(config_file)) 112 | 113 | try: 114 | sentry_logging = LoggingIntegration( 115 | level=logging.INFO, 116 | event_level=logging.ERROR 117 | ) 118 | sentry_sdk.init( 119 | dsn=config['raven']['glamtoolsetl']['DSN'], 120 | integrations=[sentry_logging] 121 | ) 122 | logging.info('External error reporting ENABLED') 123 | except KeyError: 124 | logging.info('External error reporting DISABLED') 125 | 126 | client = pymongo.MongoClient(config['mongodb']['url']) 127 | db = client[config['mongodb']['database']] 128 | collection = db[config['mongodb']['collection']] 129 | 130 | for glam in collection.find(): 131 | if 'status' in glam: 132 | if glam['status'] == 'paused': 133 | logging.info('Glam %s is paused', glam['name']) 134 | continue 135 | 136 | if glam['status'] == 'failed': 137 | logging.info('Glam %s is failed', glam['name']) 138 | continue 139 | 140 | if 'lastrun' in glam: 141 | process_glam(collection, glam) 142 | else: 143 | # this is the first run 144 | glam['lastrun'] = datetime.fromtimestamp(0) 145 | 146 | create_database(config, glam['database']) 147 | 148 | try: 149 | setup(glam['name']) 150 | dashboard(glam['name']) 151 | except SubprocessError: 152 | logging.error('Subprocess setup.js or dashboard.py failed') 153 | continue 154 | 155 | process_glam(collection, glam) 156 | 157 | 158 | if __name__ == '__main__': 159 | # change the working directory to the script's own directory 160 | script_dir = os.path.dirname(sys.argv[0]) 161 | if script_dir != '': 162 | os.chdir(script_dir) 163 | 164 | try: 165 | lockfile = open('/tmp/cassandra.lock', 'w') 166 | fcntl.flock(lockfile, fcntl.LOCK_EX | fcntl.LOCK_NB) 167 | main() 168 | fcntl.flock(lockfile, fcntl.LOCK_UN) 169 | except IOError: 170 | raise SystemExit('Scheduler is already running') 171 | -------------------------------------------------------------------------------- /etl/run_views.py: -------------------------------------------------------------------------------- 1 | import fcntl 2 | import json 3 | import logging 4 | import os 5 | import subprocess 6 | import sys 7 | from datetime import date, datetime, timedelta 8 | from subprocess import SubprocessError 9 | 10 | import psycopg2 11 | import pymongo 12 | 13 | import sentry_sdk 14 | from sentry_sdk.integrations.logging import LoggingIntegration 15 | 16 | config_file = '../config/config.json' 17 | 18 | global_min_date = date(2015, 1, 1) 19 | global_max_date = date.today() - timedelta(days=2) 20 | views_dir = 'temp' 21 | 22 | logging.basicConfig(level=logging.INFO, 23 | format='%(asctime)s %(levelname)s %(message)s') 24 | 25 | 26 | def add_missing_dates(config, glam): 27 | logging.info('Processing glam %s', glam['name']) 28 | 29 | connstring = "dbname=" + glam['database'] + " user=" + config['postgres']['user'] + \ 30 | " password=" + config['postgres']['password'] + \ 31 | " host=" + config['postgres']['host'] + \ 32 | " port=" + str(config['postgres']['port']) 33 | conn = psycopg2.connect(connstring) 34 | conn.autocommit = True 35 | curse = conn.cursor() 36 | 37 | # Find the dates already in the database 38 | curse.execute( 39 | "select distinct access_date from visualizations order by access_date") 40 | current_dates = list(map(lambda x: x[0], curse.fetchall())) 41 | 42 | # Find the date of the first image, if any 43 | curse.execute("SELECT min(img_timestamp) FROM images") 44 | try: 45 | first_image = curse.fetchone()[0].date() 46 | except TypeError: 47 | first_image = global_min_date 48 | conn.close() 49 | 50 | try: 51 | min_date = datetime.strptime(glam['min_date'], "%Y-%m-%d").date() 52 | except KeyError: 53 | min_date = max([first_image, date.today() - timedelta(days=10)]) 54 | 55 | candidate_dates = [min_date + timedelta(days=x) 56 | for x in range(0, (global_max_date - min_date).days)] 57 | 58 | glam['missing_dates'] = [] 59 | 60 | for date_value in candidate_dates: 61 | if date_value not in current_dates: 62 | glam['missing_dates'].append(date_value) 63 | 64 | 65 | def update_min_date(collection, glam, date_str): 66 | collection.update_one({'_id': glam['_id']}, { 67 | '$set': {'min_date': date_str}}) 68 | 69 | 70 | def main(): 71 | config = json.load(open(config_file)) 72 | 73 | try: 74 | sentry_logging = LoggingIntegration( 75 | level=logging.INFO, 76 | event_level=logging.ERROR 77 | ) 78 | sentry_sdk.init( 79 | dsn=config['raven']['glamtoolsetl']['DSN'], 80 | integrations=[sentry_logging] 81 | ) 82 | logging.info('External error reporting ENABLED') 83 | except KeyError: 84 | logging.info('External error reporting DISABLED') 85 | 86 | client = pymongo.MongoClient(config['mongodb']['url']) 87 | db = client[config['mongodb']['database']] 88 | collection = db[config['mongodb']['collection']] 89 | 90 | glams = [] 91 | 92 | for glam in collection.find(): 93 | if 'status' in glam: 94 | if glam['status'] == 'paused': 95 | logging.info('Glam %s is paused', glam['name']) 96 | continue 97 | 98 | if glam['status'] == 'failed': 99 | logging.info('Glam %s is failed', glam['name']) 100 | continue 101 | 102 | add_missing_dates(config, glam) 103 | glams.append(glam) 104 | 105 | date_interval = [global_min_date + timedelta(days=x) 106 | for x in range(0, (global_max_date - global_min_date).days)] 107 | 108 | # for all dates 109 | for date_value in date_interval: 110 | logging.info('Working with date %s', date_value) 111 | 112 | for glam in glams: 113 | logging.info('Working with GLAM %s', glam['name']) 114 | date_str = date_value.strftime("%Y-%m-%d") 115 | 116 | if date_value in glam['missing_dates']: 117 | try: 118 | subprocess.run(['python3', 'views.py', glam['name'], date_str, '--dir', views_dir], check=True) 119 | update_min_date(collection, glam, date_str) 120 | except SubprocessError: 121 | logging.error('Subprocess views.py failed') 122 | 123 | views_path = os.path.join(views_dir, date_str + '.tsv.bz2') 124 | if os.path.isfile(views_path): 125 | os.remove(views_path) 126 | 127 | 128 | if __name__ == '__main__': 129 | # change the working directory to the script's own directory 130 | script_dir = os.path.dirname(sys.argv[0]) 131 | if script_dir != '': 132 | os.chdir(script_dir) 133 | 134 | try: 135 | lockfile = open('/tmp/cassandra_views.lock', 'w') 136 | fcntl.flock(lockfile, fcntl.LOCK_EX | fcntl.LOCK_NB) 137 | main() 138 | fcntl.flock(lockfile, fcntl.LOCK_UN) 139 | except IOError: 140 | raise SystemExit('Views is already running') 141 | -------------------------------------------------------------------------------- /etl/setup.js: -------------------------------------------------------------------------------- 1 | var config = require("../config/config.js"); 2 | var fs = require("fs"); 3 | 4 | var endSetup = function () { 5 | console.log("Setup ended"); 6 | process.exit(0); 7 | }; 8 | 9 | var processQuery = function (db, files, step) { 10 | if (step >= files.length) { 11 | endSetup(); 12 | return; 13 | } 14 | filetoRead = "SQL/" + files[step]; 15 | console.log(filetoRead); 16 | fs.readFile(filetoRead, 'ascii', function (_, read) { 17 | // console.log("Executing: "); 18 | read = read.toString('ascii'); 19 | // console.log(read); 20 | db.query(read, function (err, b) { 21 | if (!err) { 22 | processQuery(db, files, step+1); 23 | } 24 | else { 25 | console.log("Error in step " + step); 26 | console.log(err); 27 | endSetup(); 28 | return; 29 | } 30 | }) 31 | }) 32 | }; 33 | 34 | if (process.argv.length != 3) { 35 | console.log('Missing GLAM name'); 36 | process.exit(1); 37 | } 38 | 39 | config.loadGlams(() => { 40 | var glam = config.glams[process.argv[2]]; 41 | 42 | if (glam === undefined) { 43 | console.log('Unknown GLAM name'); 44 | process.exit(1); 45 | } 46 | 47 | var files = ["db_init.sql", "dailyInsert.sql", "functions.sql", "maintenance.sql"]; 48 | 49 | console.log("Setup started"); 50 | processQuery(glam.connection, files, 0); 51 | }); 52 | -------------------------------------------------------------------------------- /etl/views.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import bz2 3 | import json 4 | import os 5 | import sys 6 | import urllib.parse 7 | import urllib.request 8 | 9 | import psycopg2 10 | import pymongo 11 | 12 | watched = set() 13 | 14 | 15 | def reporter(first, second, third): 16 | if first % 1000 == 0: 17 | print("Download progress: " + str(first * second * 100 // third) + "%") 18 | 19 | 20 | def download(date, folder): 21 | if not os.path.exists(folder): 22 | os.makedirs(folder) 23 | 24 | filename = os.path.join(folder, date + '.tsv.bz2') 25 | 26 | year, month, day = date.split("-") 27 | baseurl = "https://dumps.wikimedia.org/other/mediacounts/daily/" 28 | finalurl = baseurl + year + "/mediacounts." + \ 29 | year + "-" + month + "-" + day + ".v00.tsv.bz2" 30 | 31 | # check file size 32 | if os.path.isfile(filename): 33 | remote_size = urllib.request.urlopen(finalurl).length 34 | local_size = os.stat(filename).st_size 35 | if remote_size == local_size: 36 | return filename 37 | else: 38 | os.remove(filename) 39 | 40 | print("Retrieving " + finalurl + "...") 41 | urllib.request.urlretrieve(finalurl, filename, reporter) 42 | print("Download completed.") 43 | 44 | return filename 45 | 46 | 47 | def process(conn, date, folder): 48 | filename = download(date, folder) 49 | print("Loading visualizations from file", filename) 50 | 51 | source_file = bz2.BZ2File(filename, "r") 52 | curse = conn.cursor() 53 | counter = 0 54 | 55 | for line in source_file: 56 | if counter == len(watched): 57 | break 58 | arr = line.decode().split("\t") 59 | keysX = arr[0].split("/") 60 | # count only files from commons 61 | if len(keysX) < 6 or keysX[2] != 'commons': 62 | continue 63 | key = keysX[len(keysX) - 1] 64 | key = urllib.parse.unquote(key) 65 | if key in watched: 66 | counter += 1 67 | if counter % 100 == 0: 68 | print("Loading progress: " + 69 | str(counter * 100 // len(watched)) + "%") 70 | query = "select * from dailyinsert('" + key.replace( 71 | "'", "''") + "','" + date + "'," + arr[2] + "," + arr[22] + "," + arr[23] + ")" 72 | curse.execute(query) 73 | 74 | curse.execute('refresh materialized view visualizations_sum') 75 | curse.execute('refresh materialized view visualizations_stats') 76 | curse.close() 77 | source_file.close() 78 | 79 | 80 | def loadImages(conn): 81 | curse = conn.cursor() 82 | curse.execute("SELECT img_name FROM images;") 83 | w = 0 84 | while w < curse.rowcount: 85 | w += 1 86 | file = curse.fetchone() 87 | file = file[0] 88 | # print(file) 89 | if file not in watched: 90 | watched.add(file) 91 | curse.close() 92 | 93 | 94 | def main(): 95 | parser = argparse.ArgumentParser() 96 | parser.add_argument('glam', type=str) 97 | parser.add_argument('date', type=str) 98 | parser.add_argument('--dir', type=str, required=False, default='temp') 99 | args = parser.parse_args() 100 | 101 | # read settings 102 | config = json.load(open('../config/config.json')) 103 | client = pymongo.MongoClient(config['mongodb']['url']) 104 | db = client[config['mongodb']['database']] 105 | collection = db[config['mongodb']['collection']] 106 | glam = collection.find_one({"name": args.glam}) 107 | 108 | if glam == None: 109 | print("Unknown Glam name", args.glam) 110 | sys.exit(1) 111 | 112 | connstring = "dbname=" + glam['database'] + " user=" + config['postgres']['user'] + \ 113 | " password=" + config['postgres']['password'] + \ 114 | " host=" + config['postgres']['host'] + \ 115 | " port=" + str(config['postgres']['port']) 116 | pgconnection = psycopg2.connect(connstring) 117 | # print(pgconnection.encoding) 118 | pgconnection.autocommit = True 119 | 120 | loadImages(pgconnection) 121 | process(pgconnection, args.date, args.dir) 122 | 123 | pgconnection.close() 124 | print("Process completed") 125 | 126 | 127 | if __name__ == "__main__": 128 | main() 129 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cassandra", 3 | "version": "2.0.0", 4 | "author": "Synapta Srl (https://synapta.it/)", 5 | "license": "MIT", 6 | "private": true, 7 | "dependencies": { 8 | "@sentry/node": "^5.2.0", 9 | "apicache": "~0.1.5", 10 | "cookie-parser": "^1.4.5", 11 | "csv": "^5.1.1", 12 | "express": "^4.16.4", 13 | "express-recaptcha": "^5.0.2", 14 | "http-auth": "^3.2.4", 15 | "http-proxy-middleware": "^2.0.0", 16 | "ini": "^2.0.0", 17 | "iso-639-1": "^2.1.9", 18 | "jsonwebtoken": "^8.5.1", 19 | "mariadb": "^2.1.1", 20 | "mime-types": "^2.1.31", 21 | "mongodb": "~3.1.13", 22 | "morgan": "^1.9.1", 23 | "mustache": "^4.2.0", 24 | "nodemailer": "^6.7.0", 25 | "nodemailer-mailgun-transport": "^2.1.3", 26 | "pg": "~7.4.0", 27 | "puppeteer": "^5.5.0", 28 | "raml2html": "latest", 29 | "request": "^2.88.0", 30 | "utf8": "~2.1.2" 31 | }, 32 | "scripts": { 33 | "postinstall": "raml2html ./docs/api.raml > ./app/pages/docs.html", 34 | "start": "cd app && node server.js" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /recommender/run.py: -------------------------------------------------------------------------------- 1 | import fcntl 2 | import json 3 | import logging 4 | import os 5 | import subprocess 6 | import sys 7 | from subprocess import SubprocessError 8 | 9 | import pymongo 10 | import sentry_sdk 11 | from sentry_sdk.integrations.logging import LoggingIntegration 12 | 13 | config_file = '../config/config.json' 14 | 15 | logging.basicConfig(level=logging.INFO, 16 | format='%(asctime)s %(levelname)s %(message)s') 17 | 18 | 19 | def run_recommender(glam): 20 | logging.info('Running recommender for %s', glam['name']) 21 | 22 | try: 23 | subprocess.run(['python3', 'similarity.py', glam['database']], check=True) 24 | except SubprocessError: 25 | logging.error('Subprocess similarity.py failed') 26 | 27 | 28 | def main(): 29 | config = json.load(open(config_file)) 30 | 31 | try: 32 | logging.info('External error reporting enabled') 33 | 34 | sentry_logging = LoggingIntegration( 35 | level=logging.INFO, 36 | event_level=logging.ERROR 37 | ) 38 | sentry_sdk.init( 39 | dsn=config['raven']['glamtoolsetl']['DSN'], 40 | integrations=[sentry_logging] 41 | ) 42 | except KeyError: 43 | logging.info('External error reporting DISABLED') 44 | pass 45 | 46 | client = pymongo.MongoClient(config['mongodb']['url']) 47 | db = client[config['mongodb']['database']] 48 | collection = db[config['mongodb']['collection']] 49 | 50 | for glam in collection.find(): 51 | if 'status' in glam: 52 | if glam['status'] == 'paused': 53 | logging.info('Glam %s is paused', glam['name']) 54 | continue 55 | 56 | if glam['status'] == 'failed': 57 | logging.info('Glam %s is failed', glam['name']) 58 | continue 59 | 60 | run_recommender(glam) 61 | 62 | 63 | if __name__ == '__main__': 64 | # change the working directory to the script's own directory 65 | script_dir = os.path.dirname(sys.argv[0]) 66 | if script_dir != '': 67 | os.chdir(script_dir) 68 | 69 | try: 70 | lockfile = open('/tmp/cassandra_recommender.lock', 'w') 71 | fcntl.flock(lockfile, fcntl.LOCK_EX | fcntl.LOCK_NB) 72 | main() 73 | fcntl.flock(lockfile, fcntl.LOCK_UN) 74 | except IOError: 75 | raise SystemExit('Recommender is already running') 76 | -------------------------------------------------------------------------------- /recommender/similarity.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import sys 4 | from datetime import date, timedelta 5 | 6 | import mwclient 7 | import psycopg2 8 | import requests 9 | from psycopg2.errors import UniqueViolation 10 | 11 | if len(sys.argv) == 1: 12 | print('Missing database') 13 | sys.exit(1) 14 | 15 | database = sys.argv[1] 16 | 17 | logging.basicConfig(level=logging.INFO, 18 | format='%(asctime)s %(levelname)s %(message)s') 19 | 20 | with open('../config/config.json', 'r') as fp: 21 | config = json.load(fp) 22 | 23 | conn = psycopg2.connect(host=config['postgres']['host'], 24 | port=config['postgres']['port'], 25 | dbname=database, 26 | user=config['postgres']['user'], 27 | password=config['postgres']['password']) 28 | 29 | conn.set_session(autocommit=True) 30 | 31 | cur = conn.cursor() 32 | 33 | last_update = date.today() - timedelta(days=7) 34 | 35 | cur.execute("""SELECT i.img_name 36 | FROM images i 37 | LEFT JOIN usages u ON i.img_name = u.gil_to 38 | LEFT JOIN recommendations r ON i.img_name = r.img_name 39 | WHERE (i.is_alive = TRUE OR i.is_alive IS NULL) 40 | AND u.is_alive IS NULL 41 | AND (r.last_update < %s OR r.last_update IS NULL) 42 | GROUP BY i.img_name""", (last_update,)) 43 | 44 | images = cur.fetchall() 45 | image_counter = 1 46 | 47 | site = mwclient.Site('commons.wikimedia.org') 48 | 49 | 50 | def compute_category(image): 51 | entities = [] 52 | 53 | page = site.images[image] 54 | 55 | for cat in page.categories(show='!hidden'): 56 | iwlinks = cat.iwlinks() 57 | for iw in iwlinks: 58 | if iw[0] == 'd': 59 | entities.append(iw[1]) 60 | 61 | return entities 62 | 63 | 64 | def process_entities(image, entities, scores): 65 | for e in entities: 66 | try: 67 | r = requests.get( 68 | 'http://www.wikidata.org/wiki/Special:EntityData/' + e + '.json') 69 | entity = r.json()['entities'][e] 70 | 71 | if 'sitelinks' not in entity: 72 | continue 73 | 74 | if 'claims' not in entity: 75 | continue 76 | 77 | if len(entity['sitelinks']) == 0: 78 | continue 79 | 80 | if not ('enwiki' in entity['sitelinks'] or 'dewiki' in entity['sitelinks'] or 'frwiki' in entity['sitelinks'] or 'itwiki' in entity['sitelinks']): 81 | continue 82 | 83 | # instance of 84 | if 'P31' not in entity['claims']: 85 | continue 86 | 87 | instance_of = entity['claims']['P31'][0]['mainsnak']['datavalue']['value']['id'] 88 | 89 | # disambiguation, category, events, template 90 | if instance_of in ['Q4167410', 'Q4167836', 'Q18340514', 'Q11119738']: 91 | continue 92 | 93 | except (ValueError, KeyError): 94 | continue 95 | 96 | try: 97 | if scores is not None: 98 | cur.execute("""INSERT INTO recommendations 99 | (img_name, site, title, url, score, last_update) 100 | VALUES(%s, %s, %s, %s, %s, %s)""", (image, 'wikidata', e, 'https://www.wikidata.org/wiki/' + e, float(scores[e]), date.today())) 101 | else: 102 | cur.execute("""INSERT INTO recommendations 103 | (img_name, site, title, url, last_update) 104 | VALUES(%s, %s, %s, %s, %s)""", (image, 'wikidata', e, 'https://www.wikidata.org/wiki/' + e, date.today())) 105 | except UniqueViolation: 106 | continue 107 | 108 | 109 | for image in images: 110 | logging.info('Processing image %s of %s: %s', image_counter, len(images), image[0]) 111 | image_counter += 1 112 | 113 | try: 114 | cur.execute("""DELETE FROM recommendations 115 | WHERE img_name = %s""", (image[0],)) 116 | 117 | entities = compute_category(image[0]) 118 | process_entities(image[0], entities, None) 119 | 120 | except ValueError: 121 | continue 122 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | psycopg2-binary 2 | pymongo 3 | sentry-sdk 4 | mwclient 5 | requests --------------------------------------------------------------------------------