synchro
[redakcja.git] / src / documents / views.py
1 # This file is part of FNP-Redakcja, licensed under GNU Affero GPLv3 or later.
2 # Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information.
3 #
4 from collections import defaultdict
5 from copy import deepcopy
6 from datetime import datetime, date, timedelta
7 from itertools import zip_longest
8 import json
9 import logging
10 import os
11 from urllib.parse import quote_plus, unquote, urlsplit, urlunsplit
12
13 from django.conf import settings
14 from django.contrib import auth
15 from django.contrib.auth.models import User
16 from django.contrib.auth.decorators import login_required, permission_required
17 from django.urls import reverse
18 from django.db.models import Count, Q
19 from django.db import transaction
20 from django import http
21 from django.http import Http404, HttpResponse, HttpResponseForbidden
22 from django.http.response import HttpResponseRedirect
23 from django.shortcuts import get_object_or_404, render
24 from django.utils.encoding import iri_to_uri
25 from django.utils.translation import gettext_lazy as _
26 from django.views.decorators.http import require_POST
27 from django_cas_ng.decorators import user_passes_test
28 import requests
29 from lxml import etree
30
31 from librarian import epubcheck
32 from librarian.html import raw_printable_text
33
34 from apiclient import api_call, NotAuthorizedError
35 from . import forms
36 from . import helpers
37 from .helpers import active_tab
38 from .models import (Book, Chunk, Image, BookPublishRecord, 
39         ChunkPublishRecord, ImagePublishRecord, Project)
40 import catalogue.models
41 from fileupload.views import UploadView
42
43 #
44 # Quick hack around caching problems, TODO: use ETags
45 #
46 from django.views.decorators.cache import never_cache
47
48 logger = logging.getLogger("fnp.documents")
49
50
51 @active_tab('all')
52 @never_cache
53 def document_list(request):
54     return render(request, 'documents/document_list.html')
55
56
57 @active_tab('images')
58 @never_cache
59 def image_list(request, user=None):
60     return render(request, 'documents/image_list.html')
61
62
63 @never_cache
64 def user(request, username):
65     user = get_object_or_404(User, username=username)
66     return render(request, 'documents/user_page.html', {"viewed_user": user})
67
68
69 @login_required
70 @active_tab('my')
71 @never_cache
72 def my(request):
73     last_books = sorted(request.session.get("wiki_last_books", {}).items(),
74         key=lambda x: x[1]['time'], reverse=True)
75     for k, v in last_books:
76         v['time'] = datetime.fromtimestamp(v['time'])
77     try:
78         resp = api_call(request.user, 'username/')
79     except NotAuthorizedError:
80         wllogin = None
81     else:
82         wllogin = resp['username']
83
84     return render(request, 'documents/my_page.html', {
85         'last_books': last_books,
86         "logout_to": '/',
87         "wllogin": wllogin,
88         })
89
90
91 @active_tab('users')
92 def users(request):
93     return render(request, 'documents/user_list.html', {
94         'users': User.objects.all().annotate(count=Count('chunk')).order_by(
95             '-count', 'last_name', 'first_name'),
96     })
97
98
99 @active_tab('activity')
100 def activity(request, isodate=None):
101     today = date.today()
102     try:
103         day = helpers.parse_isodate(isodate)
104     except ValueError:
105         day = today
106
107     if day > today:
108         raise Http404
109     if day != today:
110         next_day = day + timedelta(1)
111     prev_day = day - timedelta(1)
112
113     return render(request, 'documents/activity.html', locals())
114
115
116 @never_cache
117 def logout_then_redirect(request):
118     auth.logout(request)
119     return http.HttpResponseRedirect(quote_plus(request.GET.get('next', '/'), safe='/?='))
120
121
122 @permission_required('documents.add_book')
123 @active_tab('create')
124 def create_missing(request, slug=None):
125     if slug is None:
126         slug = ''
127     slug = slug.replace(' ', '-')
128
129     if request.method == "POST":
130         form = forms.DocumentCreateForm(request.POST, request.FILES)
131         if form.is_valid():
132             
133             if request.user.is_authenticated:
134                 creator = request.user
135             else:
136                 creator = None
137             book = Book.create(
138                 text=form.cleaned_data['text'],
139                 creator=creator,
140                 slug=form.cleaned_data['slug'],
141                 title=form.cleaned_data['title'],
142                 gallery=form.cleaned_data['gallery'],
143             )
144
145             return http.HttpResponseRedirect(reverse("documents_book", args=[book.slug]))
146     else:
147         form = forms.DocumentCreateForm(initial={
148                 "slug": slug,
149                 "title": slug.replace('-', ' ').title(),
150                 "gallery": slug,
151         })
152
153     return render(request, "documents/document_create_missing.html", {
154         "slug": slug,
155         "form": form,
156
157         "logout_to": '/',
158     })
159
160
161 @permission_required('documents.add_book')
162 @active_tab('upload')
163 def upload(request):
164     if request.method == "POST":
165         form = forms.DocumentsUploadForm(request.POST, request.FILES)
166         if form.is_valid():
167             from slugify import slugify
168
169             if request.user.is_authenticated:
170                 creator = request.user
171             else:
172                 creator = None
173
174             zip = form.cleaned_data['zip']
175             skipped_list = []
176             ok_list = []
177             error_list = []
178             slugs = {}
179             existing = [book.slug for book in Book.objects.all()]
180             for filename in zip.namelist():
181                 if filename[-1] == '/':
182                     continue
183                 title = os.path.basename(filename)[:-4]
184                 slug = slugify(title)
185                 if not (slug and filename.endswith('.xml')):
186                     skipped_list.append(filename)
187                 elif slug in slugs:
188                     error_list.append((filename, slug, _('Slug already used for %s' % slugs[slug])))
189                 elif slug in existing:
190                     error_list.append((filename, slug, _('Slug already used in repository.')))
191                 else:
192                     try:
193                         zip.read(filename).decode('utf-8') # test read
194                         ok_list.append((filename, slug, title))
195                     except UnicodeDecodeError:
196                         error_list.append((filename, title, _('File should be UTF-8 encoded.')))
197                     slugs[slug] = filename
198
199             if not error_list:
200                 for filename, slug, title in ok_list:
201                     book = Book.create(
202                         text=zip.read(filename).decode('utf-8'),
203                         creator=creator,
204                         slug=slug,
205                         title=title,
206                     )
207
208             return render(request, "documents/document_upload.html", {
209                 "form": form,
210                 "ok_list": ok_list,
211                 "skipped_list": skipped_list,
212                 "error_list": error_list,
213
214                 "logout_to": '/',
215             })
216     else:
217         form = forms.DocumentsUploadForm()
218
219     return render(request, "documents/document_upload.html", {
220         "form": form,
221
222         "logout_to": '/',
223     })
224
225
226 def serve_xml(request, book, slug):
227     if not book.accessible(request):
228         return HttpResponseForbidden("Not authorized.")
229     xml = book.materialize(publishable=True)
230     response = http.HttpResponse(xml, content_type='application/xml')
231     response['Content-Disposition'] = 'attachment; filename=%s.xml' % slug
232     return response
233
234
235 @never_cache
236 def book_xml(request, slug):
237     book = get_object_or_404(Book, slug=slug)
238     return serve_xml(request, book, slug)
239
240
241 @never_cache
242 def book_xml_dc(request, slug):
243     book = get_object_or_404(Book, dc_slug=slug)
244     return serve_xml(request, book, slug)
245
246
247 @never_cache
248 def book_txt(request, slug):
249     book = get_object_or_404(Book, slug=slug)
250     if not book.accessible(request):
251         return HttpResponseForbidden("Not authorized.")
252
253     doc = book.wldocument()
254     text = doc.as_text().get_bytes()
255     response = http.HttpResponse(text, content_type='text/plain')
256     response['Content-Disposition'] = 'attachment; filename=%s.txt' % slug
257     return response
258
259
260 @never_cache
261 def book_html(request, slug):
262     book = get_object_or_404(Book, slug=slug)
263     if not book.accessible(request):
264         return HttpResponseForbidden("Not authorized.")
265
266     doc = book.wldocument(parse_dublincore=False)
267     html = doc.as_html(options={'gallery': "'%s'" % book.gallery_url()})
268
269     html = html.get_bytes().decode('utf-8') if html is not None else ''
270     # response = http.HttpResponse(html, content_type='text/html')
271     # return response
272     # book_themes = {}
273     # for fragment in book.fragments.all().iterator():
274     #     for theme in fragment.tags.filter(category='theme').iterator():
275     #         book_themes.setdefault(theme, []).append(fragment)
276
277     # book_themes = book_themes.items()
278     # book_themes.sort(key=lambda s: s[0].sort_key)
279     return render(request, 'documents/book_text.html', locals())
280
281
282 @login_required
283 @never_cache
284 def book_pdf(request, slug, mobile=False):
285     book = get_object_or_404(Book, slug=slug)
286     if not book.accessible(request):
287         return HttpResponseForbidden("Not authorized.")
288
289     # TODO: move to celery
290     doc = book.wldocument()
291     # TODO: error handling
292     customizations = ['26pt', 'nothemes', 'nomargins', 'notoc'] if mobile else None
293     pdf_file = doc.as_pdf(cover=True, base_url=request.build_absolute_uri(book.gallery_path()), customizations=customizations)
294     from .ebook_utils import serve_file
295     return serve_file(pdf_file.get_filename(),
296                 book.slug + '.pdf', 'application/pdf')
297
298
299 @login_required
300 @never_cache
301 def book_epub(request, slug):
302     book = get_object_or_404(Book, slug=slug)
303     if not book.accessible(request):
304         return HttpResponseForbidden("Not authorized.")
305
306     # TODO: move to celery
307     doc = book.wldocument(librarian2=True)
308     # TODO: error handling
309
310     from librarian.builders import EpubBuilder
311     epub = EpubBuilder(
312         base_url='file://' + book.gallery_path() + '/',
313         debug=True
314     ).build(doc).get_bytes()
315     response = HttpResponse(content_type='application/epub+zip')
316     response['Content-Disposition'] = 'attachment; filename=%s' % book.slug + '.epub'
317     response.write(epub)
318     return response
319
320
321 @login_required
322 @never_cache
323 def book_epubcheck(request, slug):
324     book = get_object_or_404(Book, slug=slug)
325     if not book.accessible(request):
326         return HttpResponseForbidden("Not authorized.")
327
328     # TODO: move to celery
329     doc = book.wldocument(librarian2=True)
330     # TODO: error handling
331
332     from librarian.builders import EpubBuilder
333     epub = EpubBuilder(
334         base_url='file://' + book.gallery_path() + '/',
335         debug=True
336     ).build(doc)
337     fname = epub.get_filename()
338
339     messages = epubcheck.epubcheck(fname)
340     for message in messages:
341         for location in message.get('locations', []):
342             if 'wl_chunk' in location:
343                 location['wl_chunk'] = book[location['wl_chunk']]
344     return render(request, 'documents/book_epubcheck.html', {
345         'messages': messages,
346         'book': book,
347     })
348
349
350 @login_required
351 @never_cache
352 def book_mobi(request, slug):
353     book = get_object_or_404(Book, slug=slug)
354     if not book.accessible(request):
355         return HttpResponseForbidden("Not authorized.")
356
357     # TODO: move to celery
358     doc = book.wldocument(librarian2=True)
359     # TODO: error handling
360     from librarian.builders import MobiBuilder
361     mobi = MobiBuilder(
362         base_url='file://' + book.gallery_path() + '/'
363     ).build(doc).get_bytes()
364     response = HttpResponse(content_type='application/x-mobipocket-ebook')
365     response['Content-Disposition'] = 'attachment; filename=%s' % book.slug + '.mobi'
366     response.write(mobi)
367     return response
368
369
370 @never_cache
371 def revision(request, slug, chunk=None):
372     try:
373         doc = Chunk.get(slug, chunk)
374     except (Chunk.MultipleObjectsReturned, Chunk.DoesNotExist):
375         raise Http404
376     if not doc.book.accessible(request):
377         return HttpResponseForbidden("Not authorized.")
378     return http.HttpResponse(str(doc.revision()))
379
380
381 def book(request, slug):
382     book = get_object_or_404(Book, slug=slug)
383     if not book.accessible(request):
384         return HttpResponseForbidden("Not authorized.")
385
386     if request.user.has_perm('documents.change_book'):
387         if request.method == "POST":
388             form = forms.BookForm(request.POST, instance=book)
389             if form.is_valid():
390                 form.save()
391                 return http.HttpResponseRedirect(book.get_absolute_url())
392         else:
393             form = forms.BookForm(instance=book)
394         publish_options_form = forms.PublishOptionsForm()
395         editable = True
396     else:
397         form = forms.ReadonlyBookForm(instance=book)
398         publish_options_form = forms.PublishOptionsForm()
399         editable = False
400
401     publish_error = book.publishable_error()
402     publishable = publish_error is None
403
404     stats = None
405     try:
406         doc = book.wldocument(librarian2=True)
407     except:
408         doc = None
409     else:
410         try:
411             stats = doc.get_statistics()
412         except:
413             pass
414
415     cbook_by_slug = None
416     if book.dc_slug:
417         audio_items = requests.get(f'https://audio.wolnelektury.pl/archive/book/{book.dc_slug}.json').json()['items']
418         has_audio = bool(audio_items)
419         can_sell_audio = has_audio and all(x['project']['can_sell'] for x in audio_items)
420
421         if book.catalogue_book is None or book.dc_slug != book.catalogue_book.slug:
422             cbook_by_slug = catalogue.models.Book.objects.filter(slug=book.dc_slug).first()
423     else:
424         has_audio = None
425         can_sell_audio = None
426
427     return render(request, "documents/book_detail.html", {
428         "book": book,
429         "doc": doc,
430         "stats": stats,
431         "publishable": publishable,
432         "publishable_error": publish_error,
433         "form": form,
434         "publish_options_form": publish_options_form,
435         "editable": editable,
436         "has_audio": has_audio,
437         "can_sell_audio": can_sell_audio,
438         "cbook_by_slug": cbook_by_slug,
439     })
440
441
442 def image(request, slug):
443     image = get_object_or_404(Image, slug=slug)
444     if not image.accessible(request):
445         return HttpResponseForbidden("Not authorized.")
446
447     if request.user.has_perm('documents.change_image'):
448         if request.method == "POST":
449             form = forms.ImageForm(request.POST, instance=image)
450             if form.is_valid():
451                 form.save()
452                 return http.HttpResponseRedirect(image.get_absolute_url())
453         else:
454             form = forms.ImageForm(instance=image)
455         editable = True
456     else:
457         form = forms.ReadonlyImageForm(instance=image)
458         editable = False
459
460     publish_error = image.publishable_error()
461     publishable = publish_error is None
462
463     return render(request, "documents/image_detail.html", {
464         "object": image,
465         "publishable": publishable,
466         "publishable_error": publish_error,
467         "form": form,
468         "editable": editable,
469     })
470
471
472 @permission_required('documents.add_chunk')
473 def chunk_add(request, slug, chunk):
474     try:
475         doc = Chunk.get(slug, chunk)
476     except (Chunk.MultipleObjectsReturned, Chunk.DoesNotExist):
477         raise Http404
478     if not doc.book.accessible(request):
479         return HttpResponseForbidden("Not authorized.")
480
481     if request.method == "POST":
482         form = forms.ChunkAddForm(request.POST, instance=doc)
483         if form.is_valid():
484             if request.user.is_authenticated:
485                 creator = request.user
486             else:
487                 creator = None
488             doc.split(creator=creator,
489                 slug=form.cleaned_data['slug'],
490                 title=form.cleaned_data['title'],
491                 gallery_start=form.cleaned_data['gallery_start'],
492                 user=form.cleaned_data['user'],
493                 stage=form.cleaned_data['stage']
494             )
495
496             return http.HttpResponseRedirect(doc.book.get_absolute_url())
497     else:
498         form = forms.ChunkAddForm(initial={
499                 "slug": str(doc.number + 1),
500                 "title": "cz. %d" % (doc.number + 1, ),
501         })
502
503     return render(request, "documents/chunk_add.html", {
504         "chunk": doc,
505         "form": form,
506     })
507
508
509 @login_required
510 def chunk_edit(request, slug, chunk):
511     try:
512         doc = Chunk.get(slug, chunk)
513     except (Chunk.MultipleObjectsReturned, Chunk.DoesNotExist):
514         raise Http404
515     if not doc.book.accessible(request):
516         return HttpResponseForbidden("Not authorized.")
517
518     if request.method == "POST":
519         form = forms.ChunkForm(request.POST, instance=doc)
520         if form.is_valid():
521             form.save()
522             go_next = request.GET.get('next', None)
523             if go_next:
524                 go_next = quote_plus(unquote(iri_to_uri(go_next)), safe='/?=&')
525             else:
526                 go_next = doc.book.get_absolute_url()
527             return http.HttpResponseRedirect(go_next)
528     else:
529         form = forms.ChunkForm(instance=doc)
530
531     referer = request.META.get('HTTP_REFERER')
532     if referer:
533         parts = urlsplit(referer)
534         parts = ['', ''] + list(parts[2:])
535         go_next = quote_plus(urlunsplit(parts))
536     else:
537         go_next = ''
538
539     return render(request, "documents/chunk_edit.html", {
540         "chunk": doc,
541         "form": form,
542         "go_next": go_next,
543     })
544
545
546 @transaction.atomic
547 @login_required
548 @require_POST
549 def chunk_mass_edit(request):
550     ids = [int(i) for i in request.POST.get('ids').split(',') if i.strip()]
551     chunks = list(Chunk.objects.filter(id__in=ids))
552     
553     stage = request.POST.get('stage')
554     if stage:
555         try:
556             stage = Chunk.tag_model.objects.get(slug=stage)
557         except Chunk.DoesNotExist as e:
558             stage = None
559        
560         for c in chunks: c.stage = stage
561
562     username = request.POST.get('user')
563     logger.info("username: %s" % username)
564     logger.info(request.POST)
565     if username:
566         try:
567             user = User.objects.get(username=username)
568         except User.DoesNotExist as e:
569             user = None
570             
571         for c in chunks: c.user = user
572
573     project_id = request.POST.get('project')
574     if project_id:
575         try:
576             project = Project.objects.get(pk=int(project_id))
577         except (Project.DoesNotExist, ValueError) as e:
578             project = None
579         for c in chunks:
580             book = c.book
581             book.project = project
582             book.save()
583
584     for c in chunks: c.save()
585
586     return HttpResponse("", content_type="text/plain")
587
588
589 @transaction.atomic
590 @login_required
591 @require_POST
592 def image_mass_edit(request):
593     ids = map(int, filter(lambda i: i.strip()!='', request.POST.get('ids').split(',')))
594     images = map(lambda i: Image.objects.get(id=i), ids)
595     
596     stage = request.POST.get('stage')
597     if stage:
598         try:
599             stage = Image.tag_model.objects.get(slug=stage)
600         except Image.DoesNotExist as e:
601             stage = None
602        
603         for c in images: c.stage = stage
604
605     username = request.POST.get('user')
606     logger.info("username: %s" % username)
607     logger.info(request.POST)
608     if username:
609         try:
610             user = User.objects.get(username=username)
611         except User.DoesNotExist as e:
612             user = None
613             
614         for c in images: c.user = user
615
616     project_id = request.POST.get('project')
617     if project_id:
618         try:
619             project = Project.objects.get(pk=int(project_id))
620         except (Project.DoesNotExist, ValueError) as e:
621             project = None
622         for c in images:
623             c.project = project
624
625     for c in images: c.save()
626
627     return HttpResponse("", content_type="text/plain")
628
629
630 @permission_required('documents.change_book')
631 def book_append(request, slug):
632     book = get_object_or_404(Book, slug=slug)
633     if not book.accessible(request):
634         return HttpResponseForbidden("Not authorized.")
635
636     if request.method == "POST":
637         form = forms.BookAppendForm(book, request.POST)
638         if form.is_valid():
639             append_to = form.cleaned_data['append_to']
640             append_to.append(book)
641             return http.HttpResponseRedirect(append_to.get_absolute_url())
642     else:
643         form = forms.BookAppendForm(book)
644     return render(request, "documents/book_append_to.html", {
645         "book": book,
646         "form": form,
647
648         "logout_to": '/',
649     })
650
651
652 @require_POST
653 @login_required
654 def publish(request, slug):
655     form = forms.PublishOptionsForm(request.POST)
656     if form.is_valid():
657         days = form.cleaned_data['days']
658         beta = form.cleaned_data['beta']
659         hidden = form.cleaned_data['hidden']
660     else:
661         days = 0
662         beta = False
663         hidden = False
664     book = get_object_or_404(Book, slug=slug)
665     if not book.accessible(request):
666         return HttpResponseForbidden("Not authorized.")
667
668     try:
669         protocol = 'https://' if request.is_secure() else 'http://'
670         book.publish(request.user, host=protocol + request.get_host(), days=days, beta=beta, hidden=hidden)
671     except NotAuthorizedError:
672         return http.HttpResponseRedirect(reverse('apiclient_oauth' if not beta else 'apiclient_beta_oauth'))
673     except BaseException as e:
674         return http.HttpResponse(repr(e))
675     else:
676         return http.HttpResponseRedirect(book.get_absolute_url())
677
678
679 @require_POST
680 @login_required
681 def publish_image(request, slug):
682     image = get_object_or_404(Image, slug=slug)
683     if not image.accessible(request):
684         return HttpResponseForbidden("Not authorized.")
685
686     try:
687         image.publish(request.user)
688     except NotAuthorizedError:
689         return http.HttpResponseRedirect(reverse('apiclient_oauth'))
690     except BaseException as e:
691         return http.HttpResponse(e)
692     else:
693         return http.HttpResponseRedirect(image.get_absolute_url())
694
695
696 class GalleryView(UploadView):
697     def get_object(self, request, slug):
698         book = get_object_or_404(Book, slug=slug)
699         if not book.gallery:
700             raise Http404
701         return book
702
703     def breadcrumbs(self):
704         return [
705             (_('books'), reverse('documents_document_list')),
706             (self.object.title, self.object.get_absolute_url()),
707             (_('scan gallery'),),
708         ]
709
710     def get_directory(self):
711         return "%s%s/" % (settings.IMAGE_DIR, self.object.gallery)
712
713
714 def active_users_list(request, csv=False):
715     year = int(request.GET.get('y', date.today().year))
716     by_user = defaultdict(lambda: 0)
717     by_email = defaultdict(lambda: 0)
718     names_by_email = defaultdict(set)
719     for change_model in (Chunk.change_model, Image.change_model):
720         for c in change_model.objects.filter(
721                 created_at__year=year).order_by(
722                 'author', 'author_email', 'author_name').values(
723                 'author', 'author_name', 'author_email').annotate(
724                 c=Count('author'), ce=Count('author_email')).distinct():
725             if c['author']:
726                 by_user[c['author']] += c['c']
727             else:
728                 by_email[c['author_email']] += c['ce']
729                 if (c['author_name'] or '').strip():
730                     names_by_email[c['author_email']].add(c['author_name'])
731     for user in User.objects.filter(pk__in=by_user):
732         by_email[user.email] += by_user[user.pk]
733         names_by_email[user.email].add("%s %s" % (user.first_name, user.last_name))
734
735     active_users = []
736     for email, count in by_email.items():
737         active_users.append((email, names_by_email[email], count))
738     active_users.sort(key=lambda x: -x[2])
739     if csv:
740         return http.HttpResponse(
741             '\n'.join((
742                 ','.join(
743                     (str(x[2]), x[0], ','.join(x[1]))
744                 )
745                 for x in active_users
746             )),
747             content_type='text/csv',
748             headers={
749                 'Content-Disposition': f'attachment; filename=redakcja-{year}.csv',
750             }
751         )
752     else:
753         return render(request, 'documents/active_users_list.html', {
754             'users': active_users,
755             'year': year,
756         })
757
758
759 @user_passes_test(lambda u: u.is_superuser)
760 def mark_final(request):
761     if request.method == 'POST':
762         form = forms.MarkFinalForm(data=request.POST)
763         if form.is_valid():
764             form.save()
765             return HttpResponseRedirect(reverse('mark_final_completed'))
766     else:
767         form = forms.MarkFinalForm()
768     return render(request, 'documents/mark_final.html', {'form': form})
769
770
771 def mark_final_completed(request):
772     return render(request, 'documents/mark_final_completed.html')
773
774
775 @login_required
776 def synchro(request, slug):
777     book = get_object_or_404(Book, slug=slug)
778     if not book.accessible(request):
779         return HttpResponseForbidden("Not authorized.")
780
781     if request.method == 'POST':
782         #hints = json.loads(request.POST.get('hints'))
783         chunk = book[0]
784         tree = etree.fromstring(chunk.head.materialize())
785         m = tree.find('.//meta[@id="synchro"]')
786         if m is None:
787             rdf = tree.find('.//{http://www.w3.org/1999/02/22-rdf-syntax-ns#}Description')
788             m = etree.SubElement(rdf, 'meta', id="synchro")
789             m.tail = '\n'
790         m.text = request.POST.get('hints')
791         text = etree.tostring(tree, encoding='unicode')
792         chunk.commit(text, author=request.user, description='Synchronizacja')
793         return HttpResponseRedirect('')
794     
795     document = book.wldocument(librarian2=True, publishable=False)
796
797     slug = document.meta.url.slug
798     error = None
799     try:
800         items = requests.get(f'https://audio.wolnelektury.pl/archive/book/{slug}.json').json()['items']
801     except:
802         error = 'Błąd połączenia z repozytorium audio.'
803         items = []
804     else:
805         mp3 = [
806             item['part'] for item in items
807         ]
808
809     split_on = (
810         'naglowek_rozdzial',
811         'naglowek_scena',
812     )
813     split_other = (
814         'naglowek_czesc',
815         'naglowek_akt',
816         'naglowek_podrozdzial',
817         'srodtytul',
818     )
819
820     headers = []
821     headers_other = []
822     master = document.tree.getroot()[-1]
823     for item in master:
824         if item.tag in split_on:
825             headers.append([
826                 item.tag,
827                 raw_printable_text(item),
828                 0,
829                 item.sourceline,
830             ])
831         if item.tag in split_other:
832             headers_other.append([
833                 item.tag,
834                 raw_printable_text(item),
835                 0,
836                 item.sourceline,
837             ])
838
839     hints = []
840     m = document.tree.find('.//meta[@id="synchro"]')
841     if m is not None:
842         try:
843             hints = json.loads(m.text)
844         except:
845             raise
846             pass
847     
848     return render(request, 'documents/synchro.html', {
849         'book': book,
850         'headers': headers,
851         'headers_other': headers_other,
852         'mp3': mp3,
853         'error': error,
854         'hints': hints,
855     })
856
857
858 @permission_required('documents.change_book')
859 def attach_book_to_catalogue(request, pk):
860     dbook = get_object_or_404(Book, pk=pk)
861     if dbook.dc_slug:
862         cbook = get_object_or_404(catalogue.models.Book, slug=dbook.dc_slug)
863         dbook.catalogue_book = cbook
864         dbook.save()
865     return http.HttpResponseRedirect(dbook.get_absolute_url())