Commit 3a740c04 by jmclaus

BLD-844: Add possibility to download transcripts in different formats.

parent 2c9585ea
......@@ -5,6 +5,8 @@ These are notable changes in edx-platform. This is a rolling list of changes,
in roughly chronological order, most recent first. Add your entries at or near
the top. Include a label indicating the component affected.
Blades: Add .txt and .srt options to the "download transcript" button. BLD-844.
Blades: Fix bug when transcript cutting off view in full view mode. BLD-852.
Blades: Show start time or starting position on slider and VCR. BLD-823.
......
......@@ -22,6 +22,15 @@
.video-controls .add-fullscreen {
display: none !important; // nasty, but needed to override the bad specificity of the xmodule css selectors
}
.video-tracks {
.a11y-menu-container {
.a11y-menu-list {
bottom: 100%;
top: auto;
}
}
}
}
}
......
$gray: rgb(127, 127, 127);
$blue: rgb(0, 159, 230);
$gray-d1: shade($gray,20%);
$gray-l2: tint($gray,40%);
$gray-l3: tint($gray,60%);
$blue-s1: saturate($blue,15%);
%use-font-awesome {
font-family: FontAwesome;
-webkit-font-smoothing: antialiased;
display: inline-block;
speak: none;
}
.a11y-menu-container {
position: relative;
&.open {
.a11y-menu-list {
display: block;
}
}
.a11y-menu-list {
top: 100%;
margin: 0;
padding: 0;
display: none;
position: absolute;
z-index: 10;
list-style: none;
background-color: $white;
border: 1px solid #eee;
li {
margin: 0;
padding: 0;
border-bottom: 1px solid #eee;
color: $white;
cursor: pointer;
a {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: $gray-l2;
font-size: 14px;
line-height: 23px;
&:hover {
color: $gray-d1;
}
}
&.active{
a {
color: $blue;
}
}
&:last-child {
box-shadow: none;
border-bottom: 0;
margin-top: 0;
}
}
}
}
// Video track button specific styles
.video-tracks {
.a11y-menu-container {
display: inline-block;
vertical-align: top;
border-left: 1px solid #eee;
&.open {
> a {
background-color: $action-primary-active-bg;
color: $very-light-text;
&:after {
color: $very-light-text;
}
}
}
> a {
@include transition(all 0.25s ease-in-out 0s);
@include font-size(12);
display: block;
border-radius: 0 3px 3px 0;
background-color: $very-light-text;
padding: ($baseline*.75 $baseline*1.25 $baseline*.75 $baseline*.75);
color: $gray-l2;
min-width: 1.5em;
line-height: 14px;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
&:after {
@extend %use-font-awesome;
content: "\f0d7";
position: absolute;
right: ($baseline*.5);
top: 33%;
color: $lighter-base-font-color;
}
}
.a11y-menu-list {
right: 0;
li {
font-size: em(14);
a {
border: 0;
display: block;
padding: lh(.5);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
}
}
......@@ -46,13 +46,15 @@ div.video {
.video-sources,
.video-tracks {
display: inline-block;
vertical-align: top;
margin: ($baseline*.75) ($baseline/2) 0 0;
a {
> a {
@include transition(all 0.25s ease-in-out 0s);
@include font-size(14);
display: inline-block;
border-radius: 3px 3px 3px 3px;
line-height : 14px;
float: left;
border-radius: 3px;
background-color: $very-light-text;
padding: ($baseline*.75);
color: $lighter-base-font-color;
......@@ -62,7 +64,14 @@ div.video {
color: $very-light-text;
}
}
}
.video-tracks {
> a {
border-radius: 3px 0 0 3px;
}
> a.external-track {
border-radius: 3px;
}
}
}
......
......@@ -69,6 +69,23 @@
</div>
<div class="focus_grabber last"></div>
<ul class="wrapper-downloads">
<li class="video-tracks">
<div class="a11y-menu-container">
<a class="a11y-menu-button" href="#" title=".srt">.srt</a>
<ol class="a11y-menu-list">
<li class="a11y-menu-item">
<a class="a11y-menu-item-link" href="#txt" title="Text (.txt) file" data-value="txt">Text (.txt) file</a>
</li>
<li class="a11y-menu-item active">
<a class="a11y-menu-item-link" href="#srt" title="SubRip (.srt) file" data-value="srt">SubRip (.srt) file</a>
</li>
</ol>
</div>
</li>
</ul>
</div>
</div>
</div>
......
......@@ -42,6 +42,7 @@ require(
[
'video/01_initialize.js',
'video/025_focus_grabber.js',
'video/035_video_accessible_menu.js',
'video/04_video_control.js',
'video/05_video_quality_control.js',
'video/06_video_progress_slider.js',
......@@ -52,6 +53,7 @@ require(
function (
Initialize,
FocusGrabber,
VideoAccessibleMenu,
VideoControl,
VideoQualityControl,
VideoProgressSlider,
......@@ -87,6 +89,7 @@ function (
state.modules = [
FocusGrabber,
VideoAccessibleMenu,
VideoControl,
VideoQualityControl,
VideoProgressSlider,
......
......@@ -13,6 +13,7 @@ in XML.
import json
import logging
from operator import itemgetter
from HTMLParser import HTMLParser
from lxml import etree
from pkg_resources import resource_string
......@@ -155,6 +156,15 @@ class VideoFields(object):
scope=Scope.preferences,
default="en"
)
transcript_download_format = String(
help="Transcript file format to download by user.",
scope=Scope.preferences,
values=[
{"display_name": "SubRip (.srt) file", "value": "srt"},
{"display_name": "Text (.txt) file", "value": "txt"}
],
default='srt',
)
speed = Float(
help="The last speed that was explicitly set by user for the video.",
scope=Scope.user_state,
......@@ -193,6 +203,7 @@ class VideoModule(VideoFields, XModule):
resource_string(module, 'js/src/video/025_focus_grabber.js'),
resource_string(module, 'js/src/video/02_html5_video.js'),
resource_string(module, 'js/src/video/03_video_player.js'),
resource_string(module, 'js/src/video/035_video_accessible_menu.js'),
resource_string(module, 'js/src/video/04_video_control.js'),
resource_string(module, 'js/src/video/05_video_quality_control.js'),
resource_string(module, 'js/src/video/06_video_progress_slider.js'),
......@@ -202,20 +213,33 @@ class VideoModule(VideoFields, XModule):
resource_string(module, 'js/src/video/10_main.js')
]
}
css = {'scss': [resource_string(module, 'css/video/display.scss')]}
css = {'scss': [
resource_string(module, 'css/video/display.scss'),
resource_string(module, 'css/video/accessible_menu.scss'),
]}
js_module_name = "Video"
def handle_ajax(self, dispatch, data):
accepted_keys = ['speed', 'saved_video_position', 'transcript_language']
if dispatch == 'save_user_state':
accepted_keys = [
'speed', 'saved_video_position', 'transcript_language',
'transcript_download_format',
]
conversions = {
'speed': json.loads,
'saved_video_position': lambda v: RelativeTime.isotime_to_timedelta(v),
}
if dispatch == 'save_user_state':
for key in data:
if hasattr(self, key) and key in accepted_keys:
if key == 'saved_video_position':
relative_position = RelativeTime.isotime_to_timedelta(data[key])
self.saved_video_position = relative_position
if key in conversions:
value = conversions[key](data[key])
else:
setattr(self, key, json.loads(data[key]))
value = data[key]
setattr(self, key, value)
if key == 'speed':
self.global_speed = self.speed
......@@ -228,6 +252,7 @@ class VideoModule(VideoFields, XModule):
def get_html(self):
track_url = None
transcript_download_format = self.transcript_download_format
get_ext = lambda filename: filename.rpartition('.')[-1]
sources = {get_ext(src): src for src in self.html5_sources}
......@@ -241,7 +266,8 @@ class VideoModule(VideoFields, XModule):
if self.download_track:
if self.track:
track_url = self.track
elif self.sub:
transcript_download_format = None
elif self.sub or self.transcripts:
track_url = self.runtime.handler_url(self, 'transcript').rstrip('/?') + '/download'
if not self.transcripts:
......@@ -289,13 +315,15 @@ class VideoModule(VideoFields, XModule):
# configuration setting field.
'yt_test_timeout': 1500,
'yt_test_url': settings.YOUTUBE_TEST_URL,
'transcript_download_format': transcript_download_format,
'transcript_download_formats_list': self.descriptor.fields['transcript_download_format'].values,
'transcript_language': transcript_language,
'transcript_languages': json.dumps(sorted_languages),
'transcript_translation_url': self.runtime.handler_url(self, 'transcript').rstrip('/?') + '/translation',
'transcript_available_translations_url': self.runtime.handler_url(self, 'transcript').rstrip('/?') + '/available_translations',
})
def get_transcript(self):
def get_transcript(self, format='srt'):
"""
Returns transcript in *.srt format.
......@@ -307,12 +335,18 @@ class VideoModule(VideoFields, XModule):
lang = self.transcript_language
subs_id = self.sub if lang == 'en' else self.youtube_id_1_0
data = asset(self.location, subs_id, lang).data
str_subs = generate_srt_from_sjson(json.loads(data), speed=1.0)
if format == 'txt':
text = json.loads(data)['text']
str_subs = HTMLParser().unescape("\n".join(text))
mime_type = 'text/plain'
else:
str_subs = generate_srt_from_sjson(json.loads(data), speed=1.0)
mime_type = 'application/x-subrip'
if not str_subs:
log.debug('generate_srt_from_sjson produces no subtitles')
raise ValueError
return str_subs
return str_subs, format, mime_type
@XBlock.handler
def transcript(self, request, dispatch):
......@@ -350,7 +384,7 @@ class VideoModule(VideoFields, XModule):
elif dispatch == 'download':
try:
subs = self.get_transcript()
subs, format, mime_type = self.get_transcript(format=self.transcript_download_format)
except (NotFoundError, ValueError, KeyError):
log.debug("Video@download exception")
response = Response(status=404)
......@@ -358,10 +392,13 @@ class VideoModule(VideoFields, XModule):
response = Response(
subs,
headerlist=[
('Content-Disposition', 'attachment; filename="{0}.srt"'.format(self.transcript_language)),
('Content-Disposition', 'attachment; filename="{filename}.{format}"'.format(
filename=self.transcript_language,
format=format,
)),
]
)
response.content_type = "application/x-subrip"
response.content_type = mime_type
elif dispatch == 'available_translations':
available_translations = []
......
......@@ -94,7 +94,7 @@
114220
],
"text": [
"LILA FISHER: Hi, welcome to Edx.",
"Hi, welcome to Edx.",
"I'm Lila Fisher, an Edx fellow helping to put",
"together these courses.",
"As you know, our courses are entirely online.",
......
......@@ -155,3 +155,24 @@ Feature: LMS Video component
Then I see video aligned correctly with enabled transcript
And I click video button "CC"
Then I see video aligned correctly without enabled transcript
# 19
Scenario: Download Transcript button works correctly in Video component
Given I am registered for the course "test_course"
And it has a video "A" in "Youtube" mode in position "1" of sequential:
| sub | download_track |
| OEoXaMPEzfM | true |
And a video "B" in "Youtube" mode in position "2" of sequential:
| sub | download_track |
| OEoXaMPEzfM | true |
And a video "C" in "Youtube" mode in position "3" of sequential:
| track | download_track |
| http://example.org/ | true |
And I open the section with videos
And I can download transcript in "srt" format
And I select the transcript format "txt"
And I can download transcript in "txt" format
When I open video "B"
Then I can download transcript in "txt" format
When I open video "C"
Then menu "download_transcript" doesn't exist
......@@ -3,12 +3,13 @@
from lettuce import world, step
import json
import os
import requests
from common import i_am_registered_for_the_course, section_location, visit_scenario_item
from django.utils.translation import ugettext as _
from django.conf import settings
from cache_toolbox.core import del_cached_content
from xmodule.contentstore.content import StaticContent
import os
from xmodule.contentstore.django import contentstore
TEST_ROOT = settings.COMMON_TEST_DATA_ROOT
LANGUAGES = settings.ALL_LANGUAGES
......@@ -32,17 +33,57 @@ VIDEO_BUTTONS = {
'play': '.video_control.play',
'pause': '.video_control.pause',
'fullscreen': '.add-fullscreen',
'download_transcript': '.video-tracks > a',
}
VIDEO_MENUS = {
'language': '.lang .menu',
'speed': '.speed .menu',
'download_transcript': '.video-tracks .a11y-menu-list',
}
coursenum = 'test_course'
sequence = {}
class ReuqestHandlerWithSessionId(object):
def get(self, url):
"""
Sends a request.
"""
kwargs = dict()
session_id = [{i['name']:i['value']} for i in world.browser.cookies.all() if i['name']==u'sessionid']
if session_id:
kwargs.update({
'cookies': session_id[0]
})
response = requests.get(url, **kwargs)
self.response = response
self.status_code = response.status_code
self.headers = response.headers
self.content = response.content
return self
def is_success(self):
"""
Returns `True` if the response was succeed, otherwise, returns `False`.
"""
if self.status_code < 400:
return True
return False
def check_header(self, name, value):
"""
Returns `True` if the response header exist and has appropriate value,
otherwise, returns `False`.
"""
if value in self.headers.get(name, ''):
return True
return False
def add_video_to_course(course, player_mode, hashes, display_name='Video'):
category = 'video'
......@@ -80,15 +121,23 @@ def add_video_to_course(course, player_mode, hashes, display_name='Video'):
if hashes:
kwargs['metadata'].update(hashes[0])
course_location = world.scenario_dict['COURSE'].location
course_location =world.scenario_dict['COURSE'].location
conversions = {
'transcripts': json.loads,
'download_track': json.loads,
'download_video': json.loads,
}
for key in kwargs['metadata']:
if key in conversions:
kwargs['metadata'][key] = conversions[key](kwargs['metadata'][key])
if 'sub' in kwargs['metadata']:
filename = _get_sjson_filename(kwargs['metadata']['sub'], 'en')
_upload_file(filename, course_location)
if 'transcripts' in kwargs['metadata']:
kwargs['metadata']['transcripts'] = json.loads(kwargs['metadata']['transcripts'])
for lang, filename in kwargs['metadata']['transcripts'].items():
_upload_file(filename, course_location)
......@@ -322,6 +371,10 @@ def upload_to_assets(_step, filename):
def is_hidden_button(_step, button):
assert not world.css_visible(VIDEO_BUTTONS[button])
@step('menu "([^"]*)" doesn\'t exist$')
def is_hidden_menu(_step, menu):
assert world.is_css_not_present(VIDEO_MENUS[menu])
@step('I see video aligned correctly (with(?:out)?) enabled transcript$')
def video_alignment(_step, transcript_visibility):
......@@ -345,3 +398,45 @@ def video_alignment(_step, transcript_visibility):
)
assert all([width, height])
@step('I can download transcript in "([^"]*)" format$')
def i_can_download_transcript(_step, format):
button = world.css_find('.video-tracks .a11y-menu-button').first
assert button.text.strip() == '.' + format
formats = {
'srt': {
'content': '0\n00:00:00,270',
'mime_type': 'application/x-subrip'
},
'txt': {
'content': 'Hi, welcome to Edx.',
'mime_type': 'text/plain'
},
}
url = world.css_find(VIDEO_BUTTONS['download_transcript'])[0]['href']
request = ReuqestHandlerWithSessionId()
assert request.get(url).is_success()
assert request.check_header('content-type', formats[format]['mime_type'])
assert request.content.startswith(formats[format]['content'])
@step('I select the transcript format "([^"]*)"$')
def select_transcript_format(_step, format):
button = world.css_find('.video-tracks .a11y-menu-button').first
button.mouse_over()
assert button.text.strip() == '...'
menu_selector = VIDEO_MENUS['download_transcript']
menu_items = world.css_find(menu_selector + ' a')
for item in menu_items:
if item['data-value'] == format:
item.click()
world.wait_for_ajax_complete()
break
assert world.css_find(menu_selector + ' .active a')[0]['data-value'] == format
assert button.text.strip() == '.' + format
......@@ -110,7 +110,7 @@ class TestVideo(BaseTestXmodule):
data = [
{'speed': 2.0},
{'saved_video_position': "00:00:10"},
{'transcript_language': json.dumps('uk')},
{'transcript_language': 'uk'},
]
for sample in data:
response = self.clients[self.users[0].username].post(
......@@ -129,7 +129,7 @@ class TestVideo(BaseTestXmodule):
self.assertEqual(self.item_descriptor.saved_video_position, timedelta(0, 10))
self.assertEqual(self.item_descriptor.transcript_language, 'en')
self.item_descriptor.handle_ajax('save_user_state', {'transcript_language': json.dumps("uk")})
self.item_descriptor.handle_ajax('save_user_state', {'transcript_language': "uk"})
self.assertEqual(self.item_descriptor.transcript_language, 'uk')
def tearDown(self):
......@@ -173,11 +173,20 @@ class TestVideoTranscriptTranslation(TestVideo):
response = self.item.transcript(request=request, dispatch='download')
self.assertEqual(response.status, '404 Not Found')
@patch('xmodule.video_module.VideoModule.get_transcript', return_value='Subs!')
def test_download_exist(self, __):
@patch('xmodule.video_module.VideoModule.get_transcript', return_value=('Subs!', 'srt', 'application/x-subrip'))
def test_download_srt_exist(self, __):
request = Request.blank('/download?language=en')
response = self.item.transcript(request=request, dispatch='download')
self.assertEqual(response.body, 'Subs!')
self.assertEqual(response.headers['Content-Type'], 'application/x-subrip')
@patch('xmodule.video_module.VideoModule.get_transcript', return_value=('Subs!', 'txt', 'text/plain'))
def test_download_txt_exist(self, __):
self.item.transcript_format = 'txt'
request = Request.blank('/download?language=en')
response = self.item.transcript(request=request, dispatch='download')
self.assertEqual(response.body, 'Subs!')
self.assertEqual(response.headers['Content-Type'], 'text/plain')
def test_download_en_no_sub(self):
request = Request.blank('/download?language=en')
......@@ -309,7 +318,7 @@ class TestVideoTranscriptsDownload(TestVideo):
self.item_descriptor.render('student_view')
self.item = self.item_descriptor.xmodule_runtime.xmodule_instance
def test_good_transcript(self):
def test_good_srt_transcript(self):
good_sjson = _create_file(content=textwrap.dedent("""\
{
"start": [
......@@ -329,7 +338,7 @@ class TestVideoTranscriptsDownload(TestVideo):
_upload_sjson_file(good_sjson, self.item.location)
self.item.sub = _get_subs_id(good_sjson.name)
text = self.item.get_transcript()
text, format, download = self.item.get_transcript()
expected_text = textwrap.dedent("""\
0
00:00:00,270 --> 00:00:02,720
......@@ -343,6 +352,33 @@ class TestVideoTranscriptsDownload(TestVideo):
self.assertEqual(text, expected_text)
def test_good_txt_transcript(self):
good_sjson = _create_file(content=textwrap.dedent("""\
{
"start": [
270,
2720
],
"end": [
2720,
5430
],
"text": [
"Hi, welcome to Edx.",
"Let&#39;s start with what is on your screen right now."
]
}
"""))
_upload_sjson_file(good_sjson, self.item.location)
self.item.sub = _get_subs_id(good_sjson.name)
text, format, mime_type = self.item.get_transcript(format="txt")
expected_text = textwrap.dedent("""\
Hi, welcome to Edx.
Let's start with what is on your screen right now.""")
self.assertEqual(text, expected_text)
def test_not_found_error(self):
with self.assertRaises(NotFoundError):
self.item.get_transcript()
......
# -*- coding: utf-8 -*-
"""Video xmodule tests in mongo."""
from mock import patch, PropertyMock
import json
from . import BaseTestXmodule
from .test_video_xml import SOURCE_XML
......@@ -41,6 +40,8 @@ class TestVideoYouTube(TestVideo):
'youtube_streams': create_youtube_string(self.item_descriptor),
'yt_test_timeout': 1500,
'yt_test_url': 'https://gdata.youtube.com/feeds/api/videos/',
'transcript_download_format': 'srt',
'transcript_download_formats_list': [{'display_name': 'SubRip (.srt) file', 'value': 'srt'}, {'display_name': 'Text (.txt) file', 'value': 'txt'}],
'transcript_language': 'en',
'transcript_languages': '{"en": "English", "uk": "Ukrainian"}',
'transcript_translation_url': self.item_descriptor.xmodule_runtime.handler_url(
......@@ -103,6 +104,8 @@ class TestVideoNonYouTube(TestVideo):
'autoplay': settings.FEATURES.get('AUTOPLAY_VIDEOS', True),
'yt_test_timeout': 1500,
'yt_test_url': 'https://gdata.youtube.com/feeds/api/videos/',
'transcript_download_format': 'srt',
'transcript_download_formats_list': [{'display_name': 'SubRip (.srt) file', 'value': 'srt'}, {'display_name': 'Text (.txt) file', 'value': 'txt'}],
'transcript_language': 'en',
'transcript_languages': '{"en": "English"}',
'transcript_translation_url': self.item_descriptor.xmodule_runtime.handler_url(
......@@ -191,6 +194,7 @@ class TestGetHtmlMethod(BaseTestXmodule):
'autoplay': settings.FEATURES.get('AUTOPLAY_VIDEOS', True),
'yt_test_timeout': 1500,
'yt_test_url': 'https://gdata.youtube.com/feeds/api/videos/',
'transcript_download_formats_list': [{'display_name': 'SubRip (.srt) file', 'value': 'srt'}, {'display_name': 'Text (.txt) file', 'value': 'txt'}],
}
for data in cases:
......@@ -208,6 +212,7 @@ class TestGetHtmlMethod(BaseTestXmodule):
context = self.item_descriptor.render('student_view').content
expected_context.update({
'transcript_download_format': None if self.item_descriptor.track and self.item_descriptor.download_track else 'srt',
'transcript_languages': '{"en": "English"}',
'transcript_language': 'en',
'transcript_translation_url': self.item_descriptor.xmodule_runtime.handler_url(
......@@ -305,6 +310,8 @@ class TestGetHtmlMethod(BaseTestXmodule):
'autoplay': settings.FEATURES.get('AUTOPLAY_VIDEOS', True),
'yt_test_timeout': 1500,
'yt_test_url': 'https://gdata.youtube.com/feeds/api/videos/',
'transcript_download_format': 'srt',
'transcript_download_formats_list': [{'display_name': 'SubRip (.srt) file', 'value': 'srt'}, {'display_name': 'Text (.txt) file', 'value': 'txt'}],
'transcript_language': 'en',
'transcript_languages': '{"en": "English"}',
}
......
......@@ -112,7 +112,29 @@
% endif
% if track:
<li class="video-tracks">
${('<a href="%s">' + _('Download timed transcript') + '</a>') % track}
% if transcript_download_format:
${('<a href="%s">' + _('Download transcript') + '</a>') % track
}
<div class="a11y-menu-container">
<a class="a11y-menu-button" href="#" title="${'.' + transcript_download_format}">${'.' + transcript_download_format}</a>
<ol class="a11y-menu-list">
% for item in transcript_download_formats_list:
% if item['value'] == transcript_download_format:
<li class="a11y-menu-item active">
% else:
<li class="a11y-menu-item">
% endif
<a class="a11y-menu-item-link" href="#${item['value']}" title="${_('{file_format}'.format(file_format=item['display_name']))}" data-value="${item['value']}">
${_('{file_format}'.format(file_format=item['display_name']))}
</a>
</li>
% endfor
</ol>
</div>
% else:
${('<a href="%s" class="external-track">' + _('Download transcript') + '</a>') % track
}
% endif
</li>
% endif
</ul>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment