#103 [incl][pl] słownik języka inkluzywnego - kod

This commit is contained in:
Avris 2020-11-17 19:21:49 +01:00
parent 9d29137def
commit 3874163d63
12 changed files with 584 additions and 18 deletions

View File

@ -0,0 +1,256 @@
<template>
<Loading :value="entriesRaw">
<section v-if="$admin()" class="px-3">
<div class="alert alert-info">
<strong>{{ entriesCountApproved() }}</strong> <T>nouns.approved</T>,
<strong>{{ entriesCountPending() }}</strong> <T>nouns.pending</T>.
</div>
</section>
<section class="sticky-top">
<div class="input-group mb-3 bg-white">
<div class="input-group-prepend">
<span class="input-group-text">
<Icon v="filter"/>
</span>
</div>
<input class="form-control border-primary" v-model="filter" :placeholder="$t('crud.filterLong')" ref="filter"/>
<div class="input-group-append" v-if="filter">
<button class="btn btn-outline-danger" @click="filter = ''; $refs.filter.focus()">
<Icon v="times"/>
</button>
</div>
<div class="input-group-append">
<button class="btn btn-outline-success" @click="$refs.form.$el.scrollIntoView()">
<Icon v="plus-circle"/>
<T>nouns.submit.action</T>
</button>
</div>
</div>
</section>
<Table :data="visibleEntries()" :columns="$admin() ? 4 : 3" :marked="(el) => !el.approved" fixed ref="dictionarytable">
<template v-slot:header>
<th class="text-nowrap">
<Icon v="comment-times"/>
<T>nouns.inclusive.insteadOf</T>
</th>
<th class="text-nowrap">
<Icon v="comment-check"/>
<T>nouns.inclusive.say</T>
</th>
<th class="text-nowrap">
<Icon v="comment-dots"/>
<T>nouns.inclusive.because</T>
</th>
<th v-if="$admin()"></th>
</template>
<template v-slot:row="s"><template v-if="s">
<td>
<ul class="list-untyled">
<li v-for="w in s.el.insteadOf">{{w}}</li>
</ul>
<small v-if="s.el.base && entries[s.el.base]">
<p><strong><T>nouns.edited</T>:</strong></p>
<ul class="list-untyled">
<li v-for="w in entries[s.el.base].insteadOf">{{w}}</li>
</ul>
</small>
<button v-if="!$admin()" class="btn btn-outline-primary btn-sm m-1 hover-show" @click="edit(s.el)">
<Icon v="pen"/>
<T>nouns.edit</T>
</button>
</td>
<td>
<ul class="list-untyled">
<li v-for="w in s.el.say">{{w}}</li>
</ul>
<small v-if="s.el.base && entries[s.el.base]">
<p><strong><T>nouns.edited</T>:</strong></p>
<ul class="list-untyled">
<li v-for="w in entries[s.el.base].say">{{w}}</li>
</ul>
</small>
</td>
<td>
<p v-for="p in s.el.because.split('\n\n')">{{p}}</p>
<small v-if="s.el.base && entries[s.el.base]">
<p><strong><T>nouns.edited</T>:</strong></p>
<ul class="list-untyled">
<p v-for="p in entries[s.el.base].because.split('\n\n')">{{p}}</p>
</ul>
</small>
</td>
<td v-if="$admin()">
<ul class="list-unstyled">
<li v-if="!s.el.approved">
<button class="btn btn-success btn-sm m-1" @click="approve(s.el)">
<Icon v="check"/>
<T>crud.approve</T>
</button>
</li>
<li v-else @click="hide(s.el)">
<button class="btn btn-outline-secondary btn-sm m-1">
<Icon v="times"/>
<T>crud.hide</T>
</button>
</li>
<li>
<button class="btn btn-outline-danger btn-sm m-1" @click="remove(s.el)">
<Icon v="trash"/>
<T>crud.remove</T>
</button>
</li>
<li>
<button class="btn btn-outline-primary btn-sm m-1" @click="edit(s.el)">
<Icon v="pen"/>
<T>crud.edit</T>
</button>
</li>
</ul>
</td>
</template></template>
<template v-slot:empty>
<Icon v="search"/>
<T>nouns.empty</T>
</template>
</Table>
<template v-if="config.nouns.submit">
<Separator icon="plus"/>
<div class="px-3">
<InclusiveSubmitForm ref="form"/>
</div>
</template>
</Loading>
</template>
<script>
import { InclusiveEntry } from "~/src/classes";
import { buildDict } from "../src/helpers";
export default {
props: {
load: {type: Boolean}
},
data() {
return {
filter: '',
entriesRaw: undefined,
}
},
mounted() {
if (this.load) {
this.loadEntries();
}
},
methods: {
async loadEntries() {
if (this.entriesRaw !== undefined) {
return;
}
this.entriesRaw = await this.$axios.$get(`/inclusive`);
},
async setFilter(filter) {
this.filter = filter;
await this.loadEntries();
this.focus();
},
focus() {
this.$el.focus();
this.$el.scrollIntoView();
setTimeout(_ => {
this.$el.scrollIntoView();
}, 1000);
},
edit(entry) {
this.$refs.form.edit(entry);
},
async approve(entry) {
await this.$axios.$post(`/inclusive/approve/${entry.id}`);
if (entry.base) {
delete this.entries[entry.base];
}
entry.approved = true;
entry.base = null;
this.$forceUpdate();
},
async hide(entry) {
await this.$axios.$post(`/inclusive/hide/${entry.id}`);
entry.approved = false;
this.$forceUpdate();
},
async remove(entry) {
if (!confirm('Czy na pewno usunąć ten wpis?')) {
return false;
}
await this.$axios.$post(`/inclusive/remove/${entry.id}`);
delete this.entries[entry.id];
this.$forceUpdate();
},
// those must be methods, not computed, because when modified, they don't get updated in the view for some reason
visibleEntries() {
return Object.values(this.entries).filter(n => n.matches(this.filter));
},
entriesCountApproved() {
return Object.values(this.entries).filter(n => n.approved).length;
},
entriesCountPending() {
return Object.values(this.entries).filter(n => !n.approved).length;
},
},
computed: {
entries() {
if (this.entriesRaw === undefined) {
return {};
}
return buildDict(function* (that) {
const sorted = that.entriesRaw.sort((a, b) => {
if (a.approved && !b.approved) {
return 1;
}
if (!a.approved && b.approved) {
return -1;
}
return a.insteadOf.toLowerCase().localeCompare(b.insteadOf.toLowerCase());
});
for (let w of sorted) {
yield [w.id, new InclusiveEntry(w)];
}
}, this);
},
},
watch: {
filter() {
if (process.client) {
if (this.$refs.dictionarytable) {
this.$refs.dictionarytable.reset();
this.$refs.dictionarytable.focus();
}
}
}
},
}
</script>
<style lang="scss">
@import "assets/variables";
tr {
.hover-show {
opacity: 0;
}
&:hover .hover-show {
opacity: 1;
}
}
</style>

View File

@ -0,0 +1,109 @@
<template>
<section>
<div v-if="afterSubmit" class="alert alert-success text-center">
<p>
<T>nouns.submit.thanks</T>
</p>
<p>
<button class="btn btn-success" @click="afterSubmit = false">
<Icon v="plus"/>
<T>nouns.submit.another</T>
</button>
</p>
</div>
<form v-else @submit.prevent="submit">
<div class="table-responsive">
<table class="table table-borderless table-sm table-fixed-3">
<thead>
<tr>
<th class="text-nowrap">
<Icon v="comment-times"/>
<T>nouns.inclusive.insteadOf</T>
</th>
<th class="text-nowrap">
<Icon v="comment-check"/>
<T>nouns.inclusive.say</T>
</th>
<th class="text-nowrap">
<Icon v="comment-dots"/>
<T>nouns.inclusive.because</T>
</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<NounForm v-model="form.insteadOf"/>
</td>
<td>
<NounForm v-model="form.say"/>
</td>
<td>
<textarea v-model="form.because" class="form-control form-control-sm" required rows="3"></textarea>
</td>
</tr>
</tbody>
</table>
</div>
<div class="alert alert-info" v-if="form.base">
<Icon v="info-circle"/>
<T>nouns.editing</T>
<button class="btn btn-sm float-right" @click="form.base = null">
<Icon v="times"/>
</button>
</div>
<button class="btn btn-primary btn-block" :disabled="submitting">
<template v-if="submitting">
<Icon v="circle-notch fa-spin"/>
</template>
<template v-else>
<Icon v="plus"/>
<T>nouns.submit.actionLong</T>
</template>
</button>
<p class="small text-muted mt-1"><T>nouns.submit.moderation</T></p>
</form>
</section>
</template>
<script>
export default {
data() {
return {
form: {
insteadOf: [''],
say: [''],
because: '',
base: null,
},
submitting: false,
afterSubmit: false,
}
},
methods: {
async submit(event) {
this.submitting = true;
await this.$axios.$post(`/inclusive/submit`, this.form);
this.submitting = false;
this.afterSubmit = true;
this.form = {
insteadOf: [''],
say: [''],
because: '',
base: null,
};
},
edit(word) {
this.form = {
insteadOf: word.insteadOf,
say: word.say,
because: word.because,
base: word.id,
}
this.$el.scrollIntoView();
}
},
};
</script>

