├── client ├── .eslintignore ├── __mocks__ │ └── style.js ├── i18n.dir.js ├── bin │ └── watch ├── images │ └── logo.png ├── src │ ├── components │ │ ├── timeline │ │ │ ├── revision │ │ │ │ ├── edit-summary-parts.js │ │ │ │ ├── timelapse.js │ │ │ │ ├── title.js │ │ │ │ ├── byte-change.js │ │ │ │ ├── revision.container.js │ │ │ │ └── comment.js │ │ │ ├── user-list.container.js │ │ │ ├── status.container.js │ │ │ ├── timeline.container.js │ │ │ ├── date-list.container.js │ │ │ ├── diff │ │ │ │ ├── diff.container.js │ │ │ │ ├── header.container.js │ │ │ │ ├── header-revision.container.js │ │ │ │ ├── header.js │ │ │ │ └── header-revision.js │ │ │ ├── error-message.container.js │ │ │ ├── user.container.js │ │ │ ├── date-revisions.container.js │ │ │ ├── user-list.js │ │ │ ├── spinner.js │ │ │ ├── date.js │ │ │ ├── alert.js │ │ │ ├── user.js │ │ │ ├── date-list.js │ │ │ ├── header.js │ │ │ ├── revision-list.js │ │ │ ├── status.js │ │ │ ├── error-message.js │ │ │ ├── date-revisions.js │ │ │ └── timeline.js │ │ ├── link.js │ │ ├── fields │ │ │ ├── select-users.container.js │ │ │ ├── select-wiki.container.js │ │ │ ├── date-range.container.js │ │ │ ├── select-wiki.js │ │ │ ├── date-picker.js │ │ │ └── date-range.js │ │ ├── share │ │ │ ├── share.container.js │ │ │ └── share.test.js │ │ ├── form.js │ │ └── error-boundary.js │ ├── entities │ │ ├── wiki-namespace.js │ │ ├── page.js │ │ ├── diff-meta.js │ │ ├── wiki.js │ │ ├── revision-meta.js │ │ ├── diff.js │ │ ├── revision.js │ │ └── query.js │ ├── reducers │ │ ├── revisions │ │ │ ├── error.js │ │ │ ├── index.js │ │ │ ├── cont.js │ │ │ ├── status.js │ │ │ └── list.js │ │ ├── wikis.js │ │ ├── index.js │ │ ├── query.js │ │ └── diffs.js │ ├── selectors │ │ ├── users.js │ │ ├── date.js │ │ ├── wiki.js │ │ ├── side.js │ │ ├── status.js │ │ ├── editorinteract.js │ │ ├── editorinteract.test.js │ │ ├── diff.js │ │ └── revisions.js │ ├── utils │ │ ├── location-query.js │ │ ├── intersection.js │ │ ├── location-query.test.js │ │ ├── special-wikis-list.js │ │ └── ip-validator.js │ ├── actions │ │ ├── wiki.js │ │ ├── diff.js │ │ ├── query.js │ │ └── revisions.js │ └── epics │ │ ├── index.js │ │ └── query.js ├── test-env.js ├── .babelrc ├── .eslintrc.json ├── index.ejs └── index.js ├── .gitignore ├── etc ├── ssh │ └── config └── lighttpd │ └── lighttpd.conf ├── server ├── src │ ├── middleware.php │ ├── routes.php │ ├── Service │ │ ├── ConnectionServiceInterface.php │ │ └── ConnectionService.php │ ├── Dao │ │ └── UserDao.php │ ├── settings.php │ ├── AppErrorHandler.php │ ├── Middleware │ │ └── ConnectionManagerMiddleware.php │ ├── Action │ │ └── InteractionAction.php │ └── dependencies.php ├── public │ └── index.php ├── phpcs.xml ├── tests │ └── Service │ │ └── ConnectionServiceTest.php ├── phpunit.xml └── composer.json ├── .editorconfig ├── Dockerfile ├── i18n ├── bs.json ├── mnw.json ├── azb.json ├── ms-arab.json ├── hy.json ├── kn.json ├── skr-arab.json ├── hif-latn.json ├── smn.json ├── sms.json ├── it.json ├── kum.json ├── zgh.json ├── ne.json ├── bg.json ├── my.json ├── bn.json ├── ckb.json ├── kab.json ├── bcl.json ├── zh-hans.json ├── li.json ├── zh-hant.json ├── ko.json ├── cs.json ├── ja.json ├── th.json ├── he.json ├── ar.json ├── en.json ├── ps.json ├── xmf.json ├── lt.json ├── eo.json ├── te.json ├── sl.json ├── ce.json ├── fa.json ├── nb.json ├── ast.json ├── sh.json ├── anp.json ├── diq.json ├── sv.json ├── jv-java.json ├── sr-ec.json ├── da.json ├── fi.json ├── hi.json ├── krc.json ├── be-tarask.json ├── hyw.json ├── tl.json ├── sk.json ├── pl.json ├── mk.json ├── gl.json ├── id.json ├── nl.json ├── sc.json ├── pt-br.json ├── ru.json ├── vi.json └── pt.json ├── toolinfo.json ├── .env.dist ├── docker-compose.yml ├── bin └── start └── .github └── workflows └── build.yml /client/.eslintignore: -------------------------------------------------------------------------------- 1 | /html 2 | -------------------------------------------------------------------------------- /client/__mocks__/style.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; 2 | -------------------------------------------------------------------------------- /client/i18n.dir.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | path: '../i18n', 3 | filter: /\.json$/ 4 | }; 5 | -------------------------------------------------------------------------------- /client/bin/watch: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | npm install --verbose --unsafe-perm \ 3 | && npm run watch 4 | -------------------------------------------------------------------------------- /client/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wikimedia/InteractionTimeline/HEAD/client/images/logo.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /client/coverage 2 | /client/node_modules 3 | /server/vendor 4 | /html 5 | .env 6 | /etc/ssh/id_rsa 7 | -------------------------------------------------------------------------------- /etc/ssh/config: -------------------------------------------------------------------------------- 1 | Host * 2 | UseRoaming no 3 | ServerAliveInterval 30 4 | AddressFamily inet 5 | IdentityFile /root/.ssh/id_rsa 6 | -------------------------------------------------------------------------------- /server/src/middleware.php: -------------------------------------------------------------------------------- 1 | add( new App\Middleware\ConnectionManagerMiddleware( 4 | $app->getContainer()->get( 'connectionService' ) 5 | ) ); 6 | -------------------------------------------------------------------------------- /client/src/components/timeline/revision/edit-summary-parts.js: -------------------------------------------------------------------------------- 1 | const REGEX_EDIT_SUMMARY_PARTS = /(?:\/\*([^*]+)\*\/)?(.+)?/; 2 | 3 | export default REGEX_EDIT_SUMMARY_PARTS; 4 | -------------------------------------------------------------------------------- /client/test-env.js: -------------------------------------------------------------------------------- 1 | import Enzyme from 'enzyme'; 2 | import Adapter from 'enzyme-adapter-react-16'; 3 | 4 | // Configure Enzyme 5 | Enzyme.configure( { adapter: new Adapter() } ); 6 | -------------------------------------------------------------------------------- /server/src/routes.php: -------------------------------------------------------------------------------- 1 | group( '/{project}', function (){ 4 | $this->get( '/interaction', App\Action\InteractionAction::class ) 5 | ->setName( 'interaction' ); 6 | } ); 7 | -------------------------------------------------------------------------------- /client/src/entities/wiki-namespace.js: -------------------------------------------------------------------------------- 1 | import { Record } from 'immutable'; 2 | 3 | export default class WikiNamespace extends Record( { 4 | id: undefined, 5 | name: undefined 6 | }, 'WikiNamespace' ) {} 7 | -------------------------------------------------------------------------------- /client/src/entities/page.js: -------------------------------------------------------------------------------- 1 | import { Record, Map } from 'immutable'; 2 | 3 | export default class Page extends Record( { 4 | id: undefined, 5 | title: undefined, 6 | editors: new Map() 7 | }, 'Page' ) {} 8 | -------------------------------------------------------------------------------- /client/src/entities/diff-meta.js: -------------------------------------------------------------------------------- 1 | import { Record } from 'immutable'; 2 | 3 | export default class DiffMeta extends Record( { 4 | show: false, 5 | status: 'ready', 6 | error: undefined 7 | }, 'DiffMeta' ) {} 8 | -------------------------------------------------------------------------------- /client/src/entities/wiki.js: -------------------------------------------------------------------------------- 1 | import { Record, Map } from 'immutable'; 2 | 3 | export default class Wiki extends Record( { 4 | id: undefined, 5 | domain: undefined, 6 | namespaces: new Map() 7 | }, 'Wiki' ) {} 8 | -------------------------------------------------------------------------------- /client/src/entities/revision-meta.js: -------------------------------------------------------------------------------- 1 | import { Record } from 'immutable'; 2 | 3 | export default class RevisionMeta extends Record( { 4 | status: 'done', 5 | error: undefined, 6 | interaction: true 7 | }, 'RevisionMeta' ) {} 8 | -------------------------------------------------------------------------------- /client/src/components/timeline/user-list.container.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import UserList from './user-list'; 3 | 4 | export default connect( 5 | ( state ) => ( { 6 | users: state.query.user 7 | } ) 8 | )( UserList ); 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.md] 11 | trim_trailing_whitespace = false 12 | 13 | [*.{yaml,yml}] 14 | indent_style = space 15 | -------------------------------------------------------------------------------- /client/src/reducers/revisions/error.js: -------------------------------------------------------------------------------- 1 | export default ( state = null, action ) => { 2 | switch ( action.type ) { 3 | case 'REVISIONS_ERROR': 4 | return action.error; 5 | case 'REVISIONS_ERROR_CLEAR': 6 | return null; 7 | default: 8 | return state; 9 | } 10 | }; 11 | -------------------------------------------------------------------------------- /client/src/selectors/users.js: -------------------------------------------------------------------------------- 1 | import { createSelector } from 'reselect'; 2 | import { OrderedSet } from 'immutable'; 3 | 4 | const getUsers = createSelector( 5 | state => state.query.user, 6 | ( user = new OrderedSet() ) => user.toArray() 7 | ); 8 | 9 | export default getUsers; 10 | -------------------------------------------------------------------------------- /client/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["env", { 4 | "targets": { 5 | "node": "6.11", 6 | "browsers": ["last 2 versions"] 7 | } 8 | }] 9 | ], 10 | "plugins": [ 11 | "transform-react-jsx", 12 | "transform-object-rest-spread" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /client/src/components/timeline/status.container.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { getStatus } from 'app/selectors/status'; 3 | import Status from './status'; 4 | 5 | export default connect( 6 | state => ( { 7 | status: getStatus( state ) 8 | } ) 9 | )( Status ); 10 | -------------------------------------------------------------------------------- /client/src/components/timeline/timeline.container.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import Timeline from './timeline'; 3 | 4 | export default connect( 5 | state => ( { 6 | empty: state.revisions.list.isEmpty(), 7 | status: state.revisions.status 8 | } ) 9 | )( Timeline ); 10 | -------------------------------------------------------------------------------- /client/src/reducers/revisions/index.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import list from './list'; 3 | import status from './status'; 4 | import cont from './cont'; 5 | import error from './error'; 6 | 7 | export default combineReducers( { 8 | list, 9 | status, 10 | cont, 11 | error 12 | } ); 13 | -------------------------------------------------------------------------------- /client/src/utils/location-query.js: -------------------------------------------------------------------------------- 1 | import qs from 'querystring'; 2 | import Query from 'app/entities/query'; 3 | 4 | export default ( location ) => { 5 | let data = {}; 6 | 7 | if ( location.search ) { 8 | data = qs.parse( location.search.substring( 1 ) ); 9 | } 10 | 11 | return new Query( data ); 12 | }; 13 | -------------------------------------------------------------------------------- /client/src/components/timeline/date-list.container.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { getTimelineRevisions } from 'app/selectors/revisions'; 3 | import DateList from './date-list'; 4 | 5 | export default connect( 6 | state => ( { 7 | revisions: getTimelineRevisions( state ) 8 | } ) 9 | )( DateList ); 10 | -------------------------------------------------------------------------------- /client/src/reducers/wikis.js: -------------------------------------------------------------------------------- 1 | import { Map } from 'immutable'; 2 | 3 | export default ( state = new Map(), action ) => { 4 | switch ( action.type ) { 5 | case 'WIKIS_SET': 6 | return action.wikis; 7 | case 'WIKIS_SET_NAMESPACES': 8 | return state.setIn( [ action.id, 'namespaces' ], action.namespaces ); 9 | default: 10 | return state; 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /client/src/components/timeline/diff/diff.container.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { setDiffShow } from 'app/actions/diff'; 3 | import Diff from './diff'; 4 | 5 | export default connect( 6 | undefined, 7 | ( dispatch, props ) => ( { 8 | closeDiff: () => { 9 | return dispatch( setDiffShow( props.diff, false ) ); 10 | } 11 | } ) 12 | )( Diff ); 13 | -------------------------------------------------------------------------------- /client/src/reducers/revisions/cont.js: -------------------------------------------------------------------------------- 1 | export default ( state = '', action ) => { 2 | switch ( action.type ) { 3 | case 'REVISIONS_ADD': 4 | return action.cont; 5 | case 'QUERY_USER_CHANGE': 6 | case 'QUERY_WIKI_CHANGE': 7 | case 'QUERY_START_DATE_CHANGE': 8 | case 'QUERY_END_DATE_CHANGE': 9 | return ''; 10 | default: 11 | return state; 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /server/public/index.php: -------------------------------------------------------------------------------- 1 | run(); 16 | -------------------------------------------------------------------------------- /client/src/actions/wiki.js: -------------------------------------------------------------------------------- 1 | export function setWikis( wikis ) { 2 | return { 3 | type: 'WIKIS_SET', 4 | wikis 5 | }; 6 | } 7 | 8 | export function setWikiNamespaces( id, namespaces ) { 9 | return { 10 | type: 'WIKIS_SET_NAMESPACES', 11 | id, 12 | namespaces 13 | }; 14 | } 15 | 16 | export function fetchWikiList() { 17 | return { 18 | type: 'WIKI_LIST_FETCH' 19 | }; 20 | } 21 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM docker-registry.tools.wmflabs.org/toolforge-php72-sssd-web 2 | 3 | # Set the host domain on Linux. 4 | # see https://github.com/docker/for-linux/issues/264 5 | RUN apt-get update && apt-get install -y \ 6 | iputils-ping \ 7 | iproute2 \ 8 | --no-install-recommends && rm -r /var/lib/apt/lists/* 9 | 10 | ENV COMPOSER_ALLOW_SUPERUSER 1 11 | 12 | CMD ["./var/www/bin/start"] 13 | -------------------------------------------------------------------------------- /client/src/components/link.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | const Link = ( props ) => { 5 | const Tag = props.href ? 'a' : 'span'; 6 | 7 | return ( 8 | 9 | ); 10 | }; 11 | 12 | Link.propTypes = { 13 | href: PropTypes.string 14 | }; 15 | 16 | Link.defaultProps = { 17 | href: undefined 18 | }; 19 | 20 | export default Link; 21 | -------------------------------------------------------------------------------- /client/src/components/timeline/error-message.container.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { clearError } from 'app/actions/revisions'; 3 | import ErrorMessage from './error-message'; 4 | 5 | export default connect( 6 | state => ( { 7 | error: state.revisions.error 8 | } ), 9 | dispatch => ( { 10 | clearError: () => dispatch( clearError() ) 11 | } ) 12 | )( ErrorMessage ); 13 | -------------------------------------------------------------------------------- /client/src/reducers/index.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import { routerReducer } from 'react-router-redux'; 3 | import query from './query'; 4 | import wikis from './wikis'; 5 | import diffs from './diffs'; 6 | import revisions from './revisions'; 7 | 8 | export default combineReducers( { 9 | router: routerReducer, 10 | query, 11 | wikis, 12 | revisions, 13 | diffs 14 | } ); 15 | -------------------------------------------------------------------------------- /server/phpcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | . 7 | 8 | 9 | vendor/ 10 | 11 | -------------------------------------------------------------------------------- /client/src/components/timeline/user.container.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { getWiki } from 'app/selectors/wiki'; 3 | import makeGetSide from 'app/selectors/side'; 4 | import User from './user'; 5 | 6 | const getSide = makeGetSide(); 7 | 8 | export default connect( 9 | ( state, props ) => ( { 10 | wiki: getWiki( state ), 11 | side: getSide( state, props ) 12 | } ), 13 | )( User ); 14 | -------------------------------------------------------------------------------- /client/src/selectors/date.js: -------------------------------------------------------------------------------- 1 | import { createSelector } from 'reselect'; 2 | import moment from 'moment'; 3 | 4 | export const getStartDate = createSelector( 5 | state => state.query.startDate, 6 | ( startDate ) => startDate ? moment.unix( startDate ).utc() : null 7 | ); 8 | 9 | export const getEndDate = createSelector( 10 | state => state.query.endDate, 11 | ( endDate ) => endDate ? moment.unix( endDate ).utc() : null 12 | ); 13 | -------------------------------------------------------------------------------- /client/src/components/timeline/date-revisions.container.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { fetchRevisions } from 'app/actions/revisions'; 3 | import DateRevisions from './date-revisions'; 4 | 5 | export default connect( 6 | state => ( { 7 | empty: state.revisions.list.isEmpty(), 8 | status: state.revisions.status 9 | } ), 10 | dispatch => ( { 11 | fetchList: () => dispatch( fetchRevisions() ) 12 | } ), 13 | )( DateRevisions ); 14 | -------------------------------------------------------------------------------- /client/src/components/timeline/user-list.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Set } from 'immutable'; 4 | import UserContainer from './user.container'; 5 | 6 | const UserList = ( { users } ) => ( 7 | users.map( ( user ) => ( 8 | 9 | ) ).toArray() 10 | ); 11 | 12 | UserList.propTypes = { 13 | users: PropTypes.instanceOf( Set ).isRequired 14 | }; 15 | 16 | export default UserList; 17 | -------------------------------------------------------------------------------- /server/tests/Service/ConnectionServiceTest.php: -------------------------------------------------------------------------------- 1 | createMock( \Doctrine\DBAL\Configuration::class ); 10 | $service = new ConnectionService( [], $config ); 11 | 12 | $this->expectException( \Exception::class ); 13 | $service->getConnection(); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /client/src/components/timeline/spinner.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import 'spinkit/scss/spinners/7-three-bounce.scss'; 3 | 4 | const Spinner = () => ( 5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 | ); 13 | 14 | export default Spinner; 15 | -------------------------------------------------------------------------------- /client/src/entities/diff.js: -------------------------------------------------------------------------------- 1 | import { Record } from 'immutable'; 2 | import DiffMeta from './diff-meta'; 3 | 4 | export default class Diff extends Record( { 5 | body: undefined, 6 | fromuser: undefined, 7 | fromrevid: undefined, 8 | touser: undefined, 9 | torevid: undefined, 10 | meta: new DiffMeta() 11 | }, 'Diff' ) { 12 | constructor( data = {} ) { 13 | data = { 14 | ...data, 15 | meta: new DiffMeta( data.meta ) 16 | }; 17 | 18 | super( data ); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /client/src/components/timeline/diff/header.container.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { makeGetFromRevision, makeGetToRevision } from 'app/selectors/diff'; 3 | import Header from './header'; 4 | 5 | const getFromRevision = makeGetFromRevision(); 6 | const getToRevision = makeGetToRevision(); 7 | 8 | export default connect( 9 | ( state, props ) => ( { 10 | from: getFromRevision( state, props ), 11 | to: getToRevision( state, props ) 12 | } ) 13 | )( Header ); 14 | -------------------------------------------------------------------------------- /client/src/components/fields/select-users.container.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { OrderedSet } from 'immutable'; 3 | import { userChange } from 'app/actions/query'; 4 | import getUsers from 'app/selectors/users'; 5 | import SelectUsers from './select-users'; 6 | 7 | export default connect( 8 | state => ( { 9 | value: getUsers( state ) 10 | } ), 11 | dispatch => ( { 12 | onChange: value => dispatch( userChange( new OrderedSet( value ) ) ) 13 | } ), 14 | )( SelectUsers ); 15 | -------------------------------------------------------------------------------- /i18n/bs.json: -------------------------------------------------------------------------------- 1 | { 2 | "@metadata": { 3 | "authors": [ 4 | "Srdjan m", 5 | "Srđan" 6 | ] 7 | }, 8 | "app-title": "Hronologija interakcije", 9 | "field-label-end-date": "Završni datum", 10 | "field-label-start-date": "Početni datum", 11 | "field-label-users": "Korisnici", 12 | "field-label-wiki": "Wiki", 13 | "field-select-placeholder": "Izaberite...", 14 | "field-select-no-results": "Nema rezultata", 15 | "report-bug": "Prijavi grešku", 16 | "view-source-code-on": "Vidi na $1" 17 | } 18 | -------------------------------------------------------------------------------- /toolinfo.json: -------------------------------------------------------------------------------- 1 | { 2 | "name" : "interaction-timeline", 3 | "title" : "Interaction Timeline", 4 | "description" : "Shows a chronological history for two users on pages where they have both made edits.", 5 | "url" : "https://meta.wikimedia.org/wiki/Community_health_initiative/Interaction_Timeline", 6 | "keywords" : "anti-harassment, javascript, php, wikipedia", 7 | "author" : "David Barratt, Dayllan Maza", 8 | "repository" : "https://github.com/wikimedia/InteractionTimeline" 9 | } 10 | -------------------------------------------------------------------------------- /i18n/mnw.json: -------------------------------------------------------------------------------- 1 | { 2 | "@metadata": { 3 | "authors": [ 4 | "Aue Nai", 5 | "咽頭べさ" 6 | ] 7 | }, 8 | "app-feedback-link": "ရီု", 9 | "back-top": "ကလေင် တိုန် နူစ", 10 | "error": "ဗၠေတ်", 11 | "field-label-end-date": "တ္ၚဲတုဲဒှ်", 12 | "field-label-start-date": "တ္ၚဲစပ္တမ်", 13 | "field-label-users": "ညးလွပ်", 14 | "field-label-wiki": "ဝဳကဳ", 15 | "field-select-placeholder": "ရုဲကေတ်", 16 | "field-select-no-results": "အရာမဂၠာဲဂှ် မုဟွံဂွံ ဆဵု", 17 | "discuss-on-wiki-copy": "စၠောအ်ကပ်ပဳ" 18 | } 19 | -------------------------------------------------------------------------------- /i18n/azb.json: -------------------------------------------------------------------------------- 1 | { 2 | "@metadata": { 3 | "authors": [ 4 | "Alp Er Tunqa" 5 | ] 6 | }, 7 | "back-top": "اوسته قاییت", 8 | "error": "خطا", 9 | "field-label-end-date": "قورتارماق تاریخی", 10 | "field-label-start-date": "باشلانماق تاریخی", 11 | "field-label-users": "ایشلدنلر", 12 | "field-label-wiki": "ویکی", 13 | "field-select-placeholder": "سئچین...", 14 | "field-select-no-results": "هئچ بیر نتیجه تاپیلمادی", 15 | "privacy-policy": "گیزلیلیک سیاستی", 16 | "warning-no-results": "نتیجه یوخ‌دور" 17 | } 18 | -------------------------------------------------------------------------------- /i18n/ms-arab.json: -------------------------------------------------------------------------------- 1 | { 2 | "@metadata": { 3 | "authors": [ 4 | "Tofeiku" 5 | ] 6 | }, 7 | "app-feedback-link": "بنتوان", 8 | "back-top": "کمبالي کأتس", 9 | "error": "رالت", 10 | "field-label-end-date": "تاريخ تمت", 11 | "field-label-start-date": "تاريخ مولا", 12 | "field-label-users": "ڤڠݢونا", 13 | "field-label-wiki": "ويکي", 14 | "field-select-placeholder": "ڤيليه...", 15 | "privacy-policy": "داسر ڤريۏاسي", 16 | "discuss-on-wiki-copy": "سالين", 17 | "discuss-on-wiki-copied": "دسالين!" 18 | } 19 | -------------------------------------------------------------------------------- /.env.dist: -------------------------------------------------------------------------------- 1 | DEBUG=true 2 | 3 | # Use DB_HOST when connecting to the replicas through an SSH tunnel. 4 | # Leave as host.docker.internal if the tunnel is running on the host machine. 5 | DB_HOST=host.docker.internal 6 | 7 | # Use DB_CLUSTER when connecting directly to the replicas. The wiki id will be 8 | # prepended to the DB_CLUSTER. 9 | # DB_CLUSTER=web.db.svc.eqiad.wmflabs 10 | 11 | DB_PORT=3306 12 | DB_USER= 13 | DB_PASS= 14 | 15 | REDIS_HOST=redis 16 | REDIS_PORT=6379 17 | REDIS_KEY_PREFIX=interaction-timeline 18 | -------------------------------------------------------------------------------- /client/src/selectors/wiki.js: -------------------------------------------------------------------------------- 1 | import { createSelector } from 'reselect'; 2 | import { Map } from 'immutable'; 3 | 4 | export const getWikiOptions = createSelector( 5 | state => state.wikis, 6 | ( wikis = new Map() ) => { 7 | return wikis.map( ( wiki ) => ( { 8 | value: wiki.id, 9 | label: `${wiki.domain}` 10 | } ) ).toArray(); 11 | } 12 | ); 13 | 14 | export const getWiki = createSelector( 15 | state => state.wikis, 16 | state => state.query.wiki, 17 | ( wikis = new Map(), id = '' ) => wikis.get( id ) 18 | ); 19 | -------------------------------------------------------------------------------- /server/src/Service/ConnectionServiceInterface.php: -------------------------------------------------------------------------------- 1 | { 5 | const subject = new Subject(); 6 | 7 | const callback = ( entries ) => { 8 | entries.forEach( entry => { 9 | subject.next( entry ); 10 | } ); 11 | }; 12 | 13 | const observer = new IntersectionObserver( callback, options ); 14 | 15 | observer.observe( element ); 16 | 17 | return subject; 18 | }; 19 | 20 | export default createIntersectionObservable; 21 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | web: 4 | build: ./ 5 | env_file: 6 | - .env 7 | ports: 8 | - 8888:80 9 | volumes: 10 | - ./etc/lighttpd/lighttpd.conf:/etc/lighttpd/lighttpd.conf:cached 11 | - ./:/var/www:cached 12 | links: 13 | - redis 14 | watch: 15 | image: docker-registry.tools.wmflabs.org/toolforge-node10-sssd-web 16 | working_dir: /code/client 17 | command: 18 | - ./bin/watch 19 | volumes: 20 | - ./:/code:cached 21 | redis: 22 | image: redis 23 | -------------------------------------------------------------------------------- /i18n/hy.json: -------------------------------------------------------------------------------- 1 | { 2 | "@metadata": { 3 | "authors": [ 4 | "Kareyac" 5 | ] 6 | }, 7 | "app-feedback-link": "Օգնություն", 8 | "error": "Սխալ", 9 | "error-help-refresh": "թարմացնել էջը", 10 | "field-label-users": "Մասնակիցներ", 11 | "field-label-wiki": "Վիքի", 12 | "field-select-placeholder": "Ընտրեք...", 13 | "field-select-no-results": "Ոչինչ չի գտնվել", 14 | "privacy-policy": "Գաղտնիության քաղաքականություն", 15 | "warning-no-results": "Արդյունք չկա", 16 | "discuss-on-wiki": "Կիսվել այս արդյունքներով", 17 | "discuss-on-wiki-copy": "Պատճենել" 18 | } 19 | -------------------------------------------------------------------------------- /client/src/utils/location-query.test.js: -------------------------------------------------------------------------------- 1 | import Query from 'app/entities/query'; 2 | import locationQuery from './location-query'; 3 | 4 | test( 'will return a Query object', () => { 5 | const wiki = 'testwiki'; 6 | const location = { 7 | search: `?wiki=${wiki}` 8 | }; 9 | 10 | let query; 11 | 12 | query = locationQuery( location ); 13 | expect( query ).toBeInstanceOf( Query ); 14 | expect( query.wiki ).toEqual( wiki ); 15 | 16 | query = locationQuery( {} ); 17 | expect( query ).toBeInstanceOf( Query ); 18 | expect( query.wiki ).toEqual( undefined ); 19 | } ); 20 | -------------------------------------------------------------------------------- /i18n/kn.json: -------------------------------------------------------------------------------- 1 | { 2 | "@metadata": { 3 | "authors": [ 4 | "ಮಲ್ನಾಡಾಚ್ ಕೊಂಕ್ಣೊ" 5 | ] 6 | }, 7 | "app-feedback-link": "ಸಹಾಯ", 8 | "error": "ದೋಷ", 9 | "error-help-report": "ದೋಷವನ್ನು ವರದಿ ಮಾಡಿ", 10 | "field-label-end-date": "ಮುಕ್ತಾಯದ ದಿನಾಂಕ", 11 | "field-label-start-date": "ಆರಂಭದ ದಿನಾಂಕ", 12 | "field-label-users": "ಬಳಕೆದಾರರು", 13 | "field-label-wiki": "ವಿಕಿ", 14 | "field-select-no-results": "ಯಾವುದೇ ಫಲಿತಾಂಶಗಳಿಲ್ಲ", 15 | "privacy-policy": "ಗೌಪ್ಯತಾ ನೀತಿ", 16 | "revision-edit-summary-removed": "ಸಂಪಾದನೆಯ ಸಾರಾಂಶ ತೆಗೆದುಹಾಕಲಾಗಿದೆ", 17 | "view-source-code-on": "$1 ನಲ್ಲಿ ನೋಡಿ" 18 | } 19 | -------------------------------------------------------------------------------- /server/phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | ./tests/ 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /server/src/Dao/UserDao.php: -------------------------------------------------------------------------------- 1 | conn->createQueryBuilder(); 14 | $query->select( 'user_id' ) 15 | ->from( 'user' ) 16 | ->where( 'user_name = :user' ) 17 | ->setParameter( ':user', $username, \PDO::PARAM_STR ); 18 | 19 | $stmt = $query->execute(); 20 | $userId = $stmt->fetchColumn(); 21 | 22 | return $userId; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /client/src/components/timeline/revision/timelapse.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Message } from '@wikimedia/react.i18n'; 4 | 5 | const TimelineTimelapse = ( { date, samePage } ) => ( 6 |
7 | 8 |
9 | ); 10 | 11 | TimelineTimelapse.propTypes = { 12 | date: PropTypes.string.isRequired, 13 | samePage: PropTypes.bool.isRequired 14 | }; 15 | 16 | export default TimelineTimelapse; 17 | -------------------------------------------------------------------------------- /bin/start: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Set the host domain on Linux. 4 | # see https://github.com/docker/for-linux/issues/264 5 | HOST_DOMAIN="host.docker.internal"; 6 | ping -q -c1 $HOST_DOMAIN > /dev/null 2>&1; 7 | if [ $? -ne 0 ]; then 8 | HOST_IP=$(ip route | awk 'NR==1 {print $3}'); 9 | echo "$HOST_IP\t$HOST_DOMAIN" >> /etc/hosts; 10 | fi 11 | 12 | # Install Dependencies and start Lighttpd 13 | composer install -d /var/www/server || exit 1; 14 | mkdir -p /var/www/html/api || exit 1; 15 | ln -sf ../../server/public/index.php /var/www/html/api/index.php || exit 1; 16 | lighttpd -D -f /etc/lighttpd/lighttpd.conf || exit 1; 17 | -------------------------------------------------------------------------------- /client/src/components/timeline/date.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import 'material-design-icons/iconfont/material-icons.css'; 4 | 5 | const TimelineDate = ( { date } ) => ( 6 |
7 |
8 |
9 | {date} 10 |
11 |
12 |
13 | ); 14 | 15 | TimelineDate.propTypes = { 16 | date: PropTypes.string.isRequired 17 | }; 18 | 19 | export default TimelineDate; 20 | -------------------------------------------------------------------------------- /client/src/selectors/side.js: -------------------------------------------------------------------------------- 1 | import { createSelector } from 'reselect'; 2 | 3 | const makeGetSide = () => ( 4 | createSelector( 5 | state => state.query.user, 6 | ( _, props ) => { 7 | if ( props.user ) { 8 | return props.user; 9 | } 10 | 11 | if ( props.revision ) { 12 | return props.revision.user; 13 | } 14 | }, 15 | ( users, user ) => { 16 | let side; 17 | 18 | if ( user === users.first() ) { 19 | side = 'left'; 20 | } else if ( user === users.last() ) { 21 | side = 'right'; 22 | } 23 | 24 | return side; 25 | } 26 | ) 27 | ); 28 | 29 | export default makeGetSide; 30 | -------------------------------------------------------------------------------- /i18n/skr-arab.json: -------------------------------------------------------------------------------- 1 | { 2 | "@metadata": { 3 | "authors": [ 4 | "Saraiki" 5 | ] 6 | }, 7 | "app-feedback-link": "مدد", 8 | "error": "خرابی", 9 | "error-help": "$1، تے ول $2۔", 10 | "field-label-end-date": "ختم کرݨ دی تریخ", 11 | "field-label-start-date": "شروع کرݨ دی تریخ", 12 | "field-label-users": "ورتݨ آلے", 13 | "field-label-wiki": "وکی", 14 | "field-select-placeholder": "چُݨو۔۔۔", 15 | "field-select-search-prompt": "ڳولݨ کیتے لکھو", 16 | "view-source-code-on": "$1 تے ݙیکھو", 17 | "warning-no-results": "کوئی نتیجہ کائنی۔", 18 | "discuss-on-wiki-copy": "نقل کرو", 19 | "discuss-on-wiki-copied": "نقل تھی ڳئے!" 20 | } 21 | -------------------------------------------------------------------------------- /client/src/components/timeline/revision/title.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import REGEX_EDIT_SUMMARY_PARTS from './edit-summary-parts'; 3 | 4 | const Title = ( { title, comment } ) => { 5 | if ( !comment ) { 6 | return title; 7 | } 8 | 9 | const matches = comment.match( REGEX_EDIT_SUMMARY_PARTS ); 10 | 11 | if ( matches[ 1 ] ) { 12 | return title + ' § ' + matches[ 1 ].trim(); 13 | } 14 | 15 | return title; 16 | }; 17 | 18 | Title.propTypes = { 19 | title: PropTypes.string.isRequired, 20 | comment: PropTypes.string 21 | }; 22 | 23 | Title.defaultProps = { 24 | comment: undefined 25 | }; 26 | 27 | export default Title; 28 | -------------------------------------------------------------------------------- /client/src/components/fields/select-wiki.container.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { fetchWikiList } from 'app/actions/wiki'; 3 | import { getWikiOptions } from 'app/selectors/wiki'; 4 | import { wikiChange } from 'app/actions/query'; 5 | import SelectWiki from './select-wiki'; 6 | 7 | export default connect( 8 | state => ( { 9 | isLoading: !state.wikis.size, 10 | value: state.query.wiki, 11 | options: getWikiOptions( state ) 12 | } ), 13 | dispatch => ( { 14 | onChange: value => dispatch( wikiChange( value ? value.value : undefined ) ), 15 | fetchOptions: () => dispatch( fetchWikiList() ) 16 | } ), 17 | )( SelectWiki ); 18 | -------------------------------------------------------------------------------- /client/src/entities/revision.js: -------------------------------------------------------------------------------- 1 | import { Record } from 'immutable'; 2 | import moment from 'moment'; 3 | import RevisionMeta from './revision-meta'; 4 | 5 | export default class Revision extends Record( { 6 | id: undefined, 7 | pageId: undefined, 8 | pageNamespace: undefined, 9 | title: undefined, 10 | user: undefined, 11 | timestamp: moment(), 12 | minor: false, 13 | sizeDiff: 0, 14 | comment: undefined, 15 | commentHidden: false, 16 | suppressed: false, 17 | meta: new RevisionMeta() 18 | }, 'Revision' ) { 19 | constructor( data = {} ) { 20 | data = { 21 | ...data, 22 | meta: new RevisionMeta( data.meta ) 23 | }; 24 | 25 | super( data ); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /client/src/components/timeline/diff/header-revision.container.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { fetchRevision } from 'app/actions/revisions'; 3 | import { makeGetRevisionUrl } from 'app/selectors/revisions'; 4 | import makeGetSide from 'app/selectors/side'; 5 | import HeaderRevision from './header-revision'; 6 | 7 | const getSide = makeGetSide(); 8 | const getRevisionUrl = makeGetRevisionUrl(); 9 | 10 | export default connect( 11 | ( state, props ) => ( { 12 | side: getSide( state, props ), 13 | url: getRevisionUrl( state, props ) 14 | } ), 15 | dispatch => ( { 16 | fetchRevision: id => dispatch( fetchRevision( id ) ) 17 | } ), 18 | )( HeaderRevision ); 19 | -------------------------------------------------------------------------------- /client/src/epics/index.js: -------------------------------------------------------------------------------- 1 | import { combineEpics } from 'redux-observable'; 2 | import { pushQueryToLocation, pushLocationToQuery, setDefaultQueryOnLoad } from './query'; 3 | import { fetchAllWikis, fetchWikiNamespaces } from './wiki'; 4 | import { 5 | shouldFetchRevisions, 6 | revisionsReady, 7 | fetchRevision, 8 | doFetchRevisions 9 | } from './revisions'; 10 | import { fetchDiff } from './diff'; 11 | 12 | export default combineEpics( 13 | pushQueryToLocation, 14 | pushLocationToQuery, 15 | fetchAllWikis, 16 | fetchWikiNamespaces, 17 | shouldFetchRevisions, 18 | revisionsReady, 19 | fetchRevision, 20 | doFetchRevisions, 21 | fetchDiff, 22 | setDefaultQueryOnLoad 23 | ); 24 | -------------------------------------------------------------------------------- /client/src/selectors/status.js: -------------------------------------------------------------------------------- 1 | import { createSelector } from 'reselect'; 2 | 3 | export const getStatus = createSelector( 4 | state => state.revisions.status, 5 | state => state.query.user, 6 | state => state.query.wiki, 7 | state => state.revisions.list, 8 | ( status, users, wiki, revisions ) => { 9 | if ( status === 'done' && revisions.isEmpty() ) { 10 | return 'noresults'; 11 | } else if ( status === 'notready' ) { 12 | if ( wiki && users.count() < 2 ) { 13 | return 'nousers'; 14 | } 15 | 16 | if ( !wiki && users.count() >= 2 ) { 17 | return 'nowiki'; 18 | } 19 | } 20 | 21 | return status; 22 | } 23 | ); 24 | 25 | export default getStatus; 26 | -------------------------------------------------------------------------------- /client/src/components/timeline/revision/byte-change.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | const ByteChange = ( { sizeDiff, minor } ) => { 5 | let size = '(' + ( sizeDiff > 0 ? '+' : '' ) + sizeDiff + ')'; 6 | 7 | if ( sizeDiff > 500 || sizeDiff < -500 ) { 8 | size = {size}; 9 | } 10 | 11 | let displayMinor; 12 | 13 | if ( minor ) { 14 | displayMinor = m; 15 | } 16 | 17 | return ( 18 | {displayMinor} {size} 19 | ); 20 | }; 21 | 22 | ByteChange.propTypes = { 23 | sizeDiff: PropTypes.number.isRequired, 24 | minor: PropTypes.bool.isRequired 25 | }; 26 | 27 | export default ByteChange; 28 | -------------------------------------------------------------------------------- /client/src/components/fields/date-range.container.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { startDateChange, endDateChange } from 'app/actions/query'; 3 | import { getStartDate, getEndDate } from 'app/selectors/date'; 4 | import DateRange from './date-range'; 5 | 6 | export default connect( 7 | state => ( { 8 | startDate: getStartDate( state ), 9 | endDate: getEndDate( state ) 10 | } ), 11 | dispatch => ( { 12 | onStartDateChange: value => dispatch( startDateChange( value ? value.utc().unix().toString() : undefined ) ), 13 | onEndDateChange: value => dispatch( endDateChange( value ? value.utc().endOf( 'day' ).unix().toString() : undefined ) ) 14 | } ), 15 | )( DateRange ); 16 | -------------------------------------------------------------------------------- /client/src/components/timeline/diff/header.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import RevisionEntity from 'app/entities/revision'; 4 | import RevisionHeaderContainer from './header-revision.container'; 5 | 6 | const Header = ( { from, to } ) => { 7 | return ( 8 | 9 | 10 | 11 | 12 | ); 13 | }; 14 | 15 | Header.propTypes = { 16 | from: PropTypes.instanceOf( RevisionEntity ), 17 | to: PropTypes.instanceOf( RevisionEntity ) 18 | }; 19 | 20 | Header.defaultProps = { 21 | from: undefined, 22 | to: undefined 23 | }; 24 | 25 | export default Header; 26 | -------------------------------------------------------------------------------- /client/src/components/share/share.container.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { getStartDate, getEndDate } from 'app/selectors/date.js'; 3 | import getUsers from 'app/selectors/users.js'; 4 | import getEditorInteractUrl from 'app/selectors/editorinteract'; 5 | import { getWiki } from 'app/selectors/wiki.js'; 6 | import Share from './share'; 7 | 8 | export default connect( 9 | state => ( { 10 | empty: state.revisions.list.isEmpty(), 11 | queryString: state.router.location.search, 12 | startDate: getStartDate( state ), 13 | endDate: getEndDate( state ), 14 | users: getUsers( state ), 15 | wiki: getWiki( state ), 16 | editorInteractUrl: getEditorInteractUrl( state ) 17 | } ) 18 | )( Share ); 19 | -------------------------------------------------------------------------------- /client/src/actions/diff.js: -------------------------------------------------------------------------------- 1 | export function throwDiffError( diff, error ) { 2 | return { 3 | type: 'DIFFS_THROW_ERROR', 4 | diff, 5 | error 6 | }; 7 | } 8 | 9 | export function setDiff( diff ) { 10 | return { 11 | type: 'DIFFS_SET', 12 | diff 13 | }; 14 | } 15 | 16 | export function setDiffShow( diff, show, suppressed = false ) { 17 | return { 18 | type: 'DIFFS_SHOW_SET', 19 | diff, 20 | show, 21 | suppressed 22 | }; 23 | } 24 | 25 | export function toggleDiff( diff, suppressed = false ) { 26 | return setDiffShow( diff, !diff.meta.show, suppressed ); 27 | } 28 | 29 | export function setDiffStatus( diff, status ) { 30 | return { 31 | type: 'DIFFS_STATUS_SET', 32 | diff, 33 | status 34 | }; 35 | } 36 | -------------------------------------------------------------------------------- /i18n/hif-latn.json: -------------------------------------------------------------------------------- 1 | { 2 | "@metadata": { 3 | "authors": [ 4 | "Thakurji" 5 | ] 6 | }, 7 | "app-feedback-link": "Madat", 8 | "error": "Galti", 9 | "error-help": "$1, aur fir $2.", 10 | "error-help-report": "Error ke report karo", 11 | "error-help-refresh": "panna ke refresh karo", 12 | "error-message-request-url": "URL ke request karo", 13 | "field-label-end-date": "Khalaas waala taarik", 14 | "field-label-start-date": "Suruu waala taarik", 15 | "field-label-users": "Sadasya", 16 | "field-label-wiki": "Wiki", 17 | "field-select-placeholder": "Select karo...", 18 | "field-select-no-results": "Koi result nai milaa", 19 | "discuss-on-wiki-copy": "Copy karo", 20 | "discuss-on-wiki-copied": "Copy kar lewa gais hai!" 21 | } 22 | -------------------------------------------------------------------------------- /i18n/smn.json: -------------------------------------------------------------------------------- 1 | { 2 | "@metadata": { 3 | "authors": [ 4 | "Muotâ", 5 | "Seipinne", 6 | "Yupik" 7 | ] 8 | }, 9 | "app-feedback-link": "Iše", 10 | "back-top": "Maassâd pajas", 11 | "error": "Feilâ", 12 | "error-help-report": "Almoot feeilâ", 13 | "field-label-end-date": "Nuuhâmpeivi", 14 | "field-label-start-date": "Älgimpeivi", 15 | "field-label-users": "Kevtteeh", 16 | "field-label-wiki": "Wiki", 17 | "field-select-placeholder": "Valjii...", 18 | "field-select-no-results": "Iä uuccâmpuátuseh", 19 | "powered-by": "Falâlduv taha máhđulâžžân Wikimedia Toolforge", 20 | "view-source-code-on": "Čääiti $1ist", 21 | "warning-no-results": "Iä puátuseh", 22 | "discuss-on-wiki-copy": "Kopijist", 23 | "discuss-on-wiki-copied": "Kopijistum!" 24 | } 25 | -------------------------------------------------------------------------------- /client/src/selectors/editorinteract.js: -------------------------------------------------------------------------------- 1 | import { createSelector } from 'reselect'; 2 | import qs from 'querystring'; 3 | import getUsers from './users'; 4 | import { getStartDate, getEndDate } from './date'; 5 | 6 | const getEditorInteractUrl = createSelector( 7 | ( state ) => state.query.wiki, 8 | getUsers, 9 | getStartDate, 10 | getEndDate, 11 | ( server, users, start, end ) => { 12 | const data = { 13 | server, 14 | users 15 | }; 16 | 17 | if ( start ) { 18 | data.startDate = start.format( 'YYYYMMDD' ); 19 | } 20 | 21 | if ( end ) { 22 | data.endDate = end.format( 'YYYYMMDD' ); 23 | } 24 | 25 | return 'https://tools.wmflabs.org/sigma/editorinteract.py?' + qs.stringify( data ); 26 | } 27 | ); 28 | 29 | export default getEditorInteractUrl; 30 | -------------------------------------------------------------------------------- /client/src/reducers/query.js: -------------------------------------------------------------------------------- 1 | import Query from 'app/entities/query'; 2 | 3 | export default ( state = new Query(), action ) => { 4 | switch ( action.type ) { 5 | case 'QUERY_SET_DEFAULT': 6 | case 'QUERY_UPDATE': 7 | let query = action.query; 8 | 9 | if ( query.user ) { 10 | query = query.set( 'user', query.user.slice( 0, 2 ) ); 11 | } 12 | 13 | return query; 14 | case 'QUERY_USER_CHANGE': 15 | return state.set( 'user', action.users ); 16 | case 'QUERY_WIKI_CHANGE': 17 | return state.set( 'wiki', action.wiki ); 18 | case 'QUERY_START_DATE_CHANGE': 19 | return state.set( 'startDate', action.startDate ); 20 | case 'QUERY_END_DATE_CHANGE': 21 | return state.set( 'endDate', action.endDate ); 22 | default: 23 | return state; 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /client/src/reducers/revisions/status.js: -------------------------------------------------------------------------------- 1 | export default ( state = 'notready', action ) => { 2 | switch ( action.type ) { 3 | case 'REVISIONS_FETCHING': 4 | return 'fetching'; 5 | case 'REVISIONS_READY': 6 | return 'ready'; 7 | case 'QUERY_WIKI_CHANGE': 8 | if ( !action.wiki ) { 9 | return 'notready'; 10 | } 11 | 12 | return state; 13 | case 'QUERY_USER_CHANGE': 14 | if ( action.users.count() < 2 ) { 15 | return 'notready'; 16 | } 17 | 18 | return state; 19 | case 'REVISIONS_NOT_READY': 20 | return 'notready'; 21 | case 'REVISIONS_DONE': 22 | return 'done'; 23 | case 'REVISIONS_ERROR': 24 | return 'error'; 25 | case 'REVISIONS_ADD': 26 | return action.cont === false ? 'done' : 'ready'; 27 | default: 28 | return state; 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /i18n/sms.json: -------------------------------------------------------------------------------- 1 | { 2 | "@metadata": { 3 | "authors": [ 4 | "Yupik" 5 | ] 6 | }, 7 | "back-top": "Mååust seeid aʹlǧǧe", 8 | "error": "Vââʹǩǩ", 9 | "error-help": "$1, teä $2.", 10 | "error-help-report": "Iʹlmmet čuõlmâst", 11 | "error-help-refresh": "peiʹvved seeid", 12 | "licensed-under": "Lisenssiõsttum liseeʹnsin $1", 13 | "field-label-start-date": "Äʹlǧǧempeiʹvv", 14 | "field-label-users": "Õõʹnni", 15 | "field-select-placeholder": "Vaʹlljed...", 16 | "field-select-no-results": "Ij käunnʼjam ni mii", 17 | "privacy-policy": "Teâttsuejjčiõʹlǧǧõs", 18 | "powered-by": "Kääzzkõõzz vueiʹtlvâstt Wikimedia Toolforge", 19 | "report-bug": "Iʹlmmet čuõlmâst", 20 | "revision-edit-summary-removed": "õʹhtteǩeässmõš lij jaukkuum", 21 | "warning-no-results": "Ij käunnʼjam ni mii", 22 | "discuss-on-wiki-copy": "Kopiââʹst" 23 | } 24 | -------------------------------------------------------------------------------- /client/src/selectors/editorinteract.test.js: -------------------------------------------------------------------------------- 1 | import { createSelector } from 'reselect'; 2 | import moment from 'moment'; 3 | import './editorinteract'; 4 | 5 | jest.mock( 'reselect' ); 6 | jest.mock( './users' ); 7 | jest.mock( './date' ); 8 | 9 | // The last argument of the last call is the function to test. 10 | const call = createSelector.mock.calls[ createSelector.mock.calls.length - 1 ]; 11 | const getEditorInteractUrl = call[ call.length - 1 ]; 12 | 13 | test( 'returns an editorinteract url', () => { 14 | const url = getEditorInteractUrl( 'testwiki', [ 'Derby pie', 'Sweets lover' ], moment.utc( '1970-01-01T00:00' ), moment.utc( '1999-12-31T23:59' ) ); 15 | expect( url ).toEqual( 'https://tools.wmflabs.org/sigma/editorinteract.py?server=testwiki&users=Derby%20pie&users=Sweets%20lover&startDate=19700101&endDate=19991231' ); 16 | } ); 17 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: push 4 | 5 | jobs: 6 | server: 7 | runs-on: ubuntu-latest 8 | container: 9 | image: docker-registry.tools.wmflabs.org/toolforge-php72-sssd-web 10 | env: 11 | COMPOSER_ALLOW_SUPERUSER: 1 12 | steps: 13 | - uses: actions/checkout@v1 14 | - run: 'composer install' 15 | working-directory: server 16 | - run: 'composer test' 17 | working-directory: server 18 | client: 19 | runs-on: ubuntu-latest 20 | container: 21 | image: docker-registry.tools.wmflabs.org/toolforge-node10-sssd-web 22 | defaults: 23 | run: 24 | working-directory: client 25 | steps: 26 | - uses: actions/checkout@v1 27 | - run: npm ci --verbose --unsafe-perm 28 | - run: npm run build --if-present 29 | - run: npm test 30 | -------------------------------------------------------------------------------- /server/src/settings.php: -------------------------------------------------------------------------------- 1 | load(); 5 | 6 | return [ 7 | 'settings' => [ 8 | // slim 9 | 'displayErrorDetails' => getenv( 'DEBUG' ), 10 | 'determineRouteBeforeAppMiddleware' => true, 11 | 'db' => [ 12 | 'host' => getenv( 'DB_HOST' ), 13 | 'cluster' => getenv( 'DB_CLUSTER' ), 14 | 'user' => getenv( 'DB_USER' ), 15 | 'pass' => getenv( 'DB_PASS' ), 16 | 'port' => getenv( 'DB_PORT' ), 17 | ], 18 | 'redis' => [ 19 | 'host' => getenv( 'REDIS_HOST' ), 20 | 'port' => getenv( 'REDIS_PORT' ), 21 | 'prefix' => getenv( 'REDIS_KEY_PREFIX' ), 22 | ], 23 | 24 | // monolog settings 25 | 'logger' => [ 26 | 'name' => 'app', 27 | 'path' => getenv( 'LOGGER_PATH' ) ?: 'php://stderr', 28 | 'level' => Monolog\Logger::DEBUG 29 | ], 30 | ], 31 | ]; 32 | -------------------------------------------------------------------------------- /client/src/components/timeline/alert.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import 'material-design-icons/iconfont/material-icons.css'; 4 | 5 | const Alert = ( { type, children } ) => { 6 | let icon = type; 7 | let color = type; 8 | 9 | if ( type === 'error' ) { 10 | color = 'danger'; 11 | } 12 | 13 | return ( 14 |
15 |
16 |
17 | {icon} 18 |
19 |
20 | {children} 21 |
22 |
23 |
24 | ); 25 | }; 26 | 27 | Alert.propTypes = { 28 | type: PropTypes.oneOf( [ 'warning', 'error', 'info' ] ).isRequired, 29 | children: PropTypes.node.isRequired 30 | }; 31 | 32 | export default Alert; 33 | -------------------------------------------------------------------------------- /i18n/it.json: -------------------------------------------------------------------------------- 1 | { 2 | "@metadata": { 3 | "authors": [ 4 | "Beta16" 5 | ] 6 | }, 7 | "app-feedback-link": "Aiuto", 8 | "back-top": "Torna all'inizio", 9 | "between-edits": "$1 tra le modifiche", 10 | "error": "Errore", 11 | "error-help-refresh": "aggiorna la pagina", 12 | "field-label-end-date": "Data fine", 13 | "field-label-start-date": "Data inizio", 14 | "field-label-users": "Utenti", 15 | "field-label-wiki": "Wiki", 16 | "field-select-placeholder": "Seleziona...", 17 | "field-select-no-results": "Nessun risultato trovato", 18 | "privacy-policy": "Informativa sulla privacy", 19 | "report-bug": "Segnala un errore", 20 | "view-source-code-on": "Vedi su $1", 21 | "warning-no-results": "Nessun risultato", 22 | "discuss-on-wiki": "Condividi questi risultati", 23 | "discuss-on-wiki-copy": "Copia", 24 | "discuss-on-wiki-copied": "Copiato!" 25 | } 26 | -------------------------------------------------------------------------------- /client/src/components/fields/select-wiki.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import Select from 'react-select'; 4 | import { Message } from '@wikimedia/react.i18n'; 5 | import 'react-select/dist/react-select.css'; 6 | 7 | class SelectWiki extends React.Component { 8 | 9 | componentDidMount() { 10 | this.props.fetchOptions(); 11 | } 12 | 13 | render() { 14 | return ( 15 | 16 |