19 | );
20 | return (
21 |
22 | All rights reserved
23 |
24 | );
25 | }
26 |
27 | export default License;
--------------------------------------------------------------------------------
/frontend/scenarios/show_fragment.feature:
--------------------------------------------------------------------------------
1 | #language: fr
2 |
3 | Fonctionnalité: Consulter le contexte d'un commentaire de fragment
4 |
5 | Scénario: de vidéo
6 | Soit "Vidéo Sherlock Jr. (Buster Keaton)" le document principal
7 | Et "Note rire Buster Keaton (Antoine-Valentin Charpentier)" la glose ouverte
8 | Quand je clique sur la référence temporelle "00:03:09.000 --> 00:03:15.000" annotée
9 | Alors la vidéo du document principal se lance de 189 à 195 secondes
10 |
11 | Scénario: de texte
12 |
13 | Soit "Treignes, le 8 septembre 2012 (Christophe Lejeune)" le document principal
14 | Et "Etage suivant (Christophe Lejeune)" la glose ouverte
15 | Quand je survole le texte :
16 | """
17 | Se socialiser
18 | """
19 | Alors le texte du document principal est en surbrillance :
20 | """
21 | plusieurs personnes se présentent à moi. Ayant identifié que je suis nouveau, elles me souhaitent la bienvenue
22 | """
23 |
--------------------------------------------------------------------------------
/samples/hyperglosae/ethnography05.json:
--------------------------------------------------------------------------------
1 | {
2 | "_id": "8b6367c9171b8b1e44625e035c0062aa",
3 | "links": [
4 | {
5 | "verb": "refersTo",
6 | "object": "6b56ee657c870dfacd34e9ae4e0643dd"
7 | }
8 | ],
9 | "text": "{2} [plusieurs personnes se présentent à moi. Ayant identifié que je suis nouveau, elles me souhaitent la bienvenue]\nSe socialiser\n\n[Un autre m'indique que j'aurais intérêt à commencer par les parties hautes de la locomotive, plutôt que des côtés, car celles-ci deviendront vite très chaudes (je risque de me bruler si j'attends pour les laver)]\nDonner son avis sur une tâche en cours\n\n{4} [Luc et Leo exposent les résultats des essais. Les différentes personnes présentes discutent des solutions possibles]\nSe concerter (lors de la pause de midi)\n\n",
10 | "dc_license": "https://creativecommons.org/licenses/by/4.0/",
11 | "dc_issued": "2012",
12 | "dc_language": "french",
13 | "dc_title": "Etage suivant",
14 | "dc_creator": "Christophe Lejeune"
15 | }
16 |
--------------------------------------------------------------------------------
/frontend/src/components/DocumentNotFound.jsx:
--------------------------------------------------------------------------------
1 | import { Container, Row, Col, Button} from 'react-bootstrap';
2 | import { useNavigate } from 'react-router';
3 |
4 | function DocumentNotFound() {
5 | const navigate = useNavigate();
6 |
7 | return (
8 |
9 |
10 |
11 |
Document introuvable
12 |
13 | Le document que vous cherchez a été supprimé par les co-éditeurs ou l'URI fournie est incorrecte.
14 |
15 |
22 |
23 |
24 |
25 | );
26 | }
27 |
28 | export default DocumentNotFound;
29 |
--------------------------------------------------------------------------------
/frontend/src/components/CroppedImage.jsx:
--------------------------------------------------------------------------------
1 | import '../styles/CroppedImage.css';
2 |
3 | import ReactCrop from 'react-image-crop';
4 | import 'react-image-crop/dist/ReactCrop.css';
5 |
6 | function CroppedImage({ src, alt, title }) {
7 |
8 | const image = ;
9 | const caption = {title};
10 |
11 | let fragment = src.match(/#xywh=percent:([.\d]+),([.\d]+),([.\d]+),([.\d]+)/);
12 | if (fragment) {
13 | let [_, x, y, width, height] = fragment;
14 | let crop = { unit: '%', x, y, width, height };
15 | return (
16 |
17 |
18 | {image}
19 |
20 | {caption}
21 |
22 | );
23 | }
24 | return (
25 |
26 | {image}
27 | {caption}
28 |
29 | );
30 | }
31 |
32 | export default CroppedImage;
33 |
--------------------------------------------------------------------------------
/samples/hyperglosae/perrault_lorinszky06.json:
--------------------------------------------------------------------------------
1 | {
2 | "_id": "b7b35d90733011edb3e7ebf87af5c301",
3 | "isPartOf": "09c906c6732b11ed89466ba197585f87",
4 | "links": [{
5 | "verb": "adapts",
6 | "object": "37b4b9ba5cdb11ed887beb5c373fa643"
7 | }],
8 | "text": "{6} – Nahát! – mondta az anya –, oda kell küldenem a [másik] lányomat. Gyere csak, Franchon, nézd, mi potyog a húgod szájából beszéd közben: ugye örülnél, ha te is ilyen ajándékban részesülnél? Elég, ha elmész a forráshoz vízért, és ha egy szegény asszony inni kér, adsz neki jó szívvel.\n– Még hogy a forráshoz menjek, eszemben sincs! – felelte a goromba lány.\n– Igenis odamész, mégpedig most rögtön! – ripakodott rá az anya.\nA nagyobbik lány elindult, de egyfolytában dúlt-fúlt. A ház legszebb ezüstkancsóját vitte magával. Alighogy odaért a forráshoz, fenséges ruhába öltözött hölgy lépett ki az erdőből, és inni kért tőle: ugyanaz a tündér volt, de most hercegnő alakját és öltözékét vette magára, hogy lássa, mennyire szívtelen ez a lány."
9 | }
10 |
--------------------------------------------------------------------------------
/frontend/scenarios/set_type.feature:
--------------------------------------------------------------------------------
1 | #language: fr
2 |
3 | Fonctionnalité: Qualifier un document
4 |
5 | Scénario: non typé avec un type pré-existant
6 |
7 | Soit un document dont je suis l'auteur affiché comme glose
8 | Et une session active avec mon compte
9 | Quand je choisis "interview" comme type de glose
10 | Alors le type "Ethnography/Interview" est le type de la glose
11 |
12 | Scénario: typé avec un autre type pré-existant
13 |
14 | Soit un document dont je suis l'auteur affiché comme glose et dont le type est "Ethnography/Report"
15 | Et une session active avec mon compte
16 | Quand je choisis "interview" comme type de glose
17 | Alors le type "Ethnography/Interview" est le type de la glose
18 |
19 |
20 | Scénario: typé comme non typé
21 |
22 | Soit un document dont je suis l'auteur affiché comme glose et dont le type est "Ethnography/Interview"
23 | Et une session active avec mon compte
24 | Quand je choisis "remove current type" comme type de glose
25 | Alors la glose n'a pas de type
26 |
27 |
--------------------------------------------------------------------------------
/frontend/src/menu-items/PictureUploadAction.jsx:
--------------------------------------------------------------------------------
1 | import { useRef } from 'react';
2 |
3 | import DiscreeteDropdown from '../components/DiscreeteDropdown';
4 |
5 | function PictureUploadAction ({ id, backend, handleImageUrl }) {
6 | const fileInputRef = useRef(null);
7 |
8 | const handleClick = () => {
9 | fileInputRef.current.click();
10 | };
11 |
12 | const handleFileChange = (event) => {
13 | const file = event.target.files[0];
14 | if (file) backend.putAttachment(id, file, (response) => {
15 | handleImageUrl(``);
16 | });
17 | };
18 |
19 | return (
20 | <>
21 |
22 | Add a picture...
23 |
24 |
31 | >
32 | );
33 | }
34 |
35 | export default PictureUploadAction;
36 |
--------------------------------------------------------------------------------
/samples/hyperglosae/inrap_B.json:
--------------------------------------------------------------------------------
1 | {
2 | "_id": "564cf7f485c411edba2c6fe0f6ec7a8f",
3 | "dc_title": "Préparation de l'entretien",
4 | "dc_isPartOf": "Archéologie préventive (IF14)",
5 | "dc_creator": "Aurélien Bénel",
6 | "dc_issued": "2019-09-10T15:14:40.577Z",
7 | "text": "- Depuis la réforme de 2003, de nombreux opérateurs ont le droit de faire de l'archéologie préventive. Cependant,nous préfèrons faire notre entretien à l'**INRAP**, car il est national, c'est le plus ancien et la plus important.\n- Le métier le plus représentatif est probablement celui de fouilleur, mais nous décidons d'interviewer un **responsable d'opération**, car, tout en restant proche de la fouille, il semble occuper un rôle clef dans la production des documents produits par l'INRAP.",
8 | "links": [{
9 | "verb": "refersTo",
10 | "object": "f413206cef01d9b99ebb3b6de2007ca7"
11 | }],
12 | "dc_language": "french",
13 | "dc_license": "https://creativecommons.org/licenses/by/4.0/",
14 | "dc_source": "https://cassandre.utt.fr/memo/f413206cef01d9b99ebb3b6de200c738"
15 | }
16 |
--------------------------------------------------------------------------------
/samples/hyperglosae/perrault_jamborova06.json:
--------------------------------------------------------------------------------
1 | {
2 | "_id": "da6706cc6c1711edbea687297d076097",
3 | "isPartOf": "420ab198674f11eda3b7a3fdd5ea984f",
4 | "links": [{
5 | "verb": "adapts",
6 | "object": "37b4b9ba5cdb11ed887beb5c373fa643"
7 | }],
8 | "editors": ["alice"],
9 | "text": "{6} — Isto tam musím poslať svoju dcéru, povedala si matka.\nPozri, Fanka, vidíš, čo vychádza z úst tvojej sestry, keď rozpráva? Nepáčilo by sa ti, keby si tiež mala taký dar?\nStačí, keď pôjdeš k studni nabrať vodu, a keď ti nejaká biedna žena povie, že má smäd, láskavo jej dáš napiť.\n— Určite sa mi chce ísť k studni, hrubo odvrkla nemilá dievčina.\n— Chcem, aby si tam išla, rozkázala matka, a to hneď teraz.\nIšla teda, ale stále hundrala. Vzala z domu najkrajšiu striebornú čašu. Len čo prišla k studni, uvidela vychádzať z lesa nádherne oblečenú dámu. Tá k nej podišla a požiadala ju, aby jej dala napiť - bola to tá istá víla, ktorá sa zjavila aj jej sestre, ale teraz sa tvárila a bola oblečená ako princezná. Chcela zistiť, kam až môže zájsť neokrôchanosť dievčaťa."
10 | }
11 |
--------------------------------------------------------------------------------
/frontend/vite.config.js:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite';
2 | import react from '@vitejs/plugin-react-swc';
3 | import eslint from 'vite-plugin-eslint';
4 |
5 | export default defineConfig({
6 | plugins: [react(), eslint({lintOnStart: true})],
7 | define: {
8 | // https://github.com/vitejs/vite/issues/1973#issuecomment-787571499
9 | 'process.env': {},
10 | },
11 | server: {
12 | port: 3000,
13 | proxy: {
14 | '/api/_users': {
15 | target: 'http://localhost:5984/_users',
16 | changeOrigin: true,
17 | rewrite: (path) => path.replace(/^\/api\/_users/, ''),
18 | },
19 | '/api/_session': {
20 | target: 'http://localhost:5984/_session',
21 | changeOrigin: true,
22 | rewrite: (path) => path.replace(/^\/api\/_session/, ''),
23 | },
24 | '/api': {
25 | target: 'http://localhost:5984/hyperglosae',
26 | changeOrigin: true,
27 | rewrite: (path) => path.replace(/^\/api/, ''),
28 | }
29 | }
30 | },
31 | build: {
32 | outDir: 'build',
33 | },
34 | });
35 |
--------------------------------------------------------------------------------
/.github/workflows/publish.yml:
--------------------------------------------------------------------------------
1 | name: Publish
2 |
3 | on:
4 | push:
5 | branches:
6 | - 'main'
7 |
8 | jobs:
9 |
10 | test:
11 | uses: ./.github/workflows/tests.yml
12 | secrets: inherit
13 |
14 | publish:
15 | needs: test
16 | runs-on: ubuntu-latest
17 | steps:
18 | - name: Download sources
19 | uses: actions/checkout@v4
20 | - name: Get frontend build
21 | uses: actions/download-artifact@v4
22 | with:
23 | name: frontend-build
24 | path: frontend/build
25 | - name: Set up QEMU
26 | uses: docker/setup-qemu-action@v3
27 | - name: Set up Docker Buildx
28 | uses: docker/setup-buildx-action@v3
29 | - name: Login to Docker Hub
30 | uses: docker/login-action@v3
31 | with:
32 | username: benel
33 | password: ${{ secrets.DOCKERHUB_TOKEN }}
34 | - name: Build and push
35 | uses: docker/build-push-action@v6
36 | with:
37 | context: . #No checkout
38 | push: true
39 | tags: benel/hyperglosae:latest
40 |
--------------------------------------------------------------------------------
/samples/hyperglosae/inrap_E_content01.json:
--------------------------------------------------------------------------------
1 | {
2 | "_id": "5f08554085cf11edba7403b7c72ddf8c",
3 | "isPartOf": "c2b9f52285ce11edbd0aff9b25defbab",
4 | "text": "## Flux\nParmi les flux évoqués par l'informateur, nous remarquons une grande diversité :\n- de [vestiges],\n- de documents.\n\nNous pouvons faire l'hypothèse que pour contrecarrer son caractère \"destructif\" (étudier une couche stratigraphique nécessite de détruire la couche au-dessus), **l'archéologie transforme les vestiges en documents** ([photographies], plans, [coupes], etc.). Nous essaierons d'en rendre compte dans notre diagramme de flux.\n\nConcernant les vestiges, nous voyons une grande finesse dans la classification de ces derniers (par datation, nature, forme, etc.). Il semblerait même que cette classification (ou \"**typologie**\") soit une part importante du travail des archéologues. Aussi, nous y consacrerons un diagramme de classes.\n",
5 | "dc_source": "https://cassandre.utt.fr/memo/edbb69a33827773d728069d8755133ab",
6 | "links": [{
7 | "verb": "refersTo",
8 | "object": "6327c5008d1f11ed9aa8e7ae771dee2e"
9 | }]
10 | }
11 |
--------------------------------------------------------------------------------
/frontend/scenarios/link_document.feature:
--------------------------------------------------------------------------------
1 | #language: fr
2 |
3 | Fonctionnalité: Relier un document existant
4 |
5 | Scénario: pour commenter un document
6 |
7 | Soit un document existant affiché comme document principal
8 | Et une session active avec mon compte
9 | Quand je réutilise "Glossaire" comme glose
10 | Alors "Glossaire" est la glose ouverte
11 | Et la glose contient :
12 | """
13 | "Il était une fois"
14 | "Once upon a time" (eng)
15 | "Bolo to raz" (svk)
16 | """
17 |
18 | Scénario: pour le comparer avec un autre
19 |
20 | Soit "Les fées (Charles Perrault)" le document principal
21 | Et avec un document reconnaissable dont je suis l'auteur affiché comme glose en tant que "Quotation"
22 | Soit "A tündérek (Charles Perrault)" le document principal
23 | Et une session active avec mon compte
24 | Quand je réutilise mon document reconnaissable en tant que glose de citation et que je me focalise dessus
25 | Alors la colonne 1 contient "Il était une fois une veuve"
26 | Et la colonne 2 contient "Volt egyszer egy özvegyasszony"
27 | Et la colonne 2 contient "MÁSIK TANULSÁG"
28 |
29 |
--------------------------------------------------------------------------------
/frontend/scenarios/edit_passage.feature:
--------------------------------------------------------------------------------
1 | #language: fr
2 |
3 | Fonctionnalité: Essayer d'éditer l'annotation d'un passage
4 |
5 | Contexte:
6 |
7 | Soit un document en deux passages affiché comme document principal
8 | Et une glose dont je suis l'auteur faisant référence uniquement au premier passage
9 | Et une session active avec mon compte
10 |
11 | Scénario: quand celle-ci est inexistante
12 |
13 | Quand j'essaie de remplacer l'annotation du passage 2 par :
14 | """
15 | Passage intéressant !
16 | """
17 | Alors la glose contient "Passage intéressant !"
18 |
19 | Scénario: quand celle-ci pré-existe
20 |
21 | Quand j'essaie de remplacer l'annotation du passage 1 par :
22 | """
23 | Passage intéressant !
24 | """
25 | Alors la glose contient "Passage intéressant !"
26 |
27 | Scénario: avec une mise en forme
28 |
29 | Quand j'essaie de remplacer l'annotation du passage 2 par :
30 | """
31 | [plusieurs personnes se présentent à moi. Ayant identifié que je suis nouveau, elles me souhaitent la bienvenue]
32 | Se **socialiser**
33 | """
34 | Alors la glose contient "Se socialiser"
35 |
--------------------------------------------------------------------------------
/samples/hyperglosae/perrault_jamborova07.json:
--------------------------------------------------------------------------------
1 | {
2 | "_id": "3d2852666c1811edbfec5bb4e1ae9ba1",
3 | "isPartOf": "420ab198674f11eda3b7a3fdd5ea984f",
4 | "links": [{
5 | "verb": "adapts",
6 | "object": "37b4b9ba5cdb11ed887beb5c373fa643"
7 | }],
8 | "editors": ["alice"],
9 | "text": "{7} — A čo som sem prišla, aby som vám dala napiť? povedala jej nevľúdne namyslená staršia sestra.\nUrčite som priniesla striebornú čašu, na to, aby som dala panej napiť! Viete čo? Napite sa ako chcete!\n— Nie si príliš slušná, povedala víla a ani sa pri tom nerozčúlila.\nKeďže si taká úslužná, dávam ti ako dar, aby ti pri každom slove, ktoré vyslovíš, vyšiel z úst had alebo ropucha.\nLen čo ju matka zbadala, zavolala na ňu:\n— No ako, dcéra moja!?\n— Nuž takto, matka moja, odvetila jej nevľúdne staršia dcéra a vypľula dve zmije a dve ropuchy.\n— Och, Bože! zvolala matka, čo to len vidím? Za to môže tvoja sestra, nedarujem jej to! a okamžite ju bežala zmlátiť. Chúďa dievča sa utieklo skryť do bezpečia v blízkom lese. Tam ju stretol kráľov syn, ktorý sa vracal z poľovačky. Veľmi sa mu zapáčila a tak sa jej spýtal, čo tam robí a prečo plače.\n— Žiaľ, Pane, matka ma vyhnala z domu."
10 | }
11 |
--------------------------------------------------------------------------------
/settings/haproxy.cfg:
--------------------------------------------------------------------------------
1 | global
2 | maxconn 512
3 | spread-checks 5
4 |
5 | defaults
6 | mode http
7 | log global
8 | monitor-uri /_haproxy_health_check
9 | option log-health-checks
10 | option httplog
11 | balance roundrobin
12 | option forwardfor
13 | option redispatch
14 | retries 4
15 | option http-server-close
16 | timeout client 150000
17 | timeout server 3600000
18 | timeout connect 500
19 |
20 | stats enable
21 | stats uri /_haproxy_stats
22 | # stats auth admin:admin # Uncomment for basic auth
23 |
24 | frontend http-in
25 | # bind *:$HAPROXY_PORT
26 | bind :80
27 | use_backend couchdb if { path /api } || { path_beg /api/ }
28 | default_backend nginx
29 |
30 | backend couchdb
31 | option httpchk GET /_up
32 | http-check disable-on-404
33 | http-request replace-path /api/_session /_session
34 | http-request replace-path /api/_users/(.*) /_users/\1
35 | http-request replace-path /api(/)?(.*) /hyperglosae/\2
36 | server couchdb1 backend:5984 check inter 5s
37 |
38 | backend nginx
39 | option httpchk HEAD /
40 | server nginx1 frontend:80 check inter 5s
41 |
42 |
--------------------------------------------------------------------------------
/frontend/scenarios/open_uri.feature:
--------------------------------------------------------------------------------
1 | #language: fr
2 |
3 | Fonctionnalité: Essayer d'ouvrir l'URI partagée
4 |
5 | Scénario: d'un document
6 |
7 | Quand j'essaie d'ouvrir l'URI "/c2b9f52285ce11edbd0aff9b25defbab" reçue par courriel
8 | Alors je ne peux pas lire "Le document que vous cherchez a été supprimé par les co-éditeurs ou l'URI fournie est incorrecte"
9 | Et "Analyse de l'entretien" est le document principal
10 | Et "Vestiges (diagramme de classes)" une des gloses
11 | Et "Flux de l'Institut (diagramme d'activité)" une des gloses
12 | Et "Fouille sur le terrain (diagramme d'activité)" une des gloses
13 |
14 | Scénario: d'un document en regard d'une de ses gloses
15 |
16 | Quand j'essaie d'ouvrir l'URI "/c2b9f52285ce11edbd0aff9b25defbab#146e6e8442f0405b721b79357d00d0a1" reçue par courriel
17 | Alors "Analyse de l'entretien" est le document principal
18 | Et "Flux de l'Institut (diagramme d'activité)" est la glose ouverte
19 |
20 | Scénario: d'un document avec une URI invalide
21 |
22 | Quand j'essaie d'ouvrir l'URI "/document-inexistant#glose-inexistante" reçue par courriel
23 | Alors je peux lire "Le document que vous cherchez a été supprimé par les co-éditeurs ou l'URI fournie est incorrecte"
24 |
--------------------------------------------------------------------------------
/samples/hyperglosae/perrault_lorinszky07.json:
--------------------------------------------------------------------------------
1 | {
2 | "_id": "3cdc7254-7331-11ed-9ca0-a300ea2f2841",
3 | "isPartOf": "09c906c6732b11ed89466ba197585f87",
4 | "links": [{
5 | "verb": "adapts",
6 | "object": "37b4b9ba5cdb11ed887beb5c373fa643"
7 | }],
8 | "text": "{7} – Hát azért jöttem én ide, hogy inni adjak őnagyságának? – kérdezte ez a kevély, goromba lány. – Hogyne, pont ezért hoztam ezüstkancsót, mi másért; ott a forrás, igyon belőle, ha akar.\n– De kemény a szíved! – felelte a tündér, de nem volt a hangjában harag. – Nos hát, mivel szívélyességnek nyoma sincs benned, azt adom neked ajándékul, hogy valahányszor megszólalsz, kígyó vagy varangy fog kibújni a szádból.\nAmikor a lányt meglátta az anyja, már messziről odakiáltott:\n– No, mi van, lányom?\n– Mi lenne, anyám? – felelte a goromba lány, és közben két vipera és két varangy bújt ki a szájából.\n– Ó, egek, mit látok? – sikoltott az anya. – Ez a húgod bűne; de meg is kapja érte a magáét!\nMár szaladt is, hogy megverje. A szegény gyermek elszökött, és a közeli erdőben keresett menedéket. A vadászatról visszatérő királyfi ott találta, és látva, milyen szép, megkérdezte tőle, mit csinál ott egymaga, és miért sír.\n– Jaj, uram, anyám üldözött el otthonról."
9 | }
10 |
--------------------------------------------------------------------------------
/docker-compose.test.yml:
--------------------------------------------------------------------------------
1 | services:
2 |
3 | backend:
4 | extends:
5 | file: docker-compose.dev.yml
6 | service: backend
7 |
8 | updated_samples:
9 | extends:
10 | file: docker-compose.dev.yml
11 | service: updated_samples
12 | depends_on:
13 | backend:
14 | condition: service_healthy
15 |
16 | updated_code:
17 | extends:
18 | file: docker-compose.dev.yml
19 | service: updated_code
20 | depends_on:
21 | backend:
22 | condition: service_healthy
23 |
24 | frontend:
25 | image: nginx
26 | volumes:
27 | - ./frontend/build:/usr/share/nginx/html:ro
28 | - ./settings/nginx.conf:/etc/nginx/conf.d/default.conf:ro
29 | healthcheck:
30 | test: curl --fail --head localhost || exit 1
31 | start_period: 5s
32 | start_interval: 2s
33 | depends_on:
34 | updated_code:
35 | condition: service_completed_successfully
36 |
37 | proxy:
38 | image: haproxy
39 | ports:
40 | - 80:80
41 | volumes:
42 | - ./settings:/usr/local/etc/haproxy:ro
43 | depends_on:
44 | backend:
45 | condition: service_healthy
46 | frontend:
47 | condition: service_healthy
48 |
49 |
--------------------------------------------------------------------------------
/frontend/src/components/FragmentComment.jsx:
--------------------------------------------------------------------------------
1 | import '../styles/FragmentComment.css';
2 |
3 | function FragmentComment({ children, setHighlightedText }) {
4 | try {
5 | children = (children instanceof Array) ? children : [children];
6 | const citationRegex = /^\[.*\]\s*\n(.*)$/m;
7 | if (citationRegex.test(children[0])) {
8 | let [citation, comment] = children[0].split(/\n/);
9 | citation = citation.replace(/[[\]]/g, '');
10 | const commentParts = [
11 | comment,
12 | ...children.slice(1)
13 | ].map((part, index) => (
14 |
15 | {part}
16 |
17 | ));
18 |
19 | return
22 |
23 | );
24 |
25 | function playVideoAt(timecode) {
26 | let [start, end] = timecode.split('-->');
27 | let [hour, min, sec] = start.split(/[:.]/);
28 | let startTime = Number(hour * 3600) + Number(min * 60) + Number(sec);
29 | [hour, min, sec] = end.split(/[:.]/);
30 | let endTime = Number(hour * 3600) + Number(min * 60) + Number(sec);
31 | let iframe = document.getElementsByTagName('iframe');
32 | if (iframe.length != 0) {
33 | let youTubeLink = new URL(iframe[0].src);
34 | let youTubeBaseLink = youTubeLink.origin + youTubeLink.pathname;
35 | let targetLink = `${youTubeBaseLink}?start=${startTime}&end=${endTime}&autoplay=1&mute=1`;
36 | iframe[0].src = targetLink;
37 | }
38 | }
39 | }
40 |
41 | export default VideoComment;
42 |
--------------------------------------------------------------------------------
/frontend/scenarios/set_license.feature:
--------------------------------------------------------------------------------
1 | #language: fr
2 |
3 | Fonctionnalité: Définir la licence
4 |
5 | Contexte:
6 |
7 | Soit "Vidéo Sherlock Jr. (Buster Keaton)" le document principal
8 | Et "All rights reserved" le nom de la licence du document principal
9 | Et une session active avec mon compte
10 |
11 | Scénario: d'une traduction avec licence compatible avec celle de la source
12 |
13 | Et j'essaie de créer une glose de type "Adaptation"
14 | Quand j'essaie de remplacer les métadonnées de la glose par :
15 | """
16 | dc_license: All rights reserved
17 | """
18 | Alors le nom de la licence de la glose est "All rights reserved"
19 | Et je ne peux pas lire "Licenses are not compatible"
20 |
21 | Scénario: d'une traduction avec une licence incompatible avec celle de la source
22 |
23 | Et j'essaie de créer une glose de type "Adaptation"
24 | Quand j'essaie de remplacer les métadonnées de la glose par :
25 | """
26 | dc_license: https://creativecommons.org/licenses/by-sa/4.0/
27 | """
28 | Alors le code de la licence de la glose est "CC-BY-SA"
29 | Et je peux lire "Licenses are not compatible"
30 |
31 | Scénario: d'un commentaire avec une licence incompatible avec celle de la source
32 |
33 | Et j'essaie de créer une glose de type "Commentary"
34 | Quand j'essaie de remplacer les métadonnées de la glose par :
35 | """
36 | dc_license: https://creativecommons.org/licenses/by-sa/4.0/
37 | """
38 | Alors le code de la licence de la glose est "CC-BY-SA"
39 | Et je ne peux pas lire "Licenses are not compatible"
40 |
41 |
--------------------------------------------------------------------------------
/frontend/scenarios/invite_editor.feature:
--------------------------------------------------------------------------------
1 | #language: fr
2 |
3 | Fonctionnalité: Essayer d'inviter quelqu'un à éditer un document
4 |
5 | Scénario: dont on est l'auteur
6 |
7 | Soit un document reconnaissable dont je suis l'auteur affiché comme glose
8 | Et une session active avec mon compte
9 | Quand j'essaie d'accorder les droits d'édition à "Christophe"
10 | Alors je vois "christophe" dans la liste des éditeurs
11 | Et "christophe" peut modifier le document
12 | Et le document apparaît dans la bibliothèque de "christophe"
13 |
14 | Scénario: dont on n'est pas l'auteur
15 |
16 | Soit un document dont je ne suis pas l'auteur affiché comme glose
17 | Et une session active avec mon compte
18 | Quand j'essaie d'accorder les droits d'édition à "christophe"
19 | Alors je peux lire "Before editing this document, please request authorization to its editors first"
20 |
21 | Scénario: sans être connecté
22 |
23 | Soit un document reconnaissable dont je suis l'auteur affiché comme glose
24 | Quand j'essaie d'accorder les droits d'édition à "christophe"
25 | Alors je peux lire "Before editing this document, please log in first"
26 |
27 | Scénario: en appuyant sur Entrée
28 |
29 | Soit un document reconnaissable dont je suis l'auteur affiché comme glose
30 | Et une session active avec mon compte
31 | Quand j'essaie d'accorder les droits d'édition à "Christophe" en appuyant sur Entrée
32 | Alors je vois "christophe" dans la liste des éditeurs
33 | Et "christophe" peut modifier le document
34 | Et le document apparaît dans la bibliothèque de "christophe"
35 |
36 |
--------------------------------------------------------------------------------
/frontend/src/components/LicenseCompatibility.jsx:
--------------------------------------------------------------------------------
1 | const licenseCompatibility = {
2 | 'Public domain': ['Public domain', 'by', 'by-sa', 'by-nc', 'by-nc-sa', 'by-nc-nd', 'by-nd', 'All rights reserved'],
3 | 'by': ['by', 'by-sa', 'by-nc', 'by-nc-sa', 'by-nc-nd', 'by-nd', 'All rights reserved'],
4 | 'by-sa': ['by-sa'],
5 | 'by-nc': ['by-nc', 'by-nc-sa', 'by-nc-nd'],
6 | 'by-nc-sa': ['by-nc-sa'],
7 | 'by-nc-nd': [],
8 | 'by-nd': [],
9 | 'All rights reserved': ['All rights reserved'],
10 | };
11 |
12 | function LicenseCompatibility({ sourceMetadata, marginMetadata }) {
13 | const isAdaptation = marginMetadata?.links?.some(x => x.verb === 'adapts');
14 |
15 | const getLicenseKey = (licenseUri) => {
16 | if (!licenseUri) return 'All rights reserved';
17 | if (licenseUri.toLowerCase() === 'public domain') return 'Public domain';
18 | const matches = /BY[\w-]*/i.exec(licenseUri);
19 | return matches ? matches[0].toLowerCase() : 'All rights reserved';
20 | };
21 |
22 | const sourceKey = getLicenseKey(sourceMetadata?.dc_license);
23 | const marginKey = getLicenseKey(marginMetadata?.dc_license);
24 | const isCompatible = isAdaptation ? licenseCompatibility[sourceKey]?.includes(marginKey) : true;
25 |
26 | const warningStyle = {
27 | textAlign: 'right'
28 | };
29 |
30 | return (
31 | !isCompatible && (
32 |
55 | );
56 | };
57 |
58 | export default CheckboxList;
59 |
--------------------------------------------------------------------------------
/frontend/scenarios/delete_reference.feature:
--------------------------------------------------------------------------------
1 | #language: fr
2 |
3 | Fonctionnalité: Supprimer une référence à un document
4 |
5 | Scénario: dont je suis l'auteur
6 | Soit un document dont je ne suis pas l'auteur affiché comme document principal
7 | Et avec un document reconnaissable dont je suis l'auteur affiché comme glose
8 | Et une session active avec mon compte
9 | Quand je supprime le lien entre le document principal et la référence
10 | Alors il n'y a aucun document principal affiché
11 | Et la glose ouverte est le document reconnaissable
12 |
13 | Scénario: dont je ne suis pas l'auteur
14 |
15 | Soit un document dont je ne suis pas l'auteur affiché comme document principal
16 | Et avec un document reconnaissable dont je ne suis pas l'auteur affiché comme glose
17 | Et une session active avec mon compte
18 | Quand je supprime le lien entre le document principal et la référence
19 | Alors je peux lire "Before editing this document, please request authorization to its editors first"
20 |
21 | Scénario: contenant une citation
22 |
23 | Soit un document reconnaissable dont je suis l'auteur affiché comme glose et dont le type est "Quotation"
24 | Et un document dont je ne suis pas l'auteur affiché comme document principal et contenant :
25 | """
26 | Les hommes naissent et demeurent libres et égaux en droits. Les distinctions sociales ne peuvent être fondées que sur l'utilité commune.
27 | Le but de toute association politique est la conservation des droits naturels et imprescriptibles de l'Homme. Ces droits sont la liberté, la propriété, la sûreté, et la résistance à l'oppression.
28 | """
29 | Et une session active avec mon compte
30 | Et je choisis "Quotation" comme type de reférence
31 | Et je réutilise ma glose reconnaissable
32 | Quand je supprime le lien entre le document principal et la référence
33 | Alors il n'y a aucun document principal affiché
34 | Et la glose ouverte est le document reconnaissable
35 | Et les références au document principal contenues dans la glose ne sont plus visibles
36 |
--------------------------------------------------------------------------------
/frontend/eslint.config.js:
--------------------------------------------------------------------------------
1 | import js from '@eslint/js';
2 | import globals from 'globals';
3 | import react from 'eslint-plugin-react';
4 | import reactHooks from 'eslint-plugin-react-hooks';
5 | import reactRefresh from 'eslint-plugin-react-refresh';
6 |
7 | export default [
8 | { ignores: ['build'] },
9 | {
10 | files: ['src/**/*.{js,jsx}'],
11 | languageOptions: {
12 | ecmaVersion: 2020,
13 | globals: globals.browser,
14 | parserOptions: {
15 | ecmaVersion: 'latest',
16 | ecmaFeatures: { jsx: true },
17 | sourceType: 'module',
18 | },
19 | },
20 | settings: { react: { version: '18.3' } },
21 | plugins: {
22 | react,
23 | 'react-hooks': reactHooks,
24 | 'react-refresh': reactRefresh,
25 | },
26 | rules: {
27 | ...js.configs.recommended.rules,
28 | ...react.configs.recommended.rules,
29 | ...react.configs['jsx-runtime'].rules,
30 | ...reactHooks.configs.recommended.rules,
31 | 'react/prop-types': 'off', //for now
32 | 'react-refresh/only-export-components': [
33 | 'warn',
34 | { allowConstantExport: true },
35 | ],
36 | 'no-unused-vars': [
37 | 'error',
38 | { destructuredArrayIgnorePattern: '^_' }
39 | ],
40 | 'object-shorthand': 2,
41 | 'brace-style': 2,
42 | 'jsx-quotes': 2,
43 | quotes: [2, 'single'],
44 | 'keyword-spacing': 2,
45 | 'no-else-return': 2,
46 | 'space-infix-ops': 2,
47 | 'comma-spacing': 2,
48 | 'key-spacing': 2,
49 |
50 | semi: [2, 'always', {
51 | omitLastInOneLineBlock: true,
52 | }],
53 |
54 | 'no-multiple-empty-lines': [2, {
55 | max: 1,
56 | }],
57 |
58 | 'no-multi-spaces': 2,
59 | 'no-trailing-spaces': 2,
60 | 'no-spaced-func': 2,
61 | 'space-before-blocks': 2,
62 | 'space-in-parens': [2, 'never'],
63 |
64 | indent: [2, 2, {
65 | SwitchCase: 1,
66 | }],
67 |
68 | 'no-mixed-spaces-and-tabs': 2,
69 | },
70 | }
71 | ];
72 |
--------------------------------------------------------------------------------
/frontend/scenarios/break_into_passages.feature:
--------------------------------------------------------------------------------
1 | #language: fr
2 |
3 | Fonctionnalité: Découper un document en passages numérotés
4 |
5 | Scénario: en fonction du numéro de paragraphe
6 |
7 | Soit un document dont je suis l'auteur affiché comme glose et contenant :
8 | """
9 | Les hommes naissent et demeurent libres et égaux en droits. Les distinctions sociales ne peuvent être fondées que sur l'utilité commune.
10 | Le but de toute association politique est la conservation des droits naturels et imprescriptibles de l'Homme. Ces droits sont la liberté, la propriété, la sûreté, et la résistance à l'oppression.
11 | """
12 | Et une session active avec mon compte
13 | Quand je découpe la glose en passages numérotés et que je me focalise sur la glose
14 | Alors la rubrique "1" est associée au passage "Les hommes naissent et demeurent libres"
15 | Et la rubrique "2" est associée au passage "Le but de toute association politique"
16 |
17 | Scénario: en indiquant des rubriques alpha-numériques dans le texte
18 |
19 | Soit un document dont je suis l'auteur affiché comme glose
20 | Et une session active avec mon compte
21 | Quand je remplace le contenu de la glose par ce qui suit et que je me focalise sur la glose :
22 | """
23 | {447a} CALLICLÈS. C'est ainsi, dit-on, qu'il faut arriver à la guerre et à une bataille.
24 | SOCRATE. Comment ! sommes-bous en retard, et arrivons-nous, comme on dit, après la fête ?
25 | CALLICLÈS. Et même après une fête tout à fait agréable ; car Gorgias vient de nous faire entendre une infinité de belles choses.
26 | SOCRATE. Eh bien, Calliclès, c'est pourtant Chéréphon qui est cause de cela, pour nous avoir obligés de nous arrêter sur la place publique.
27 |
28 | {447b} CHÉRÉPHON. Il n'y a pas de mal, Socrate ; car j'y remédierai bien. Gorgias est mon ami, et par conséquent il se fera entendre à nous à l'instant même, si tu le désires, ou, si tu l'aimes mieux, une autre fois.
29 | """
30 | Alors la rubrique "447a" est associée au passage "CALLICLÈS. C'est ainsi, dit-on, qu'il faut arriver à la guerre et à une bataille."
31 | Et la rubrique "447b" est associée au passage "CHÉRÉPHON. Il n'y a pas de mal, Socrate ; car j'y remédierai bien. Gorgias"
32 |
--------------------------------------------------------------------------------
/frontend/src/menu-items/InviteEditorsAction.jsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import { Button, InputGroup, ListGroup, Modal, Form } from 'react-bootstrap';
3 | import DiscreeteDropdown from '../components/DiscreeteDropdown';
4 |
5 | export default function InviteEditorsAction({metadata, backend, setLastUpdate}) {
6 | const [show, setShow] = useState(false);
7 | const [userName, setUserName] = useState('');
8 | const [grantedEditors, setGrantedEditors] = useState(metadata.editors || []);
9 |
10 | const handleClose = () => setShow(false);
11 |
12 | const handleShow = () => setShow(true);
13 |
14 | let addEditor = () => {
15 | const formattedUserName = userName.trim();
16 | let editors;
17 | backend.getDocument(metadata._id)
18 | .then(x => {
19 | editors = [...(x.editors || []), formattedUserName];
20 | return backend.putDocument({...x, editors});
21 | })
22 | .then(x => setLastUpdate(x.rev))
23 | .then(() => {
24 | setGrantedEditors(editors);
25 | setUserName('');
26 | })
27 | .catch(console.error);
28 | };
29 |
30 | return (
31 | <>
32 |
33 | Invite editors...
34 |
35 |
36 |
37 |
38 | Invite user to edit document
39 |
40 |
41 | Username
42 |
43 | setUserName(event.target.value.toLowerCase())}
47 | onKeyDown={(event) => {
48 | if (event.key === 'Enter') {
49 | addEditor();
50 | }
51 | }}
52 | />
53 |
56 |
57 |
58 |
59 |
Editors
60 |
61 | {grantedEditors.map((user) => (
62 | {user}
63 | ))}
64 |
65 |
66 |
67 | >
68 | );
69 | }
70 |
--------------------------------------------------------------------------------
/frontend/scenarios/edit_content.feature:
--------------------------------------------------------------------------------
1 | #language: fr
2 |
3 | Fonctionnalité: Essayer d'éditer le contenu
4 |
5 | Scénario: de la glose dont on est l'auteur
6 |
7 | Soit un document dont je suis l'auteur affiché comme glose
8 | Et une session active avec mon compte
9 | Quand j'essaie de remplacer le contenu de la glose par :
10 | """
11 | Nous traduisons ici "shape" par "ombre" et non "forme" car dans un autre
12 | poème du recueil, les "shapes" sont clairement ce qui apparaît en allumant
13 | une lampe dans les ténèbres.
14 | """
15 | Alors la glose contient "Nous traduisons ici"
16 |
17 | Scénario: de la glose dont on n'est pas l'auteur
18 |
19 | Soit un document dont je ne suis pas l'auteur affiché comme glose
20 | Et une session active avec mon compte
21 | Quand j'essaie de remplacer le contenu de la glose par :
22 | """
23 | Nous traduisons ici "shape" par "ombre" et non "forme" car dans un autre
24 | poème du recueil, les "shapes" sont clairement ce qui apparaît en allumant
25 | une lampe dans les ténèbres.
26 | """
27 | Alors je peux lire "Before editing this document, please request authorization to its editors first"
28 | Et la glose est ouverte en mode édition
29 |
30 | Scénario: de la glose sans être connecté
31 |
32 | Soit un document dont je suis l'auteur affiché comme glose
33 | Quand j'essaie de remplacer le contenu de la glose par :
34 | """
35 | Nous traduisons ici "shape" par "ombre" et non "forme" car dans un autre
36 | poème du recueil, les "shapes" sont clairement ce qui apparaît en allumant
37 | une lampe dans les ténèbres.
38 | """
39 | Alors je peux lire "Before editing this document, please log in first"
40 | Et la glose est ouverte en mode édition
41 |
42 | Scénario: du document principal lorsqu'il n'a pas de document source
43 |
44 | Soit "Restaurer la vapeur" le document principal
45 | Et qui n'a pas de document source
46 | Et une session active avec mon compte
47 | Quand je souhaite modifier le contenu du document principal
48 | Alors "Restaurer la vapeur" est la glose ouverte en mode édition
49 |
50 | Scénario: du document principal lorsqu'il a un document source
51 |
52 | Soit "Treignes, le 8 septembre 2012 (Christophe Lejeune)" le document principal
53 | Et qui a un document source
54 | Et une session active avec mon compte
55 | Quand je souhaite modifier le contenu du document principal
56 | Alors "Treignes, le 8 septembre 2012 (Christophe Lejeune)" est la glose ouverte en mode édition
--------------------------------------------------------------------------------
/frontend/src/components/Menu.jsx:
--------------------------------------------------------------------------------
1 | import '../styles/Menu.css';
2 |
3 | import Navbar from 'react-bootstrap/Navbar';
4 | import Container from 'react-bootstrap/Container';
5 | import Col from 'react-bootstrap/Col';
6 | import Row from 'react-bootstrap/Row';
7 | import Form from 'react-bootstrap/Form';
8 | import Dropdown from 'react-bootstrap/Dropdown';
9 | import { Link } from 'react-router';
10 | import { OverlayTrigger, Tooltip } from 'react-bootstrap';
11 | import SignOutAction from '../menu-items/SignOutAction';
12 |
13 | function Menu({backend, user, setUser}) {
14 | return (
15 |
16 |
17 |
18 | Consult my bookshelf} placement="bottom">
19 |
20 |
25 |
26 |
27 |
28 |
29 |
30 |
31 | );
32 | }
33 |
34 | function Authentication({backend, user, setUser}) {
35 |
36 | let handleSubmit = (e) => {
37 | e.preventDefault();
38 | let credentials = Object.fromEntries(new FormData(e.target).entries());
39 | backend.postSession(credentials)
40 | .then(setUser);
41 | };
42 |
43 | if (user) return (
44 |
45 |
46 | {user}
47 |
48 |
49 |
50 |
51 |
52 | );
53 | return (
54 |
78 | );
79 | }
80 |
81 | export default Menu;
82 |
--------------------------------------------------------------------------------
/samples/hyperglosae/ethnography04.json:
--------------------------------------------------------------------------------
1 | {
2 | "_id": "6b56ee657c870dfacd34e9ae4e0643dd",
3 | "links": [
4 | {
5 | "verb": "refersTo",
6 | "object": "6b56ee657c870dfacd34e9ae4e0576ce"
7 | }
8 | ],
9 | "editors": [
10 | "christophe"
11 | ],
12 | "dc_license": "https://creativecommons.org/licenses/by/4.0/",
13 | "text": "{1} Lors d'une de mes premières venues à l'atelier, le nettoyage de la carrosserie d'une locomotive à vapeur m'est confié. Pendant ce temps, Luc, un autre bénévole la met en chauffe. Il s'agit d'allumer le feu dans le foyer de manière à progressivement réchauffer l'eau présente dans la chaudière, qui, se transformant en vapeur, fournit la force motrice d'une telle locomotive. \n \n{2} Assez rapidement, plusieurs personnes se présentent à moi. Ayant identifié que je suis nouveau, elles me souhaitent la bienvenue. Un premier membre me trouve un seau plus grand que celui avec lequel j'ai commencé le nettoyage. Un autre m'indique que j'aurais intérêt à commencer par les parties hautes de la locomotive, plutôt que des côtés, car celles-ci deviendront vite très chaudes (je risque de me bruler si j'attends pour les laver). \n \n{3} La chauffe dure à trois à quatre heures. Une fois que la locomotive est prête, des essais ont lieu aux abords de l'atelier. Pour réaliser ces essais, Leo se joint à Luc. Ces essais consistent à faire quelques manœuvres au sein du faisceau (rail et aiguillage) aux alentours de l'atelier. Très vite, Leo descend de la locomotive, plonge son bras sous la carrosserie, et affichant une moue crispée, affirme que les boîtes d'essieu chauffent trop. \n \n{4} Il est midi. Nous interrompons tous les trois les essais et rejoignons les autres bénévoles pour le repas de midi. La météo étant clémente, nous sortons quelques chaises pour manger en plein air. Tout en mangeant, Luc et Leo exposent les résultats des essais. Les différentes personnes présentes discutent des solutions possibles. J'apprends alors que le problème est connu et récurrent sur cette ancienne locomotive de charbonnage. Afin de faire diminuer ces *hot boxes*, les bénévoles ont déjà testé différents types d'huiles de plus en plus épaisses. C'est un des administrateurs de l'association, travaillant dans l'industrie, qui a identifié l'huile épaisse utilisée lors des essais. Un autre convive m'explique que cette huile est tellement épaisse qu'elle donne une sensation de velour lorsqu'on la fait glisser entre les doigts.",
14 | "dc_issued": "2012",
15 | "dc_language": "french",
16 | "dc_title": "Treignes, le 8 septembre 2012",
17 | "dc_creator": "Christophe Lejeune"
18 | }
19 |
--------------------------------------------------------------------------------
/frontend/src/routes/Bookshelf.jsx:
--------------------------------------------------------------------------------
1 | import '../styles/Bookshelf.css';
2 | import { useState, useEffect } from 'react';
3 | import Container from 'react-bootstrap/Container';
4 | import ButtonGroup from 'react-bootstrap/ButtonGroup';
5 | import ToggleButton from 'react-bootstrap/ToggleButton';
6 | import FutureDocument from '../components/FutureDocument.jsx';
7 | import DocumentsCards from '../components/DocumentsCards.jsx';
8 | import Graph from '../components/Graph.jsx';
9 | import Col from 'react-bootstrap/Col';
10 | import Row from 'react-bootstrap/Row';
11 |
12 | function Bookshelf({ backend, user }) {
13 | const [documents, setDocuments] = useState([]);
14 | const [lastUpdate, setLastUpdate] = useState();
15 | const [displayMode, setDisplayMode] = useState(localStorage.getItem('displayMode') || 'graph');
16 |
17 | const displayModesList = ['graph', 'list'];
18 |
19 | useEffect(() => {
20 | backend.getAllDocuments(user)
21 | .then(setDocuments);
22 | }, [lastUpdate, user, backend]);
23 | const docs = [
24 | ...new Map(
25 | documents?.filter(x => !!x)
26 | .map(({_id, dc_title, links}) => [_id, [_id, dc_title, links]])
27 | ).values()
28 | ];
29 | const displayedDocs = docs?.flatMap(d => d[0]);
30 |
31 | function DisplayDocuments() {
32 | switch (displayMode) {
33 | case 'graph':
34 | return (
35 |
36 |
54 |
55 | {displayModesList.map((display, idx) => (
56 | {
65 | setDisplayMode(e.currentTarget.value);
66 | localStorage.setItem('displayMode', e.currentTarget.value);
67 | }}
68 | >
69 | as a {display}
70 |
71 | ))}
72 |
73 |
74 |
75 | );
76 | }
77 |
78 | export default Bookshelf;
79 |
80 |
--------------------------------------------------------------------------------
/frontend/scenarios/open_document.feature:
--------------------------------------------------------------------------------
1 | #language: fr
2 |
3 | Fonctionnalité: Ouvrir à côté d'une source
4 |
5 | Scénario: une traduction passage par passage
6 |
7 | Soit "Les fées (Charles Perrault)" le document principal
8 | Et "A tündérek (Charles Perrault)" une des gloses
9 | Et "Víly (Charles Perrault)" une des gloses
10 | Et je peux lire "Il était une fois une veuve qui avait deux filles"
11 | Mais je ne peux pas lire "Volt egyszer egy özvegyasszony"
12 | Et je ne peux pas lire "Bola raz jedna vdova, ktorá mala dve dcéry"
13 | Quand j'ouvre "Víly (Charles Perrault)" à côté
14 | Alors je peux lire "Bola raz jedna vdova, ktorá mala dve dcéry"
15 | Et je peux lire "Il était une fois une veuve qui avait deux filles"
16 | Mais je ne peux pas lire "Volt egyszer egy özvegyasszony"
17 |
18 | Scénario: un commentaire fragment par fragment
19 |
20 | Soit "Entretien avec un responsable d'opération" le document principal
21 | Et "Étiquetage de l'entretien" une des gloses
22 | Et je peux lire "le matin tu arrives sur le chantier, tu vas fouiller selon le programme que t'as établi"
23 | Quand j'ouvre "Étiquetage de l'entretien" à côté
24 | Alors je peux lire "le matin tu arrives sur le chantier, tu vas fouiller selon le programme que t'as établi"
25 | Et je peux lire "fouiller selon le programmeAction"
26 |
27 | Scénario: un commentaire global
28 |
29 | Soit "Étiquetage de l'entretien" le document principal
30 | Et "Analyse de l'entretien" une des gloses
31 | Et je peux lire "photos"
32 | Mais je ne peux pas lire "photographies"
33 | Quand j'ouvre "Analyse de l'entretien" à côté
34 | Alors je peux lire "photos"
35 | Et je peux lire "l'archéologie transforme les vestiges en documents ([photographies], plans, [coupes], etc.)."
36 |
37 | Scénario: une comparaison avec des documents apparentés
38 |
39 | Soit "Photographie : vitrail, baie 113, Église Saint-Nizier, Troyes" le document principal
40 | Et "Soleil noir et étoiles qui tombent – Comparaison de vitraux" une des gloses
41 | Et "Lavage des tuniques – Comparaison de vitraux" une des gloses
42 | Et je vois l'image "SNZ 113" dans le document principal
43 | Mais je ne vois pas l'image "GRV 005"
44 | Et je ne vois pas l'image "SMV 006 Soleil"
45 | Et je ne vois pas l'image "SMV 006 Lavage"
46 | Quand j'ouvre "Soleil noir et étoiles qui tombent – Comparaison de vitraux" à côté
47 | Alors je vois l'image "GRV 005" dans la glose
48 | Et je vois l'image "SMV 006 Soleil" dans la glose
49 | Mais je ne vois pas l'image "SMV 006 Lavage"
50 | Et je vois l'image "SNZ 113" dans la glose
51 | Et l'image intégrée dans la page a pour légende "Photographie : vitrail, baie 113, Église Saint-Nizier, Troyes"
52 | Et l'image intégrée dans la page a pour légende "Photographie : vitrail, baie 6, Église Saint-Martin-ès-Vignes, Troyes"
53 | Et l'image intégrée dans la page a pour légende "Photographie : vitrail, baie 5, Église Saint-Martin, Grandville, Aube"
54 |
55 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing to Hyperglosae
2 |
3 | ## Requirements
4 |
5 | - A UNIX-like terminal (preferably WSL if you are on Windows),
6 | - Git,
7 | - Node.js (preferably through nvm),
8 | - Docker (preferably through Docker Desktop).
9 |
10 | ## Installing software
11 |
12 | Get the code:
13 |
14 | ```shell
15 | git clone https://github.com/Hypertopic/HyperGlosae.git
16 | ```
17 |
18 | Install the backend:
19 |
20 | ```shell
21 | docker compose --file docker-compose.dev.yml pull
22 | ```
23 |
24 | Install the frontend:
25 |
26 | ```
27 | cd frontend
28 | npm install
29 | ```
30 |
31 | ## Launching software
32 |
33 | From the root folder of Hyperglosae, launch the backend:
34 |
35 | ```shell
36 | export COUCHDB_USER="TO_BE_CHANGED"
37 | export COUCHDB_PASSWORD="TO_BE_CHANGED"
38 | docker compose --file docker-compose.dev.yml up --detach
39 | ```
40 |
41 | Open to view its Web console.
42 |
43 | From the `frontend` folder of Hyperglosae, compile and launch the frontend:
44 |
45 | ```
46 | npm start
47 | ```
48 | Don't close the terminal or interrupt the command unless you want to "kill" the service.
49 |
50 |
51 | Open to browse sample data in the application.
52 | To test edit features, log in as user `alice` with `whiterabbit` as the password.
53 |
54 | ## Running tests
55 |
56 | From the `frontend` folder of Hyperglosae, type the following command:
57 |
58 | ```shell
59 | npm run test2
60 | ```
61 |
62 | Select `E2E testting`, `Electron`, and then the tests you want to run.
63 |
64 | ## Developping
65 |
66 | ### Frontend developping
67 |
68 | The frontend is coded in JavaScript with the React framework (see [documentation](https://reactjs.org/docs/getting-started.html)).
69 |
70 | Everytime you update code in `frontend/src`, the frontend page is reloaded.
71 | You may also see any lint errors in the console.
72 |
73 | ### Backend developping
74 |
75 | The backend is coded in JavaScript as CouchDB views (see [documentation](https://docs.couchdb.org/en/stable/ddocs/views/)). Documents stored in CouchDB can be created, updated and deleted (esp. by the frontend) using CouchDB REST API (see [documentation](https://docs.couchdb.org/en/stable/api/document/)).
76 |
77 | Everytime you update code or settings in `backend`, please push them to the backend with:
78 |
79 | ```shell
80 | docker compose --file docker-compose.dev.yml run updated_code
81 | ```
82 |
83 | And then refresh the frontend (or backend) page.
84 |
85 | ### Samples writing
86 |
87 | Everytime you update sample data in `samples`, please push them to the backend with:
88 |
89 | ```shell
90 | docker compose --file docker-compose.dev.yml run updated_samples
91 | ```
92 |
93 | ### Cleaning the data
94 |
95 | If (and only if) you want to remove ANY DATA added by hand or through automated tests, launch the following command:
96 |
97 | ```shell
98 | docker-compose --file docker-compose.dev.yml down
99 | docker compose --file docker-compose.dev.yml up --detach
100 | ```
101 |
102 |
--------------------------------------------------------------------------------
/frontend/src/components/Metadata.jsx:
--------------------------------------------------------------------------------
1 | import '../styles/Metadata.css';
2 |
3 | import { useEffect, useState } from 'react';
4 | import { parse, stringify } from 'yaml';
5 | import { OverlayTrigger, Tooltip } from 'react-bootstrap';
6 |
7 | function Metadata({metadata = {}, editable, backend, setLastUpdate}) {
8 | const [beingEdited, setBeingEdited] = useState(false);
9 | const [editedDocument, setEditedDocument] = useState(metadata);
10 |
11 | useEffect(() => {
12 | setEditedDocument(metadata);
13 | }, [metadata]);
14 |
15 | let handleClick = () => {
16 | setBeingEdited(true);
17 | backend.getDocument(metadata._id)
18 | .then((x) => {
19 | setEditedDocument(x);
20 | });
21 | };
22 |
23 | let handleBlur = () => {
24 | setBeingEdited(false);
25 | let updatedDocument = {
26 | ...Object.fromEntries(
27 | Object.entries(editedDocument).filter(([key, _]) => !key.startsWith('dc_'))
28 | ),
29 | ...parse(event.target.value)
30 | };
31 | setEditedDocument(updatedDocument);
32 | backend.putDocument(updatedDocument)
33 | .then(x => setLastUpdate(x.rev))
34 | .catch(console.error);
35 | };
36 |
37 | let editedMetadata = Object.fromEntries(
38 | Object.entries(editedDocument)
39 | .filter(([key, _]) => key.startsWith('dc_'))
40 | );
41 |
42 | let format = (actors, prefix = '', suffix = '') =>
43 | actors && (prefix + [actors].flat().join(' & ') + suffix);
44 |
45 | let capitalize = (name) =>
46 | name.charAt(0).toUpperCase() + name.slice(1).toLowerCase();
47 |
48 | let formatTranslation = (translators = '', language = '') =>
49 | (translators) &&
50 | 'Translated '
51 | + (language && `in ${capitalize(language)} `)
52 | + format(translators, 'by ', ', ');
53 |
54 | let getCaption = ({dc_title, dc_spatial}) => dc_title + (dc_spatial ? `, ${dc_spatial}` : '');
55 |
56 | if (!beingEdited) {
57 | let {dc_title, dc_spatial, dc_creator, dc_translator, dc_isPartOf, dc_issued, dc_language} = editedMetadata;
58 | let formattedMetadata = (
59 | <>
60 |
61 | {getCaption({dc_title, dc_spatial})} {format(dc_creator, '(', ')')},
62 |
63 |
64 | {formatTranslation(dc_translator, dc_language)}
65 | {dc_isPartOf ? {dc_isPartOf}, : ''}
66 | {dc_issued ? `${new Date(dc_issued.toString()).getFullYear()}` : ''}
67 |
68 | >
69 | );
70 | if (editable) return (
71 | Edit metadata...} >
72 |
73 | {formattedMetadata}
74 |
75 |
76 | );
77 | return (
78 |
79 | {formattedMetadata}
80 |
81 | );
82 | }
83 | return (
84 |
89 | );
90 | }
91 | export default Metadata;
92 |
--------------------------------------------------------------------------------
/frontend/src/routes/Lectern.jsx:
--------------------------------------------------------------------------------
1 | import '../styles/Lectern.css';
2 | import DocumentNotFound from '../components/DocumentNotFound';
3 |
4 | import Container from 'react-bootstrap/Container';
5 | import Row from 'react-bootstrap/Row';
6 | import Col from 'react-bootstrap/Col';
7 | import { useState, useEffect } from 'react';
8 | import { useParams, useLocation } from 'react-router';
9 | import Context from '../context';
10 | import ParallelDocuments from '../parallelDocuments';
11 | import OpenedDocuments from '../components/OpenedDocuments';
12 | import DocumentsCards from '../components/DocumentsCards';
13 |
14 | function Lectern({backend, user}) {
15 |
16 | const [metadata, setMetadata] = useState(new Context());
17 | const [content, setContent] = useState([]);
18 | const [parallelDocuments, setParallelDocuments] = useState(new ParallelDocuments());
19 | const [lastUpdate, setLastUpdate] = useState();
20 | const [rawEditMode, setRawEditMode] = useState(false);
21 | const [loading, setLoading] = useState(true);
22 | let {id} = useParams();
23 | let margin = useLocation().hash.slice(1);
24 | const getCaption = ({dc_title, dc_spatial}) => [dc_title, dc_spatial].filter(Boolean).join(', ');
25 |
26 | if (metadata) {
27 | const sourceMetadata = metadata.focusedDocument;
28 | document.title = `${getCaption(sourceMetadata)} ${sourceMetadata.dc_creator ? `(${sourceMetadata.dc_creator})` : ''}`;
29 | }
30 |
31 | useEffect(() => {
32 | setLoading(true);
33 | backend.refreshMetadata(id, x => {
34 | setMetadata(new Context(id, x));
35 | setLoading(false);
36 | });
37 | backend.refreshContent(id, x => setContent(x));
38 | }, [id, lastUpdate, backend]);
39 |
40 | useEffect(() => {
41 | setParallelDocuments(new ParallelDocuments(id, content, margin, rawEditMode));
42 | }, [id, content, margin, rawEditMode, lastUpdate]);
43 |
44 | if (!metadata?.focusedDocument?._id && !loading) {
45 | return ;
46 | }
47 |
48 | const createOn = [...new Set([
49 | id,
50 | ...content
51 | .filter(({value}) => value.isPartOf === id)
52 | .map(({id, value}) => value._id || id)
53 | ])];
54 |
55 | return (
56 |
57 |
58 |
94 | {scholia.map((scholium, i) =>
95 |
96 | )}
97 |
98 | );
99 | }
100 |
101 | export default Passage;
102 |
--------------------------------------------------------------------------------
/frontend/src/README.md:
--------------------------------------------------------------------------------
1 | # Components structure
2 |
3 | Hyperglosae frontend is composed of the following React components:
4 |
5 | ## Bookshelf
6 |
7 | 
8 |
9 | Source: [routes/Bookshelf.jsx](https://github.com/Hypertopic/HyperGlosae/blob/main/frontend/src/routes/Bookshelf.jsx)
10 |
11 | Parts:
12 |
13 | - Graph
14 | - [FutureDocument](#futuredocument)
15 |
16 |
17 | ## Lectern
18 |
19 | 
20 | 
21 |
22 | Source: [routes/Lectern.jsx](https://github.com/Hypertopic/HyperGlosae/blob/main/frontend/src/routes/Lectern.jsx)
23 |
24 | Parts:
25 |
26 | - [DocumentsCards](#documentscards)
27 | - [OpenedDocuments](#openeddocuments)
28 |
29 | ## DocumentsCards
30 |
31 |
32 |
33 |
34 | Source: [components/DocumentsCards.jsx](https://github.com/Hypertopic/HyperGlosae/blob/main/frontend/src/components/DocumentsCards.jsx)
35 |
36 | Parts:
37 |
38 | - [BrowseTools](#browsetools)
39 | - [Metadata](#metadata)
40 | - TypeBadge
41 | - [FutureDocument](#futuredocument)
42 |
43 | ## OpenedDocuments
44 |
45 |
46 |
47 |
48 | Source: [components/OpenedDocuments.jsx](https://github.com/Hypertopic/HyperGlosae/blob/main/frontend/src/components/OpenedDocuments.jsx)
49 |
50 | Parts:
51 |
52 | - [BrowseTools](#browsetools)
53 | - [Metadata](#metadata)
54 | - TypeBadge
55 | - Type
56 | - [Passage](#passage)
57 |
58 | ## BrowseTools
59 |
60 |
61 |
62 |
63 | Source: [components/BrowseTools.jsx](https://github.com/Hypertopic/HyperGlosae/blob/main/frontend/src/components/BrowseTools.jsx)
64 |
65 | Parts: **none**
66 |
67 | ## Metadata
68 |
69 |
70 |
71 |
72 | Source: [components/Metadata.jsx](https://github.com/Hypertopic/HyperGlosae/blob/main/frontend/src/components/Metadata.jsx)
73 |
74 | Parts: **none**
75 |
76 | ## FutureDocument
77 |
78 |
79 |
80 |
81 |
82 | Source: [components/FutureDocument.jsx](https://github.com/Hypertopic/HyperGlosae/blob/main/frontend/src/components/FutureDocument.jsx)
83 |
84 | Parts: **none**
85 |
86 |
87 | ## Passage
88 |
89 |
90 |
91 |
92 |
93 | Source: [components/Passage.jsx](https://github.com/Hypertopic/HyperGlosae/blob/main/frontend/src/components/Passage.jsx)
94 |
95 | Parts:
96 |
97 | - [FormattedText](#formattedtext)
98 | - [EditableText](#editabletext)
99 |
100 | ## FormattedText
101 |
102 | Source: [components/FormattedText.jsx](https://github.com/Hypertopic/HyperGlosae/blob/main/frontend/src/components/FormattedText.jsx)
103 |
104 | Parts:
105 |
106 | - [EditableText](#editabletext)
107 |
108 | ## EditableText
109 |
110 | Source: [components/EditableText.jsx](https://github.com/Hypertopic/HyperGlosae/blob/main/frontend/src/components/EditableText.jsx)
111 |
112 | Parts:
113 |
114 | - [CroppedImage](#croppedimage)
115 | - [VideoComment](#videocomment)
116 |
117 | ## CroppedImage
118 |
119 | Source: [components/CroppedImage.jsx](https://github.com/Hypertopic/HyperGlosae/blob/main/frontend/src/components/CroppedImage.jsx)
120 |
121 | Parts: **none**
122 |
123 | ## VideoCommment
124 |
125 | Source: [components/VideoComment.jsx](https://github.com/Hypertopic/HyperGlosae/blob/main/frontend/src/components/VideoComment.jsx)
126 |
127 | Parts: **none**
128 |
--------------------------------------------------------------------------------
/frontend/scenarios/create_document.feature:
--------------------------------------------------------------------------------
1 | #language: fr
2 |
3 | Fonctionnalité: Essayer de créer un document
4 |
5 | Scénario: en tant que commentaire
6 |
7 | Soit un document existant affiché comme document principal
8 | Et une session active avec mon compte
9 | Quand j'essaie de créer une glose de type "Commentary"
10 | Alors la glose ouverte a le titre par défaut
11 |
12 | Scénario: en tant qu'adaptation
13 |
14 | Soit un document existant affiché comme document principal
15 | Et une session active avec mon compte
16 | Quand j'essaie de créer une glose de type "Adaptation"
17 | Alors la glose ouverte a le titre par défaut
18 |
19 | Scénario: en tant que collection
20 |
21 | Soit le document contenant l'image 2019_10-13_16_UKR_R_A affiché comme document principal
22 | Et une session active avec mon compte
23 | Quand j'essaie de créer une glose de type "Quotation"
24 | Alors je vois l'image "2019_10-13_16_UKR_R_A" dans la glose
25 |
26 |
27 | Scénario: en tant que composition partielle de texte
28 |
29 | Soit 'Les fées (Charles Perrault)' le document principal
30 | Et une session active avec mon compte
31 | Quand j'essaie de créer une glose de type "Quotation"
32 | Alors la glose contient :
33 | """
34 | Peuvent beaucoup sur les Esprits ;
35 | Cependant les douces paroles
36 | Ont encore plus de force, et sont d'un plus grand prix.
37 | """
38 |
39 | Scénario: à partir de zéro
40 |
41 | Soit la liste des documents affichée
42 | Et une session active avec mon compte
43 | Quand j'essaie de créer un nouveau document
44 | Alors la glose ouverte a le titre par défaut
45 |
46 | Scénario: sans être connecté
47 |
48 | Soit un document existant affiché comme document principal
49 | Quand j'essaie de créer un nouveau document
50 | Alors je peux lire "Before editing this document, please log in first"
51 |
52 | Scénario: en gardant certains éditeurs
53 |
54 | Soit un document existant affiché comme document principal
55 | Et une session active avec mon compte
56 | Et ayant parmi les éditeurs "bill" et "christophe"
57 | Quand j'essaie de créer une glose en gardant "bill" comme éditeur
58 | Alors la glose ouverte a "bill" parmi les éditeurs par défaut
59 |
60 | Scénario: en gardant tous les éditeurs
61 |
62 | Soit un document existant affiché comme document principal
63 | Et une session active avec mon compte
64 | Et ayant parmi les éditeurs "bill" et "christophe"
65 | Quand j'essaie de créer une glose en gardant tous les éditeurs
66 | Alors la glose ouverte a "bill" et "christophe" parmi les éditeurs par défaut
67 |
68 | Scénario: en gardant toutes les métadonnées du document source
69 |
70 | Soit "Vestiges (diagramme de classes)" le document principal
71 | Et une session active avec mon compte
72 | Et ayant les métadonnées
73 | """
74 | dc_creator: Aurélien Bénel
75 | dc_isPartOf: Archéologie préventive (IF14)
76 | dc_issued: 2019-10-01T15:50:42.624Z
77 | dc_language: french
78 | dc_license: https://creativecommons.org/licenses/by/4.0/
79 | dc_title: Vestiges (diagramme de classes)
80 | """
81 | Quand j'essaie de créer une glose en gardant les métadonnées du document source
82 | Alors la glose ouverte a les métadonnées
83 | """
84 | dc_creator: Aurélien Bénel
85 | dc_isPartOf: Archéologie préventive (IF14)
86 | dc_issued: 2019-10-01T15:50:42.624Z
87 | dc_language: french
88 | dc_license: https://creativecommons.org/licenses/by/4.0/
89 | dc_title: Vestiges (diagramme de classes)
90 | """
91 |
92 | Scénario: en gardant certaines des métadonnées du document source
93 |
94 | Soit "Vestiges (diagramme de classes)" le document principal
95 | Et une session active avec mon compte
96 | Et ayant les métadonnées
97 | """
98 | dc_creator: Aurélien Bénel
99 | dc_isPartOf: Archéologie préventive (IF14)
100 | dc_issued: 2019-10-01T15:50:42.624Z
101 | dc_language: french
102 | dc_license: https://creativecommons.org/licenses/by/4.0/
103 | dc_title: Vestiges (diagramme de classes)
104 | """
105 | Quand j'essaie de créer une glose en gardant la "dc_isPartOf" du document source
106 | Alors la glose ouverte a les métadonnées
107 | """
108 | dc_isPartOf: Archéologie préventive (IF14)
109 | """
110 |
--------------------------------------------------------------------------------
/frontend/src/parallelDocuments.js:
--------------------------------------------------------------------------------
1 | function ParallelDocuments(id, rawContent = [], margin, raw = false) {
2 |
3 | // Should have the same definition as in `backend/hyperglosae/src/lib/links.js`
4 | const parseText = (text) => {
5 | if (!text) return [];
6 | const PASSAGE = /{([^{]+)} ([^{]*)/g;
7 | let passages = [...text.matchAll(PASSAGE)];
8 | passages = (passages.length) ? passages : [[null, '0', text]];
9 | return passages.map(([_, rubric, passage]) => ({
10 | rubric,
11 | passage,
12 | parsed_rubric: rubric.match(/(?:(\d+)[:., ])?(\d+) ?([a-z]?)/)
13 | .slice(1)
14 | .filter(x => !!x)
15 | .map(x => {
16 | let n = Number(x) ;
17 | return Number.isNaN(n) ? x : n;
18 | })
19 | }));
20 | };
21 |
22 | const compareCompositeKeys = (a, b) =>
23 | a.key.reduce(
24 | (acc, x, i) =>
25 | acc !== 0 ? acc
26 | : i >= b.key.length ? 1
27 | : x < b.key[i] ? -1
28 | : x > b.key[i] ? 1 : 0,
29 | 0
30 | ) || (a.key.length - b.key.length);
31 |
32 | const content = rawContent
33 | .filter(
34 | x => [x.id, x.value.isPartOf].includes(id) || margin && [x.id, x.value.isPartOf].includes(margin)
35 | )
36 | .map(({id, key, value, doc}) => (doc)
37 | ? parseText(doc.text).map(
38 | ({parsed_rubric, passage, rubric}) => ({
39 | key: [key[0], ...parsed_rubric],
40 | value: {...value, text: passage, includedFrom: doc.isPartOf || doc._id, rubric, _id: null},
41 | ...doc.dc_title && {doc}
42 | }))
43 | : ({id, key, value})
44 | )
45 | .flat()
46 | .sort(compareCompositeKeys);
47 |
48 | let includedDocs = [...new Set(
49 | content
50 | .filter(({value}) => value.isPartOf === id)
51 | .map(({value}) => value.includedFrom)
52 | .filter(x => !!x))
53 | ];
54 |
55 | this.doesSourceHaveInclusions = content.some(x => x.value.inclusion);
56 |
57 | const hasRubrics = (doc_id) =>
58 | content.some(x => x.value.rubric !== '0' && x.value.isPartOf === doc_id && x.value.text);
59 |
60 | this.doesSourceHaveRubrics = hasRubrics(id);
61 |
62 | this.doesMarginHaveRubrics = hasRubrics(margin);
63 |
64 | const getCaption = ({dc_title, dc_spatial}) => [dc_title, dc_spatial].filter(Boolean).join(', ');
65 |
66 | const getText = ({doc, value}) => {
67 | let includedImage = (value.inclusion !== 'whole' ? '#' + value.inclusion : '')
68 | + ` "${doc ? getCaption(doc) : ''}"`;
69 | let imageReference = /!\[[^\]]*\]\([^)]+/;
70 | return value.text?.replace(imageReference, '$&' + includedImage);
71 | };
72 |
73 | this.isFromScratch = id === margin;
74 |
75 | const shouldBeAligned = !raw
76 | && this.doesSourceHaveRubrics
77 | && (!margin || this.doesMarginHaveRubrics);
78 |
79 | const xor = (x, y) => x !== y;
80 |
81 | const fill = (array, length, last) => [
82 | ...array,
83 | ...Array.from({length: length - array.length}),
84 | ...last ? [last] : []
85 | ];
86 |
87 | this.passages = content.reduce(({whole, part}, x, i, {length}) => {
88 | if (part.rubric && x.value.rubric !== part.rubric) {
89 | part.source = fill(part.source, includedDocs.length);
90 | whole.push(part);
91 | part = {source: [], scholia: []};
92 | }
93 | if (shouldBeAligned) {
94 | part.rubric = x.value.rubric;
95 | }
96 | let text = getText(x);
97 | if (text) {
98 | let isPartOf = x.value.isPartOf;
99 | if (!this.isFromScratch && isPartOf === id) {
100 | part.source = fill(part.source, includedDocs.indexOf(x.value.includedFrom), text);
101 | }
102 | if (xor(!this.isFromScratch, isPartOf === id)) {
103 | if (!raw || !part.scholia.length || part.scholia[part.scholia.length - 1].id !== x.id) {
104 | let rubric = x.value.rubric;
105 | part.scholia.push({id: x.id, text, isPartOf, ...(rubric !== '0' && {rubric})});
106 | }
107 | }
108 | }
109 | if (i === length - 1) {
110 | part.source = fill(part.source, includedDocs.length);
111 | return [...whole, part];
112 | }
113 | return {whole, part};
114 | }, {whole: [], part: {source: [], scholia: []}});
115 | this.passages = Array.isArray(this.passages) ? this.passages : [];
116 |
117 | return this;
118 | }
119 |
120 | export default ParallelDocuments;
121 |
--------------------------------------------------------------------------------
/frontend/src/components/EditableText.jsx:
--------------------------------------------------------------------------------
1 | import '../styles/EditableText.css';
2 |
3 | import { useState, useEffect, useCallback } from 'react';
4 | import FormattedText from './FormattedText';
5 | import DiscreeteDropdown from './DiscreeteDropdown';
6 | import PictureUploadAction from '../menu-items/PictureUploadAction';
7 | import {v4 as uuid} from 'uuid';
8 | import { OverlayTrigger, Tooltip } from 'react-bootstrap';
9 |
10 | function EditableText({id, text, rubric, isPartOf, links, fragment, setFragment, setHighlightedText, setSelectedText, rawEditMode, setRawEditMode, backend, setLastUpdate}) {
11 | const [beingEdited, setBeingEdited] = useState(false);
12 | const [editedDocument, setEditedDocument] = useState();
13 | const [editedText, setEditedText] = useState();
14 | const [hasBeenChanged, setHasBeenChanged] = useState(false);
15 | const PASSAGE = new RegExp(`\\{${rubric}} ?([^{]*)`);
16 |
17 | let parsePassage = (rawText) => (rubric)
18 | ? rawText.match(PASSAGE)[1]
19 | : rawText;
20 |
21 | let parseFirstPassage = useCallback((rawText) => {
22 | const FIRST_PASSAGE = new RegExp('\\{[^}]+} ?([^{]*)');
23 | let parsed = rawText.match(FIRST_PASSAGE);
24 | return (parsed) ? parsed[1] : rawText;
25 | }, []);
26 |
27 | let updateEditedDocument = useCallback(() => backend.getDocument(id)
28 | .then((x) => {
29 | x = x.error
30 | ? {_id: uuid(), text: `{${rubric}}`, isPartOf, links}
31 | : x;
32 | setEditedDocument(x);
33 | return x;
34 | }), [backend, id, isPartOf, links, rubric]);
35 |
36 | useEffect(() => {
37 | if (fragment) {
38 | updateEditedDocument()
39 | .then((x) => {
40 | let existingText = parseFirstPassage(x.text);
41 | setEditedText((existingText && `${existingText}\n\n`) + fragment + '…');
42 | setBeingEdited(true);
43 | setFragment();
44 | setHasBeenChanged(true);
45 | });
46 | }
47 | }, [fragment, parseFirstPassage, setFragment, updateEditedDocument]);
48 |
49 | useEffect(() => {
50 | if (rawEditMode) {
51 | updateEditedDocument()
52 | .then((x) => {
53 | setEditedText(x.text);
54 | setBeingEdited(true);
55 | });
56 | }
57 | }, [rawEditMode, updateEditedDocument]);
58 |
59 | let handleClick = () => {
60 | setBeingEdited(true);
61 | updateEditedDocument()
62 | .then((x) => {
63 | setEditedText(parsePassage(x.text));
64 | });
65 | };
66 |
67 | let handleImageUrl = (imageTag) => {
68 | backend.getDocument(id).then((editedDocument) => {
69 | let parsedText = parsePassage(editedDocument.text) + imageTag;
70 | let text = (rubric)
71 | ? editedDocument.text.replace(PASSAGE, `{${rubric}} ${parsedText}`)
72 | : parsedText;
73 | backend.putDocument({ ...editedDocument, text })
74 | .then(x => setLastUpdate(x.rev))
75 | .catch(console.error);
76 | });
77 | };
78 |
79 | let handleChange = (event) => {
80 | setHasBeenChanged(true);
81 | setEditedText(event.target.value);
82 | };
83 |
84 | let handleBlur = () => {
85 | if (!hasBeenChanged) {
86 | if (!rawEditMode) {
87 | setHighlightedText();
88 | setBeingEdited(false);
89 | }
90 | return;
91 | }
92 | let parsedText = parseFirstPassage(editedText);
93 | let text = (rubric && !rawEditMode)
94 | ? editedDocument.text.replace(PASSAGE, `{${rubric}} ${parsedText}`)
95 | : editedText;
96 | backend.putDocument({ ...editedDocument, text })
97 | .then(x => setLastUpdate(x.rev))
98 | .then(() => setHighlightedText())
99 | .then(() => setBeingEdited(false))
100 | .then(() => setHasBeenChanged(false))
101 | .then(() => setRawEditMode(false))
102 | .catch(console.error);
103 | };
104 |
105 | if (!beingEdited) return (
106 |
107 | Edit content...}
110 | >
111 |
112 |
113 | {text || ' '}
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 | );
122 | return (
123 |
128 | );
129 | }
130 |
131 | export default EditableText;
132 |
--------------------------------------------------------------------------------
/samples/hyperglosae/perrault_1886_content.json:
--------------------------------------------------------------------------------
1 | {
2 | "_id": "ba6ccf72716811ed85c80749207200a2",
3 | "isPartOf": "37b4b9ba5cdb11ed887beb5c373fa643",
4 | "text": "{1} Il était une fois une veuve qui avait deux filles ; l'aînée lui ressemblait si fort et d'humeur et de visage que, qui la voyait, voyait la mère. Elles étaient toutes deux si désagréables et si orgueilleuses qu'on ne pouvait vivre avec elles. La cadette, qui était le vrai portrait de son père pour la douceur et l'honnêteté, était avec cela une des plus belles filles qu'on eût su voir.\nComme on aime naturellement son semblable, cette mère était folle de sa fille aînée, et en même temps avait une aversion effroyable pour la cadette. Elle la faisait manger à la cuisine et travailler sans cesse.\n{2} Il fallait, entre autres choses, que cette pauvre enfant allât, deux fois le jour, puiser de l’eau à une grande demi-lieue du logis, et qu’elle en rapportât plein une grande cruche. Un jour qu’elle était à cette fontaine, il vint à elle une pauvre femme qui la pria de lui donner à boire.\n{3} « Oui dà, ma bonne mère, » lui dit la jeune fille ; et, rinçant aussitôt sa cruche, elle puisa de l’eau au plus bel endroit de la fontaine et la lui présenta, soutenant toujours la cruche, afin qu’elle bût plus aisément.\n{4} La bonne femme, ayant bu, lui dit :\n« Vous êtes si belle, si bonne et si honnête, que je ne puis m’empêcher de vous faire un don ; car c’était une fée qui avait pris la forme d’une pauvre femme de village, pour voir jusqu’où irait l’honnêteté de cette jeune fille. Je vous donne pour don, poursuivit la fée, qu’à chaque parole que vous direz, il vous sortira de la bouche ou une fleur, ou une pierre précieuse. »\n{5} Lorsque cette belle fille arriva au logis, sa mère la gronda de revenir si tard de la fontaine.\n— « Je vous demande pardon, ma mère, dit cette pauvre fille, d’avoir tardé si longtemps ; » — et, en disant ces mots, il lui sortit de la bouche deux roses, deux perles et deux gros diamants.\n — « Que vois-je là ! dit sa mère tout étonnée ; je crois qu’il lui sort de la bouche des perles et des diamants. D’où vient cela, ma fille ? » (Ce fut là la première fois qu’elle l’appela sa fille).\nLa pauvre enfant lui raconta naïvement tout ce qui lui était arrivé, non sans jeter une infinité de diamants.\n{6} — « Vraiment, dit la mère, il faut que j’y envoie ma fille. Tenez, Fanchon, voyez ce qui sort de la bouche de votre sœur, quand elle parle ; ne seriez-vous pas bien aise d’avoir le même don ? Vous n’avez qu’à aller puiser de l’eau à la fontaine, et, quand une pauvre femme vous demandera à boire, lui en donner bien honnêtement.\n— Il me ferait beau voir, répondit la brutale, aller à la fontaine !\n— Je veux que vous y alliez, reprit la mère, et tout à l’heure. »\nElle y alla, mais toujours en grondant. Elle prit le plus beau flacon d’argent qui fût dans le logis. Elle ne fut pas plus tôt arrivée à la fontaine, qu’elle vit sortir du bois une dame magnifiquement vêtue, qui vint lui demander à boire. C’était la même fée qui avait apparu à sa sœur, mais qui avait pris l’air et les habits d’une princesse, pour voir jusqu’où irait la malhonnêteté de cette fille.\n{7} — Est-ce que je suis ici venue, lui dit cette brutale orgueilleuse, pour vous donner à boire ! Justement j’ai apporté un flacon d’argent tout exprès pour donner à boire à Madame ? J’en suis d’avis : buvez à même si vous voulez.\n— Vous n’êtes guère honnête, reprit la fée, sans se mettre en colère. Eh bien ! puisque vous êtes si peu obligeante, je vous donne pour don qu’à chaque parole que vous direz, il vous sortira de la bouche ou un serpent, ou un crapaud. »\nD’abord que sa mère l’aperçut, elle lui cria :\n« Eh bien ! ma fille !\n— Eh bien ! ma mère ! lui répondit la brutale, en jetant deux vipères et deux crapauds.\n— Ô ciel, s’écria la mère, que vois-je là ? C’est sa sœur qui en est cause : elle me le paiera ; et aussitôt elle courut pour la battre.\nLa pauvre enfant s’enfuit et alla se sauver dans la forêt prochaine.\nLe fils du roi, qui revenait de la chasse, la rencontra et, la voyant si triste, lui demanda ce qu’elle faisait là toute seule et ce qu’elle avait à pleurer !\n« Hélas ! Monsieur, c’est ma mère qui m’a chassée du logis. »\n{8} Le fils du roi, qui vit sortir de sa bouche cinq ou six perles et autant de diamants, la pria de lui dire d’où cela lui venait. Elle lui conta toute son aventure. Le fils du roi considérant qu’un tel don valait mieux que tout ce qu’on pouvait donner en mariage à une autre, l’emmena au palais du roi son père, où il l’épousa.\nPour sa sœur, elle se fit tant haïr, que sa propre mère la chassa de chez elle ; et la malheureuse, après avoir bien couru sans trouver personne qui voulût la recevoir, alla mourir au coin d’un bois.\n{9} MORALITÉ\nLes diamants et les pistoles,\nPeuvent beaucoup sur les Esprits ;\nCependant les douces paroles\nOnt encore plus de force, et sont d'un plus grand prix.",
5 | "links": [{
6 | "verb": "adapts",
7 | "object": "02ee00d85cdb11ed834c4fb9e3c972af"
8 | }]
9 | }
10 |
--------------------------------------------------------------------------------
/samples/hyperglosae/perrault_1697_content.json:
--------------------------------------------------------------------------------
1 | {
2 | "_id": "7221e95a716811ed8a16f363ea2d1350",
3 | "isPartOf": "02ee00d85cdb11ed834c4fb9e3c972af",
4 | "text": "{1} Il estoit une fois une veuve qui avoit deux filles : l’aînée luy ressembloit si fort et d’humeur et de visage que qui la voyoit voyoit la mere. Elles estoient toutes deux si desagréables et si orgueilleuses qu’on ne pouvoit vivre avec elles. La cadette, qui estoit le vray portrait de son pere pour la douceur et l’honnesteté, estoit avec cela une des plus belles filles qu’on eust sceu voir. Comme on aime naturellement son semblable, cette mere estoit folle de sa fille aînée, et, en même temps, avoit une aversion effroyable pour la cadette. Elle la faisoit manger à la cuisine et travailler sans cesse.\n{2} Il falloit, entre autre-chose, que cette pauvre enfant allast, deux fois le jour, puiser de l’eau à une grande demy-lieuë du logis, et qu’elle en raportast plein une grande cruche. Un jour qu’elle estoit à cette fontaine, il vint à elle une pauvre femme qui la pria de luy donner à boire.\n{3} « Ouy da, ma bonne mere », dit cette belle fille ; et, rinçant aussi tost sa cruche, elle puisa de l’eau au plus bel endroit de la fontaine et la lui presenta, soûtenant toûjours la cruche, afin qu’elle bût plus aisément.\n{4} La bonne femme, ayant bû, luy dit :\n« Vous estes si belle, si bonne et si honneste, que je ne puis m’empêcher de vous faire un don (car c’estoit une fée qui avoit pris la forme d’une pauvre femme de village, pour voir jusqu’où iroit l’honnesteté de cette jeune fille). Je vous donne pour don, poursuivit la fée, qu’à chaque parole que vous direz, il vous sortira de la bouche ou une fleur, ou une pierre précieuse. »\n{5} Lorsque cette belle fille arriva au logis, sa mere la gronda de revenir si tard de la fontaine.\n« Je vous demande pardon, ma mere, dit cette pauvre fille, d’avoir tardé si long-temps » ; et, en disant ces mots, il luy sortit de la bouche deux roses, deux perles et deux gros diamans.\n« Que voy-je là ? dit sa mere tout estonnée ; je crois qu’il luy sort de la bouche des perles et des diamants. D’où vient cela, ma fille ? » (Ce fut là la premiere fois qu’elle l’appela sa fille.)\nLa pauvre enfant luy raconta naïvement tout ce qui luy estoit arrivé, non sans jetter une infinité de diamants.\n{6} « Vrayment, dit la mere, il faut que j’y envoye ma fille. Tenez, Fanchon, voyez ce qui sort de la bouche de vôtre sœur quand elle parle ; ne seriez-vous pas bien aise d’avoir le mesme don ? Vous n’avez qu’à aller puiser de l’eau à la fontaine, et, quand une pauvre femme vous demandera à boire, luy en donner bien honnestement.\n— Il me feroit beau voir, répondit la brutale, aller à la fontaine !\n— Je veux que vous y alliez, reprit la mere, et tout à l’heure. »\nElle y alla, mais toûjours en grondant. Elle prit le plus beau flacon d’argent qui fut dans le logis. Elle ne fut pas plustost arrivée à la fontaine qu’elle vit sortir du bois une dame magnifiquement vestuë, qui vint luy demander à boire. C’estoit la même fée qui avoit apparu à sa sœur, mais qui avoit pris l’air et les habits d’une princesse, pour voir jusqu’où iroit la malhonnesteté de cette fille.\n{7} « Est-ce que je suis icy venuë, luy dit cette brutale orgueileuse, pour vous donner à boire ! Justement j’ai apporté un flacon d’argent tout exprés pour donner à boire à Madame ! J’en suis d’avis : beuvez à même si vous voulez.\n— Vous n’estes guere honneste, reprit la fée sans se mettre en colere. Et bien ! puisque vous estes si peu obligeante, je vous donne pour don qu’à chaque parole que vous direz, il vous sortira de la bouche ou un serpent,\nou un crapau. »\nD’abord que sa mere l’aperceut, elle luy cria :\n« Hé bien ! ma fille !\n— Hé bien ! ma mere ? luy repondit la brutale en jettant deux viperes et deux crapaus.\n— O Ciel, s’écria la mere, que vois-je là ? C’est sa sœur qui en est cause : elle me le payera. » Et aussi tost elle courut pour la battre.\nLa pauvre enfant s’enfuit et alla se sauver dans la forest prochaine. Le fils du roi, qui revenoit de la chasse, la rencontra, et, la voyant si belle, luy demanda ce qu’elle faisoit là toute seule et ce qu’elle avoit à pleurer.\n« Helas ! Monsieur, c’est ma mere qui m’a chassée du logis. »\n{8} Le fils du roi, qui vit sortir de sa bouche cinq ou six perles et autant de diamants, la pria de luy dire d’où cela luy venoit. Elle luy conta toute son avanture. Le fils du roi en devint amoureux, et, considerant qu’un tel don valoit mieux que tout ce qu’on pouvoit donner en mariage à une autre, l’emmena au palais du roi son pere, où il l’épousa.\nPour sa sœur, elle se fit tant haïr que sa propre mere la chassa de chez elle ; et la malheureuse, aprés avoir bien couru sans trouver personne qui voulut la recevoir, alla mourir au coin d’un bois.\n{9} MORALITÉ\nLes diamans et les pistoles\nPeuvent beaucoup sur les esprits ;\nCependant les douces paroles\nOnt encor plus de force, et sont d’un plus grand prix.\n{10} AUTRE MORALITÉ\nL’honnesteté couste des soins,\nEt veut un peu de complaisance ;\nMais tost ou tard elle a sa récompense,\nEt souvent dans le temps qu’on y pense le moins."
5 | }
6 |
--------------------------------------------------------------------------------
/frontend/src/components/Graph.jsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from 'react';
2 | import { select, schemeCategory10, forceSimulation, forceLink, forceManyBody, forceX, forceY, scaleOrdinal, drag } from 'd3';
3 | import { legendColor } from 'd3-svg-legend';
4 |
5 | function Graph({ rawDocs, displayedDocs }) {
6 | const svgRef = useRef();
7 | let width = window.innerWidth;
8 | const height = width * 0.7;
9 |
10 | useEffect(() => {
11 | let docs = rawDocs || [];
12 | const types = {
13 | adapts: 'is adapted as',
14 | refersTo: 'is referred by',
15 | includes: 'is included in',
16 | };
17 | const typeColorScale = scaleOrdinal(Object.values(types), schemeCategory10);
18 | const nodes = docs.map(([id, title]) => ({id, title}));
19 | const links = docs.flatMap(d =>(d[2] || [])
20 | .filter(l => displayedDocs.includes(l.object.split('#')[0]))
21 | .map(l => ({
22 | source: l.object.split('#')[0],
23 | target: d[0],
24 | type: l.verb,
25 | inverseType: types[l.verb] || `${l.verb} (inverse)`
26 | }))
27 | );
28 |
29 | const simulation = forceSimulation(nodes)
30 | .force('link', forceLink(links).id(d => d.id))
31 | .force('charge', forceManyBody().strength(-500))
32 | .force('x', forceX(width / 2.5))
33 | .force('y', forceY(height / 2.5));
34 |
35 | const svgContainer = select(svgRef.current);
36 | const svg = svgContainer
37 | .append('svg')
38 | .attr('className', 'graph')
39 | .attr('viewBox', [0, 0 / 3, width, height])
40 | .attr('preserveAspectRatio', 'xMidYMid meet')
41 | .attr('style', 'font: 12px sans-serif;');
42 |
43 | svg.append('defs').selectAll('marker')
44 | .data(links)
45 | .join('marker')
46 | .attr('id', d => `arrow-${d.type}-inverse`)
47 | .attr('viewBox', '0 -5 10 10')
48 | .attr('refX', 15)
49 | .attr('refY', -0.5)
50 | .attr('markerWidth', 6)
51 | .attr('markerHeight', 6)
52 | .attr('orient', 'auto')
53 | .append('path')
54 | .attr('fill', d => typeColorScale(d.inverseType))
55 | .attr('d', 'M0,-5L10,0L0,5');
56 |
57 | const link = svg.append('g')
58 | .attr('fill', 'none')
59 | .attr('stroke-width', 1.5)
60 | .selectAll('path')
61 | .data(links)
62 | .join('path')
63 | .attr('stroke', d => typeColorScale(d.inverseType))
64 | .attr('marker-end', d => `url(${new URL(`#arrow-${d.type}-inverse`, window.location)})`);
65 |
66 | const node = svg.append('g')
67 | .attr('fill', 'currentColor')
68 | .attr('stroke-linecap', 'round')
69 | .attr('stroke-linejoin', 'round')
70 | .selectAll('g')
71 | .data(nodes)
72 | .join('g')
73 | .call(setDraggable(simulation));
74 |
75 | node.append('circle')
76 | .attr('stroke', 'white')
77 | .attr('stroke-width', 1.5)
78 | .attr('r', 4);
79 |
80 | node.append('a')
81 | .attr('href', d => `../${d.id}`)
82 | .attr('style', 'text-decoration: none; color: black !important;')
83 | .append('text')
84 | .text(d => d.title)
85 | .attr('x', 8)
86 | .attr('y', '0.31em');
87 |
88 | svg.append('g')
89 | .attr('class', 'legendLinear');
90 |
91 | let legendLinear = legendColor()
92 | .shapeWidth(30)
93 | .orient('vertical')
94 | .scale(typeColorScale)
95 | .labels(Object.values(types));
96 | svg.select('.legendLinear')
97 | .call(legendLinear);
98 |
99 | try {
100 | simulation.on('tick', () => {
101 | link.attr('d', drawLinkArc);
102 | node.attr('transform', d => `translate(${d.x},${d.y})`);
103 | });
104 | } catch (error) {
105 | simulation.stop();
106 | console.log(error);
107 | }
108 |
109 | return () => {
110 | svgContainer.selectAll('*').remove();
111 | };
112 | }, [displayedDocs, rawDocs, height, width]);
113 |
114 | if (!rawDocs) {
115 | return