View File

@ -32,7 +32,6 @@
<span class="d-none d-md-inline"><T>nouns.neuter</T></span> <span class="d-none d-md-inline"><T>nouns.neuter</T></span>
<span class="d-md-none"><T>nouns.neuterShort</T></span> <span class="d-md-none"><T>nouns.neuterShort</T></span>
</th> </th>
<th v-if="$admin()"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>

View File

@ -3,6 +3,7 @@
<Separator icon="atom-alt"/> <Separator icon="atom-alt"/>
<h3 :id="$t('nouns.neuterNouns.id')"> <h3 :id="$t('nouns.neuterNouns.id')">
<Icon v="neuter"/>
<T>nouns.neuterNouns.header</T> <T>nouns.neuterNouns.header</T>
</h3> </h3>
@ -66,6 +67,7 @@
<Separator icon="atom-alt"/> <Separator icon="atom-alt"/>
<h3 :id="$t('nouns.dukajNouns.id')"> <h3 :id="$t('nouns.dukajNouns.id')">
<Icon v="genderless"/>
<T>nouns.dukajNouns.header</T> <T>nouns.dukajNouns.header</T>
</h3> </h3>
@ -150,6 +152,7 @@
<Separator icon="atom-alt"/> <Separator icon="atom-alt"/>
<h3 :id="$t('nouns.personNouns.id')"> <h3 :id="$t('nouns.personNouns.id')">
<Icon v="user-friends"/>
<T>nouns.personNouns.header</T> <T>nouns.personNouns.header</T>
<small><NormativeBadge/></small> <small><NormativeBadge/></small>
</h3> </h3>
@ -209,6 +212,27 @@
</table> </table>
</div> </div>
</details> </details>
<Separator icon="atom-alt"/>
<h3 :id="$t('nouns.inclusive.id')">
<Icon v="book-heart"/>
<T>nouns.inclusive.headerLong</T>
</h3>
<T>nouns.inclusive.info</T>
<details class="border mb-3">
<summary class="bg-light p-3" @click="$refs.inclusivedictionary.loadEntries()">
<h4 class="h5 d-inline">
<Icon v="book-heart"/>
<T>nouns.inclusive.headerLong</T>
</h4>
</summary>
<div class="border-top">
<InclusiveDictionary ref="inclusivedictionary"/>
</div>
</details>
</div> </div>
</template> </template>

View File

@ -1,16 +1,31 @@
<template> <template>
<section class="btn-group btn-block mb-2"> <section>
<a :href="'#' + $t('nouns.neuterNouns.id')" class="btn btn-outline-primary"> <div class="d-none d-md-inline-flex btn-group btn-block mb-2">
<Icon v="atom-alt"/> <a v-for="(icon, name) in links" :href="'#' + $t(`nouns.${name}.id`)" class="btn btn-outline-primary">
<T>nouns.neuterNouns.header</T> <Icon :v="icon"/>
<T>nouns.{{name}}.header</T>
</a> </a>
<a :href="'#' + $t('nouns.dukajNouns.id')" class="btn btn-outline-primary"> </div>
<Icon v="atom-alt"/> <div class="d-block d-md-none btn-group-vertical btn-block mb-2">
<T>nouns.dukajNouns.header</T> <a v-for="(icon, name) in links" :href="'#' + $t(`nouns.${name}.id`)" class="btn btn-outline-primary">
</a> <Icon :v="icon"/>
<a :href="'#' + $t('nouns.personNouns.id')" class="btn btn-outline-primary"> <T>nouns.{{name}}.header</T>
<Icon v="atom-alt"/>
<T>nouns.personNouns.header</T>
</a> </a>
</div>
</section> </section>
</template> </template>
<script>
export default {
data() {
return {
links: {
neuterNouns: 'neuter',
dukajNouns: 'genderless',
personNouns: 'user-friends',
inclusive: 'book-heart',
}
};
},
}
</script>

View File

@ -225,6 +225,23 @@ nouns:
plural: 'liczba mnoga' plural: 'liczba mnoga'
pluralShort: 'l. mn.' pluralShort: 'l. mn.'
inclusive:
header: 'Inkluzywność'
headerLong: 'Słownik inkluzywnego języka'
id: 'inkluzywnosc'
insteadOf: 'Zamiast'
say: 'Lepiej mów'
because: 'Ponieważ'
info:
- >
Język jest nośnikiem myśli, nośnikiem kultury, podstawą komunikacji. Wpływa na to, co robimy i jak myślimy.
Jeśli chcemy tworzyć społeczeństwo otwarte na różnorodność i akceptujące odmienność,
to nasz język też musi być włączający.
- >
Inkluzywny język to nie tylko rzeczowniki i nie tylko kwestie płciowości.
Poniżej przedstawiamy słownik, w którym zbieramy sugestie,
jakich konstrukcji lepiej unikać i dlatego, oraz czym je zastępować.
names: names:
header: 'Imiona' header: 'Imiona'
headerLong: 'Neutralne imiona' headerLong: 'Neutralne imiona'

View File

@ -0,0 +1,15 @@
-- Up
CREATE TABLE inclusive (
id TEXT NOT NULL PRIMARY KEY,
insteadOf TEXT NOT NULL,
say TEXT NOT NULL,
because TEXT NOT NULL,
locale TEXT NOT NULL,
approved INTEGER NOT NULL,
base_id TEXT
);
-- Down
DROP TABLE inclusive;

View File

@ -39,6 +39,7 @@ app.use(require('./routes/admin').default);
app.use(require('./routes/pronouns').default); app.use(require('./routes/pronouns').default);
app.use(require('./routes/sources').default); app.use(require('./routes/sources').default);
app.use(require('./routes/nouns').default); app.use(require('./routes/nouns').default);
app.use(require('./routes/inclusive').default);
export default { export default {
path: '/api', path: '/api',

102
server/routes/inclusive.js Normal file
View File

@ -0,0 +1,102 @@
import { Router } from 'express';
import SQL from 'sql-template-strings';
import {ulid} from "ulid";
import {isTroll} from "../../src/helpers";
const approve = async (db, id) => {
const { base_id } = await db.get(SQL`SELECT base_id FROM inclusive WHERE id=${id}`);
if (base_id) {
await db.get(SQL`
DELETE FROM inclusive
WHERE id = ${base_id}
`);
}
await db.get(SQL`
UPDATE inclusive
SET approved = 1, base_id = NULL
WHERE id = ${id}
`);
}
const router = Router();
router.get('/inclusive', async (req, res) => {
return res.json(await req.db.all(SQL`
SELECT * FROM inclusive
WHERE locale = ${req.config.locale}
AND approved >= ${req.admin ? 0 : 1}
ORDER BY approved, insteadOf
`));
});
router.get('/inclusive/search/:term', async (req, res) => {
const term = '%' + req.params.term + '%';
return res.json(await req.db.all(SQL`
SELECT * FROM inclusive
WHERE locale = ${req.config.locale}
AND approved >= ${req.admin ? 0 : 1}
AND (insteadOf like ${term} OR say like ${term})
ORDER BY approved, insteadOf
`));
});
router.post('/inclusive/submit', async (req, res) => {
if (!(req.user && req.user.admin) && isTroll(JSON.stringify(req.body))) {
return res.json('ok');
}
const id = ulid();
await req.db.get(SQL`
INSERT INTO inclusive (id, insteadOf, say, because, approved, base_id, locale)
VALUES (
${id},
${req.body.insteadOf.join('|')}, ${req.body.say.join('|')}, ${req.body.because},
0, ${req.body.base}, ${req.config.locale}
)
`);
if (req.admin) {
await approve(req.db, id);
}
return res.json('ok');
});
router.post('/inclusive/hide/:id', async (req, res) => {
if (!req.admin) {
res.status(401).json({error: 'Unauthorised'});
}
await req.db.get(SQL`
UPDATE inclusive
SET approved = 0
WHERE id = ${req.params.id}
`);
return res.json('ok');
});
router.post('/inclusive/approve/:id', async (req, res) => {
if (!req.admin) {
res.status(401).json({error: 'Unauthorised'});
}
await approve(req.db, req.params.id);
return res.json('ok');
});
router.post('/inclusive/remove/:id', async (req, res) => {
if (!req.admin) {
res.status(401).json({error: 'Unauthorised'});
}
await req.db.get(SQL`
DELETE FROM inclusive
WHERE id = ${req.params.id}
`);
return res.json('ok');
});
export default router;

View File

@ -1,10 +1,7 @@
import { Router } from 'express'; import { Router } from 'express';
import SQL from 'sql-template-strings'; import SQL from 'sql-template-strings';
import {ulid} from "ulid"; import {ulid} from "ulid";
import {isTroll} from "../../src/helpers";
const isTroll = (body) => {
return ['cipeusz', 'feminazi', 'bruksela', 'zboczeń'].some(t => body.indexOf(t) > -1);
}
const approve = async (db, id) => { const approve = async (db, id) => {
const { base_id } = await db.get(SQL`SELECT base_id FROM nouns WHERE id=${id}`); const { base_id } = await db.get(SQL`SELECT base_id FROM nouns WHERE id=${id}`);

View File

@ -433,6 +433,33 @@ export class NounDeclension {
} }
export class InclusiveEntry {
constructor({id, insteadOf, say, because, approved = true, base_id = null}) {
this.id = id;
this.insteadOf = insteadOf.split('|');
this.say = say.split('|');
this.because = because;
this.approved = !!approved;
this.base = base_id;
}
matches(filter) {
if (!filter) {
return true;
}
for (let field of ['insteadOf', 'say']) {
for (let value of this[field]) {
if (value.toLowerCase().indexOf(filter.toLowerCase()) > -1) {
return true;
}
}
}
return false;
}
}
export class Name { export class Name {
constructor(name, origin, meaning, usage, legally, pros, cons, notablePeople, count, links) { constructor(name, origin, meaning, usage, legally, pros, cons, notablePeople, count, links) {
this.name = name; this.name = name;

View File

@ -131,3 +131,7 @@ export const now = function () {
export const isEmoji = char => { export const isEmoji = char => {
return !!char.match(/^(?:[\u2700-\u27bf]|(?:\ud83c[\udde6-\uddff]){2}|[\ud800-\udbff][\udc00-\udfff]|[\u0023-\u0039]\ufe0f?\u20e3|\u3299|\u3297|\u303d|\u3030|\u24c2|\ud83c[\udd70-\udd71]|\ud83c[\udd7e-\udd7f]|\ud83c\udd8e|\ud83c[\udd91-\udd9a]|\ud83c[\udde6-\uddff]|\ud83c[\ude01-\ude02]|\ud83c\ude1a|\ud83c\ude2f|\ud83c[\ude32-\ude3a]|\ud83c[\ude50-\ude51]|\u203c|\u2049|[\u25aa-\u25ab]|\u25b6|\u25c0|[\u25fb-\u25fe]|\u00a9|\u00ae|\u2122|\u2139|\ud83c\udc04|[\u2600-\u26FF]|\u2b05|\u2b06|\u2b07|\u2b1b|\u2b1c|\u2b50|\u2b55|\u231a|\u231b|\u2328|\u23cf|[\u23e9-\u23f3]|[\u23f8-\u23fa]|\ud83c\udccf|\u2934|\u2935|[\u2190-\u21ff])$/) return !!char.match(/^(?:[\u2700-\u27bf]|(?:\ud83c[\udde6-\uddff]){2}|[\ud800-\udbff][\udc00-\udfff]|[\u0023-\u0039]\ufe0f?\u20e3|\u3299|\u3297|\u303d|\u3030|\u24c2|\ud83c[\udd70-\udd71]|\ud83c[\udd7e-\udd7f]|\ud83c\udd8e|\ud83c[\udd91-\udd9a]|\ud83c[\udde6-\uddff]|\ud83c[\ude01-\ude02]|\ud83c\ude1a|\ud83c\ude2f|\ud83c[\ude32-\ude3a]|\ud83c[\ude50-\ude51]|\u203c|\u2049|[\u25aa-\u25ab]|\u25b6|\u25c0|[\u25fb-\u25fe]|\u00a9|\u00ae|\u2122|\u2139|\ud83c\udc04|[\u2600-\u26FF]|\u2b05|\u2b06|\u2b07|\u2b1b|\u2b1c|\u2b50|\u2b55|\u231a|\u231b|\u2328|\u23cf|[\u23e9-\u23f3]|[\u23f8-\u23fa]|\ud83c\udccf|\u2934|\u2935|[\u2190-\u21ff])$/)
} }
export const isTroll = (body) => {
return ['cipeusz', 'feminazi', 'bruksela', 'zboczeń'].some(t => body.indexOf(t) > -1);
}