Add Timed Transcripts Editor.
This commit is contained in:
@@ -64,6 +64,8 @@ LMS: Improved accessibility of parts of forum navigation sidebar.
|
||||
LMS: enhanced accessibility labeling and aria support for the discussion forum
|
||||
new post dropdown as well as response and comment area labeling.
|
||||
|
||||
Blades: Add Studio timed transcripts editor to video player.
|
||||
|
||||
LMS: enhanced shib support, including detection of linked shib account
|
||||
at login page and support for the ?next= GET parameter.
|
||||
|
||||
|
||||
@@ -51,6 +51,13 @@ def create_component_instance(step, category, component_type=None, is_advanced=F
|
||||
module_count_before + 1))
|
||||
|
||||
|
||||
@world.absorb
|
||||
def click_new_component_button(step, component_button_css):
|
||||
step.given('I have clicked the new unit button')
|
||||
|
||||
world.css_click(component_button_css)
|
||||
|
||||
|
||||
def _click_advanced():
|
||||
css = 'ul.problem-type-tabs a[href="#tab2"]'
|
||||
world.css_click(css)
|
||||
@@ -122,24 +129,29 @@ def verify_setting_entry(setting, display_name, value, explicitly_set):
|
||||
----------
|
||||
setting: the WebDriverElement object found in the browser
|
||||
display_name: the string expected as the label
|
||||
value: the expected field value
|
||||
html: the expected field value
|
||||
explicitly_set: True if the value is expected to have been explicitly set
|
||||
for the problem, rather than derived from the defaults. This is verified
|
||||
by the existence of a "Clear" button next to the field value.
|
||||
"""
|
||||
assert_equal(display_name, setting.find_by_css('.setting-label')[0].value)
|
||||
assert_equal(display_name, setting.find_by_css('.setting-label')[0].html)
|
||||
|
||||
# Check if the web object is a list type
|
||||
# If so, we use a slightly different mechanism for determining its value
|
||||
if setting.has_class('metadata-list-enum'):
|
||||
list_value = ', '.join(ele.value for ele in setting.find_by_css('.list-settings-item'))
|
||||
assert_equal(value, list_value)
|
||||
elif setting.has_class('metadata-videolist-enum'):
|
||||
list_value = ', '.join(ele.find_by_css('input')[0].value for ele in setting.find_by_css('.videolist-settings-item'))
|
||||
assert_equal(value, list_value)
|
||||
else:
|
||||
assert_equal(value, setting.find_by_css('.setting-input')[0].value)
|
||||
|
||||
settingClearButton = setting.find_by_css('.setting-clear')[0]
|
||||
assert_equal(explicitly_set, settingClearButton.has_class('active'))
|
||||
assert_equal(not explicitly_set, settingClearButton.has_class('inactive'))
|
||||
# VideoList doesn't have clear button
|
||||
if not setting.has_class('metadata-videolist-enum'):
|
||||
settingClearButton = setting.find_by_css('.setting-clear')[0]
|
||||
assert_equal(explicitly_set, settingClearButton.has_class('active'))
|
||||
assert_equal(not explicitly_set, settingClearButton.has_class('inactive'))
|
||||
|
||||
|
||||
@world.absorb
|
||||
|
||||
654
cms/djangoapps/contentstore/features/transcripts.feature
Normal file
654
cms/djangoapps/contentstore/features/transcripts.feature
Normal file
@@ -0,0 +1,654 @@
|
||||
Feature: Video Component Editor
|
||||
As a course author, I want to be able to create video components.
|
||||
|
||||
# For transcripts acceptance tests there are 3 available caption
|
||||
# files. They can be used to test various transcripts features. Two of
|
||||
# them can be imported from YouTube.
|
||||
#
|
||||
# The length of each file name is 11 characters. This is because the
|
||||
# YouTube's ID length is 11 characters. If file name is not of length 11,
|
||||
# front-end validation will not pass.
|
||||
#
|
||||
# t__eq_exist - this file exists on YouTube, and can be imported
|
||||
# via the transcripts menu; after import, this file will
|
||||
# be equal to the one stored locally
|
||||
# t_neq_exist - same as above, except local file will differ from the
|
||||
# one stored on YouTube
|
||||
# t_not_exist - this file does not exist on YouTube; it exists locally
|
||||
|
||||
#1
|
||||
Scenario: Check input error messages
|
||||
Given I have created a Video component
|
||||
And I edit the component
|
||||
|
||||
#User inputs html5 links with equal extension
|
||||
And I enter a "123.webm" source to field number 1
|
||||
And I enter a "456.webm" source to field number 2
|
||||
Then I see error message "file_type"
|
||||
# Currently we are working with 2nd field. It means, that if 2nd field
|
||||
# contain incorrect value, 1st and 3rd fields should be disabled until
|
||||
# 2nd field will be filled by correct correct value
|
||||
And I expect 1, 3 inputs are disabled
|
||||
When I clear fields
|
||||
And I expect inputs are enabled
|
||||
|
||||
#User input URL with incorrect format
|
||||
And I enter a "htt://link.c" source to field number 1
|
||||
Then I see error message "url_format"
|
||||
# Currently we are working with 1st field. It means, that if 1st field
|
||||
# contain incorrect value, 2nd and 3rd fields should be disabled until
|
||||
# 1st field will be filled by correct correct value
|
||||
And I expect 2, 3 inputs are disabled
|
||||
# We are not clearing fields here,
|
||||
# Because we changing same field.
|
||||
And I enter a "http://youtu.be/t_not_exist" source to field number 1
|
||||
Then I do not see error message
|
||||
And I expect inputs are enabled
|
||||
|
||||
#2
|
||||
Scenario: Testing interaction with test youtube server
|
||||
Given I have created a Video component with subtitles
|
||||
And I edit the component
|
||||
# first part of url will be substituted by mock_youtube_server address
|
||||
# for t__eq_exist id server will respond with transcripts
|
||||
And I enter a "http://youtu.be/t__eq_exist" source to field number 1
|
||||
Then I see status message "not found"
|
||||
# t__eq_exist subs locally not presented at this moment
|
||||
And I see button "import"
|
||||
|
||||
# for t_not_exist id server will respond with 404
|
||||
And I enter a "http://youtu.be/t_not_exist" source to field number 1
|
||||
Then I see status message "not found"
|
||||
And I do not see button "import"
|
||||
And I see button "disabled_download_to_edit"
|
||||
|
||||
#3
|
||||
Scenario: Youtube id only: check "not found" and "import" states
|
||||
Given I have created a Video component with subtitles
|
||||
And I edit the component
|
||||
|
||||
# Not found: w/o local or server subs
|
||||
And I remove "t_not_exist" transcripts id from store
|
||||
And I enter a "http://youtu.be/t_not_exist" source to field number 1
|
||||
Then I see status message "not found"
|
||||
And I see value "" in the field "HTML5 Transcript"
|
||||
|
||||
# Import: w/o local but with server subs
|
||||
And I remove "t__eq_exist" transcripts id from store
|
||||
And I enter a "http://youtu.be/t__eq_exist" source to field number 1
|
||||
Then I see status message "not found"
|
||||
And I see button "import"
|
||||
And I click button "import"
|
||||
Then I see status message "found"
|
||||
And I see button "upload_new_timed_transcripts"
|
||||
And I see button "download_to_edit"
|
||||
And I see value "t__eq_exist" in the field "HTML5 Transcript"
|
||||
|
||||
#4
|
||||
Scenario: Youtube id only: check "Found" state
|
||||
Given I have created a Video component with subtitles "t_not_exist"
|
||||
And I edit the component
|
||||
|
||||
And I enter a "http://youtu.be/t_not_exist" source to field number 1
|
||||
Then I see status message "found"
|
||||
And I see value "t_not_exist" in the field "HTML5 Transcript"
|
||||
|
||||
#5
|
||||
Scenario: Youtube id only: check "Found" state when user sets youtube_id with local and server subs and they are equal
|
||||
|
||||
Given I have created a Video component with subtitles "t__eq_exist"
|
||||
And I edit the component
|
||||
|
||||
And I enter a "http://youtu.be/t__eq_exist" source to field number 1
|
||||
And I see status message "found"
|
||||
And I see value "t__eq_exist" in the field "HTML5 Transcript"
|
||||
|
||||
#6
|
||||
Scenario: Youtube id only: check "Found" state when user sets youtube_id with local and server subs and they are not equal
|
||||
Given I have created a Video component with subtitles "t_neq_exist"
|
||||
And I edit the component
|
||||
|
||||
And I enter a "http://youtu.be/t_neq_exist" source to field number 1
|
||||
And I see status message "replace"
|
||||
And I see button "replace"
|
||||
And I click button "replace"
|
||||
And I see status message "found"
|
||||
And I see value "t_neq_exist" in the field "HTML5 Transcript"
|
||||
|
||||
#7
|
||||
Scenario: html5 source only: check "Not Found" state
|
||||
Given I have created a Video component
|
||||
And I edit the component
|
||||
|
||||
And I enter a "t_not_exist.mp4" source to field number 1
|
||||
Then I see status message "not found"
|
||||
And I see value "" in the field "HTML5 Transcript"
|
||||
|
||||
#8
|
||||
Scenario: html5 source only: check "Found" state
|
||||
Given I have created a Video component with subtitles "t_not_exist"
|
||||
And I edit the component
|
||||
|
||||
And I enter a "t_not_exist.mp4" source to field number 1
|
||||
Then I see status message "found"
|
||||
And I see value "t_not_exist" in the field "HTML5 Transcript"
|
||||
|
||||
#9
|
||||
Scenario: User sets youtube_id w/o server but with local subs and one html5 link w/o subs
|
||||
Given I have created a Video component with subtitles "t_not_exist"
|
||||
And I edit the component
|
||||
|
||||
And I enter a "http://youtu.be/t_not_exist" source to field number 1
|
||||
Then I see status message "found"
|
||||
|
||||
And I enter a "test_video_name.mp4" source to field number 2
|
||||
Then I see status message "found"
|
||||
And I see value "t_not_exist" in the field "HTML5 Transcript"
|
||||
|
||||
#10
|
||||
Scenario: User sets youtube_id w/o local but with server subs and one html5 link w/o subs
|
||||
Given I have created a Video component
|
||||
And I edit the component
|
||||
|
||||
And I enter a "http://youtu.be/t__eq_exist" source to field number 1
|
||||
Then I see status message "not found"
|
||||
And I see button "import"
|
||||
And I click button "import"
|
||||
Then I see status message "found"
|
||||
|
||||
And I enter a "t_not_exist.mp4" source to field number 2
|
||||
Then I see status message "found"
|
||||
And I see value "t__eq_exist" in the field "HTML5 Transcript"
|
||||
|
||||
#11
|
||||
Scenario: User sets youtube_id w/o local but with server subs and one html5 link w/o transcripts w/o import action, then another one html5 link w/o transcripts
|
||||
Given I have created a Video component
|
||||
And I edit the component
|
||||
|
||||
And I enter a "http://youtu.be/t__eq_exist" source to field number 1
|
||||
Then I see status message "not found"
|
||||
And I see button "import"
|
||||
And I see button "upload_new_timed_transcripts"
|
||||
|
||||
And I enter a "t_not_exist.mp4" source to field number 2
|
||||
Then I see status message "not found"
|
||||
And I see button "import"
|
||||
And I see button "upload_new_timed_transcripts"
|
||||
|
||||
And I enter a "t_not_exist.webm" source to field number 3
|
||||
Then I see status message "not found"
|
||||
And I see button "import"
|
||||
And I see button "upload_new_timed_transcripts"
|
||||
|
||||
#12
|
||||
Scenario: Entering youtube (no importing), and 2 html5 sources without transcripts - "Not Found"
|
||||
Given I have created a Video component
|
||||
And I edit the component
|
||||
And I enter a "http://youtu.be/t_not_exist" source to field number 1
|
||||
Then I see status message "not found"
|
||||
And I see button "disabled_download_to_edit"
|
||||
And I see button "upload_new_timed_transcripts"
|
||||
And I enter a "t_not_exist.mp4" source to field number 2
|
||||
Then I see status message "not found"
|
||||
And I see button "upload_new_timed_transcripts"
|
||||
And I see button "disabled_download_to_edit"
|
||||
And I enter a "t_not_exist.webm" source to field number 3
|
||||
Then I see status message "not found"
|
||||
And I see button "disabled_download_to_edit"
|
||||
And I see button "upload_new_timed_transcripts"
|
||||
|
||||
#13
|
||||
Scenario: Entering youtube with imported transcripts, and 2 html5 sources without transcripts - "Found"
|
||||
Given I have created a Video component
|
||||
And I edit the component
|
||||
|
||||
And I enter a "http://youtu.be/t__eq_exist" source to field number 1
|
||||
Then I see status message "not found"
|
||||
And I see button "import"
|
||||
And I click button "import"
|
||||
Then I see status message "found"
|
||||
And I see button "upload_new_timed_transcripts"
|
||||
|
||||
And I enter a "t_not_exist.mp4" source to field number 2
|
||||
Then I see status message "found"
|
||||
And I see button "download_to_edit"
|
||||
And I see button "upload_new_timed_transcripts"
|
||||
|
||||
And I enter a "t_not_exist.webm" source to field number 3
|
||||
Then I see status message "found"
|
||||
And I see button "download_to_edit"
|
||||
And I see button "upload_new_timed_transcripts"
|
||||
|
||||
#14
|
||||
Scenario: Entering youtube w/o transcripts - html5 w/o transcripts - html5 with transcripts
|
||||
Given I have created a Video component with subtitles "t_neq_exist"
|
||||
And I edit the component
|
||||
|
||||
And I enter a "http://youtu.be/t_not_exist" source to field number 1
|
||||
Then I see status message "not found"
|
||||
And I see button "disabled_download_to_edit"
|
||||
And I see button "upload_new_timed_transcripts"
|
||||
|
||||
And I enter a "t_not_exist.mp4" source to field number 2
|
||||
Then I see status message "not found"
|
||||
And I see button "disabled_download_to_edit"
|
||||
And I see button "upload_new_timed_transcripts"
|
||||
|
||||
And I enter a "t_neq_exist.webm" source to field number 3
|
||||
Then I see status message "found"
|
||||
And I see button "download_to_edit"
|
||||
And I see button "upload_new_timed_transcripts"
|
||||
|
||||
#15
|
||||
Scenario: Entering youtube w/o imported transcripts - html5 w/o transcripts w/o import - html5 with transcripts
|
||||
Given I have created a Video component with subtitles "t_neq_exist"
|
||||
And I edit the component
|
||||
|
||||
And I enter a "http://youtu.be/t__eq_exist" source to field number 1
|
||||
Then I see status message "not found"
|
||||
And I see button "import"
|
||||
And I see button "upload_new_timed_transcripts"
|
||||
|
||||
And I enter a "t_not_exist.mp4" source to field number 2
|
||||
Then I see status message "not found"
|
||||
And I see button "import"
|
||||
And I see button "upload_new_timed_transcripts"
|
||||
|
||||
And I enter a "t_neq_exist.webm" source to field number 3
|
||||
Then I see status message "not found"
|
||||
And I see button "import"
|
||||
And I see button "upload_new_timed_transcripts"
|
||||
|
||||
#16
|
||||
Scenario: Entering youtube w/o imported transcripts - html5 with transcripts - html5 w/o transcripts w/o import
|
||||
Given I have created a Video component with subtitles "t_neq_exist"
|
||||
And I edit the component
|
||||
|
||||
And I enter a "http://youtu.be/t__eq_exist" source to field number 1
|
||||
Then I see status message "not found"
|
||||
And I see button "import"
|
||||
And I see button "upload_new_timed_transcripts"
|
||||
|
||||
And I enter a "t_neq_exist.mp4" source to field number 2
|
||||
Then I see status message "not found"
|
||||
And I see button "import"
|
||||
And I see button "upload_new_timed_transcripts"
|
||||
|
||||
And I enter a "t_not_exist.webm" source to field number 3
|
||||
Then I see status message "not found"
|
||||
And I see button "import"
|
||||
And I see button "upload_new_timed_transcripts"
|
||||
|
||||
#17
|
||||
Scenario: Entering youtube with imported transcripts - html5 with transcripts - html5 w/o transcripts
|
||||
Given I have created a Video component with subtitles "t_neq_exist"
|
||||
And I edit the component
|
||||
|
||||
And I enter a "http://youtu.be/t__eq_exist" source to field number 1
|
||||
Then I see status message "not found"
|
||||
And I see button "import"
|
||||
And I click button "import"
|
||||
Then I see status message "found"
|
||||
And I see button "upload_new_timed_transcripts"
|
||||
|
||||
And I enter a "t_neq_exist.mp4" source to field number 2
|
||||
Then I see status message "found"
|
||||
And I see button "download_to_edit"
|
||||
And I see button "upload_new_timed_transcripts"
|
||||
|
||||
And I enter a "t_not_exist.webm" source to field number 3
|
||||
Then I see status message "found"
|
||||
And I see button "download_to_edit"
|
||||
And I see button "upload_new_timed_transcripts"
|
||||
|
||||
#18
|
||||
Scenario: Entering youtube with imported transcripts - html5 w/o transcripts - html5 with transcripts
|
||||
Given I have created a Video component with subtitles "t_neq_exist"
|
||||
And I edit the component
|
||||
|
||||
And I enter a "http://youtu.be/t__eq_exist" source to field number 1
|
||||
Then I see status message "not found"
|
||||
And I see button "import"
|
||||
And I click button "import"
|
||||
Then I see status message "found"
|
||||
And I see button "upload_new_timed_transcripts"
|
||||
|
||||
And I enter a "t_not_exist.mp4" source to field number 2
|
||||
Then I see status message "found"
|
||||
And I see button "download_to_edit"
|
||||
And I see button "upload_new_timed_transcripts"
|
||||
|
||||
And I enter a "t_neq_exist.webm" source to field number 3
|
||||
Then I see status message "found"
|
||||
And I see button "download_to_edit"
|
||||
And I see button "upload_new_timed_transcripts"
|
||||
|
||||
#19
|
||||
Scenario: Entering html5 with transcripts - upload - youtube w/o transcripts
|
||||
Given I have created a Video component with subtitles "t__eq_exist"
|
||||
And I edit the component
|
||||
|
||||
And I enter a "t__eq_exist.mp4" source to field number 1
|
||||
Then I see status message "found"
|
||||
And I see button "download_to_edit"
|
||||
And I see button "upload_new_timed_transcripts"
|
||||
And I upload the transcripts file "test_transcripts.srt"
|
||||
Then I see status message "uploaded_successfully"
|
||||
And I see button "download_to_edit"
|
||||
And I see button "upload_new_timed_transcripts"
|
||||
And I see value "t__eq_exist" in the field "HTML5 Transcript"
|
||||
|
||||
And I enter a "http://youtu.be/t_not_exist" source to field number 2
|
||||
Then I see status message "found"
|
||||
And I see button "download_to_edit"
|
||||
And I see button "upload_new_timed_transcripts"
|
||||
|
||||
And I enter a "test_transcripts.webm" source to field number 3
|
||||
Then I see status message "found"
|
||||
|
||||
#20
|
||||
Scenario: Enter 2 HTML5 sources with transcripts, they are not the same, choose
|
||||
Given I have created a Video component with subtitles "t_not_exist"
|
||||
And I edit the component
|
||||
|
||||
And I enter a "test_transcripts.mp4" source to field number 1
|
||||
Then I see status message "not found"
|
||||
And I see button "download_to_edit"
|
||||
And I see button "upload_new_timed_transcripts"
|
||||
And I upload the transcripts file "test_transcripts.srt"
|
||||
Then I see status message "uploaded_successfully"
|
||||
And I see value "test_transcripts" in the field "HTML5 Transcript"
|
||||
|
||||
And I enter a "t_not_exist.webm" source to field number 2
|
||||
Then I see status message "replace"
|
||||
|
||||
And I see choose button "test_transcripts.mp4" number 1
|
||||
And I see choose button "t_not_exist.webm" number 2
|
||||
And I click button "choose" number 2
|
||||
And I see value "test_transcripts|t_not_exist" in the field "HTML5 Transcript"
|
||||
|
||||
#21
|
||||
Scenario: Work with 1 field only: Enter HTML5 source with transcripts - save - > change it to another one HTML5 source w/o transcripts - click on use existing - > change it to another one HTML5 source w/o transcripts - click on use existing
|
||||
Given I have created a Video component with subtitles "t_not_exist"
|
||||
And I edit the component
|
||||
|
||||
And I enter a "t_not_exist.mp4" source to field number 1
|
||||
Then I see status message "found"
|
||||
And I see button "download_to_edit"
|
||||
And I see button "upload_new_timed_transcripts"
|
||||
And I see value "t_not_exist" in the field "HTML5 Transcript"
|
||||
|
||||
And I save changes
|
||||
And I edit the component
|
||||
|
||||
And I enter a "video_name_2.mp4" source to field number 1
|
||||
Then I see status message "use existing"
|
||||
And I see button "use_existing"
|
||||
And I click button "use_existing"
|
||||
And I see value "video_name_2" in the field "HTML5 Transcript"
|
||||
|
||||
And I enter a "video_name_3.mp4" source to field number 1
|
||||
Then I see status message "use existing"
|
||||
And I see button "use_existing"
|
||||
And I click button "use_existing"
|
||||
And I see value "video_name_3" in the field "HTML5 Transcript"
|
||||
|
||||
#22
|
||||
Scenario: Work with 1 field only: Enter HTML5 source with transcripts - save -> change it to another one HTML5 source w/o transcripts - click on use existing -> change it to another one HTML5 source w/o transcripts - do not click on use existing -> change it to another one HTML5 source w/o transcripts - click on use existing
|
||||
Given I have created a Video component with subtitles "t_not_exist"
|
||||
And I edit the component
|
||||
|
||||
And I enter a "t_not_exist.mp4" source to field number 1
|
||||
Then I see status message "found"
|
||||
And I see button "download_to_edit"
|
||||
And I see button "upload_new_timed_transcripts"
|
||||
And I see value "t_not_exist" in the field "HTML5 Transcript"
|
||||
|
||||
And I save changes
|
||||
And I edit the component
|
||||
|
||||
And I enter a "video_name_2.mp4" source to field number 1
|
||||
Then I see status message "use existing"
|
||||
And I see button "use_existing"
|
||||
And I click button "use_existing"
|
||||
And I see value "video_name_2" in the field "HTML5 Transcript"
|
||||
|
||||
And I enter a "video_name_3.mp4" source to field number 1
|
||||
Then I see status message "use existing"
|
||||
And I see button "use_existing"
|
||||
|
||||
And I enter a "video_name_4.mp4" source to field number 1
|
||||
Then I see status message "use existing"
|
||||
And I see button "use_existing"
|
||||
And I click button "use_existing"
|
||||
And I see value "video_name_4" in the field "HTML5 Transcript"
|
||||
|
||||
#23
|
||||
Scenario: Work with 2 fields: Enter HTML5 source with transcripts - save -> change it to another one HTML5 source w/o transcripts - do not click on use existing -> add another one HTML5 source w/o transcripts - click on use existing
|
||||
Given I have created a Video component with subtitles "t_not_exist"
|
||||
And I edit the component
|
||||
|
||||
And I enter a "t_not_exist.mp4" source to field number 1
|
||||
Then I see status message "found"
|
||||
And I see button "download_to_edit"
|
||||
And I see button "upload_new_timed_transcripts"
|
||||
|
||||
And I save changes
|
||||
And I edit the component
|
||||
|
||||
And I enter a "video_name_2.mp4" source to field number 1
|
||||
Then I see status message "use existing"
|
||||
And I see button "use_existing"
|
||||
|
||||
And I enter a "video_name_3.webm" source to field number 2
|
||||
Then I see status message "use existing"
|
||||
And I see button "use_existing"
|
||||
And I click button "use_existing"
|
||||
And I see value "video_name_2|video_name_3" in the field "HTML5 Transcript"
|
||||
|
||||
#24 Uploading subtitles with different file name than file
|
||||
Scenario: File name and name of subs are different
|
||||
Given I have created a Video component
|
||||
And I edit the component
|
||||
|
||||
And I enter a "video_name_1.mp4" source to field number 1
|
||||
And I see status message "not found"
|
||||
And I upload the transcripts file "test_transcripts.srt"
|
||||
Then I see status message "uploaded_successfully"
|
||||
And I see value "video_name_1" in the field "HTML5 Transcript"
|
||||
|
||||
And I save changes
|
||||
Then when I view the video it does show the captions
|
||||
|
||||
And I edit the component
|
||||
Then I see status message "found"
|
||||
|
||||
#25
|
||||
# Video can have filled item.sub, but doesn't have subs file.
|
||||
# In this case, after changing this video by another one without subs
|
||||
# `Not found` message should appear ( not `use existing`).
|
||||
Scenario: Video w/o subs - another video w/o subs - Not found message
|
||||
Given I have created a Video component
|
||||
And I edit the component
|
||||
|
||||
And I enter a "video_name_1.mp4" source to field number 1
|
||||
Then I see status message "not found"
|
||||
|
||||
#26
|
||||
Scenario: Subtitles are copied for every html5 video source
|
||||
Given I have created a Video component
|
||||
And I edit the component
|
||||
|
||||
And I enter a "video_name_1.mp4" source to field number 1
|
||||
And I see status message "not found"
|
||||
|
||||
And I enter a "video_name_2.webm" source to field number 2
|
||||
And I see status message "not found"
|
||||
And I upload the transcripts file "test_transcripts.srt"
|
||||
Then I see status message "uploaded_successfully"
|
||||
And I see value "video_name_1|video_name_2" in the field "HTML5 Transcript"
|
||||
|
||||
And I clear field number 1
|
||||
Then I see status message "found"
|
||||
And I see value "video_name_2" in the field "HTML5 Transcript"
|
||||
|
||||
#27
|
||||
Scenario: Upload button for single youtube id.
|
||||
Given I have created a Video component
|
||||
And I edit the component
|
||||
|
||||
And I enter a "http://youtu.be/t_not_exist" source to field number 1
|
||||
Then I see status message "not found"
|
||||
And I see button "upload_new_timed_transcripts"
|
||||
And I upload the transcripts file "test_transcripts.srt"
|
||||
Then I see status message "uploaded_successfully"
|
||||
|
||||
And I save changes
|
||||
Then when I view the video it does show the captions
|
||||
|
||||
And I edit the component
|
||||
Then I see status message "found"
|
||||
|
||||
#28
|
||||
Scenario: Upload button for youtube id with html5 ids.
|
||||
Given I have created a Video component
|
||||
And I edit the component
|
||||
|
||||
And I enter a "http://youtu.be/t_not_exist" source to field number 1
|
||||
Then I see status message "not found"
|
||||
And I see button "upload_new_timed_transcripts"
|
||||
|
||||
And I enter a "video_name_1.mp4" source to field number 2
|
||||
Then I see status message "not found"
|
||||
And I see button "upload_new_timed_transcripts"
|
||||
|
||||
And I upload the transcripts file "test_transcripts.srt"
|
||||
Then I see status message "uploaded_successfully"
|
||||
And I clear field number 1
|
||||
Then I see status message "found"
|
||||
And I see value "video_name_1" in the field "HTML5 Transcript"
|
||||
|
||||
And I save changes
|
||||
Then when I view the video it does show the captions
|
||||
And I edit the component
|
||||
Then I see status message "found"
|
||||
|
||||
#29
|
||||
Scenario: Change transcripts field in Advanced tab
|
||||
Given I have created a Video component with subtitles "t_not_exist"
|
||||
And I edit the component
|
||||
|
||||
And I enter a "video_name_1.mp4" source to field number 1
|
||||
Then I see status message "not found"
|
||||
|
||||
And I open tab "Advanced"
|
||||
And I set value "t_not_exist" to the field "HTML5 Transcript"
|
||||
|
||||
And I save changes
|
||||
Then when I view the video it does show the captions
|
||||
And I edit the component
|
||||
|
||||
Then I see status message "found"
|
||||
And I see value "video_name_1" in the field "HTML5 Transcript"
|
||||
|
||||
#30
|
||||
Scenario: Check non-ascii (chinise) transcripts
|
||||
Given I have created a Video component
|
||||
And I edit the component
|
||||
|
||||
And I enter a "video_name_1.mp4" source to field number 1
|
||||
Then I see status message "not found"
|
||||
And I upload the transcripts file "chinese_transcripts.srt"
|
||||
|
||||
Then I see status message "uploaded_successfully"
|
||||
|
||||
And I save changes
|
||||
Then when I view the video it does show the captions
|
||||
|
||||
#31
|
||||
Scenario: Check saving module metadata on switching between tabs
|
||||
Given I have created a Video component with subtitles "t_not_exist"
|
||||
And I edit the component
|
||||
|
||||
And I enter a "video_name_1.mp4" source to field number 1
|
||||
Then I see status message "not found"
|
||||
|
||||
And I open tab "Advanced"
|
||||
And I set value "t_not_exist" to the field "HTML5 Transcript"
|
||||
And I open tab "Basic"
|
||||
Then I see status message "found"
|
||||
|
||||
And I save changes
|
||||
Then when I view the video it does show the captions
|
||||
And I edit the component
|
||||
|
||||
Then I see status message "found"
|
||||
And I see value "video_name_1" in the field "HTML5 Transcript"
|
||||
|
||||
#32
|
||||
Scenario: After clearing Transcripts field in the Advanced tab "not found" message should be visible w/o saving
|
||||
Given I have created a Video component with subtitles "t_not_exist"
|
||||
And I edit the component
|
||||
|
||||
And I enter a "t_not_exist.mp4" source to field number 1
|
||||
Then I see status message "found"
|
||||
|
||||
And I open tab "Advanced"
|
||||
And I set value "" to the field "HTML5 Transcript"
|
||||
And I open tab "Basic"
|
||||
Then I see status message "not found"
|
||||
|
||||
And I save changes
|
||||
Then when I view the video it does not show the captions
|
||||
And I edit the component
|
||||
|
||||
Then I see status message "not found"
|
||||
And I see value "" in the field "HTML5 Transcript"
|
||||
|
||||
#33
|
||||
Scenario: After clearing Transcripts field in the Advanced tab "not found" message should be visible with saving
|
||||
Given I have created a Video component with subtitles "t_not_exist"
|
||||
And I edit the component
|
||||
|
||||
And I enter a "t_not_exist.mp4" source to field number 1
|
||||
Then I see status message "found"
|
||||
|
||||
And I save changes
|
||||
And I edit the component
|
||||
|
||||
And I open tab "Advanced"
|
||||
And I set value "" to the field "HTML5 Transcript"
|
||||
And I open tab "Basic"
|
||||
Then I see status message "not found"
|
||||
|
||||
And I save changes
|
||||
Then when I view the video it does not show the captions
|
||||
And I edit the component
|
||||
|
||||
Then I see status message "not found"
|
||||
And I see value "" in the field "HTML5 Transcript"
|
||||
|
||||
#34
|
||||
Scenario: Video with existing subs - Advanced tab - change to another one subs - Basic tab - Found message - Save - see correct subs
|
||||
Given I have created a Video component with subtitles "t_not_exist"
|
||||
And I edit the component
|
||||
|
||||
And I enter a "video_name_1.mp4" source to field number 1
|
||||
Then I see status message "not found"
|
||||
|
||||
And I upload the transcripts file "chinese_transcripts.srt"
|
||||
Then I see status message "uploaded_successfully"
|
||||
|
||||
And I save changes
|
||||
Then when I view the video it does show the captions
|
||||
And I edit the component
|
||||
|
||||
And I open tab "Advanced"
|
||||
And I set value "t_not_exist" to the field "HTML5 Transcript"
|
||||
And I open tab "Basic"
|
||||
Then I see status message "found"
|
||||
|
||||
And I save changes
|
||||
Then when I view the video it does show the captions
|
||||
And I see "LILA FISHER: Hi, welcome to Edx." text in the captions
|
||||
|
||||
246
cms/djangoapps/contentstore/features/transcripts.py
Normal file
246
cms/djangoapps/contentstore/features/transcripts.py
Normal file
@@ -0,0 +1,246 @@
|
||||
# disable missing docstring
|
||||
# pylint: disable=C0111
|
||||
|
||||
import os
|
||||
from lettuce import world, step
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from xmodule.contentstore.content import StaticContent
|
||||
from xmodule.contentstore.django import contentstore
|
||||
from xmodule.exceptions import NotFoundError
|
||||
|
||||
|
||||
TEST_ROOT = settings.COMMON_TEST_DATA_ROOT
|
||||
|
||||
# We should wait 300 ms for event handler invocation + 200ms for safety.
|
||||
DELAY = 0.5
|
||||
|
||||
ERROR_MESSAGES = {
|
||||
'url_format': u'Incorrect url format.',
|
||||
'file_type': u'Link types should be unique.',
|
||||
}
|
||||
|
||||
STATUSES = {
|
||||
'found': u'Timed Transcript Found',
|
||||
'not found': u'No Timed Transcript',
|
||||
'replace': u'Timed Transcript Conflict',
|
||||
'uploaded_successfully': u'Timed Transcript uploaded successfully',
|
||||
'use existing': u'Timed Transcript Not Updated',
|
||||
}
|
||||
|
||||
SELECTORS = {
|
||||
'error_bar': '.transcripts-error-message',
|
||||
'url_inputs': '.videolist-settings-item input.input',
|
||||
'collapse_link': '.collapse-action.collapse-setting',
|
||||
'collapse_bar': '.videolist-extra-videos',
|
||||
'status_bar': '.transcripts-message-status',
|
||||
}
|
||||
|
||||
# button type , button css selector, button message
|
||||
BUTTONS = {
|
||||
'import': ('.setting-import', 'Import from YouTube'),
|
||||
'download_to_edit': ('.setting-download', 'Download to Edit'),
|
||||
'disabled_download_to_edit': ('.setting-download.is-disabled', 'Download to Edit'),
|
||||
'upload_new_timed_transcripts': ('.setting-upload', 'Upload New Timed Transcript'),
|
||||
'replace': ('.setting-replace', 'Yes, Replace EdX Timed Transcript with YouTube Timed Transcript'),
|
||||
'choose': ('.setting-choose', 'Timed Transcript from {}'),
|
||||
'use_existing': ('.setting-use-existing', 'Use Existing Timed Transcript'),
|
||||
}
|
||||
|
||||
|
||||
@step('I clear fields$')
|
||||
def clear_fields(_step):
|
||||
js_str = '''
|
||||
$('{selector}')
|
||||
.eq({index})
|
||||
.prop('disabled', false)
|
||||
.removeClass('is-disabled');
|
||||
'''
|
||||
for index in range(1, 4):
|
||||
js = js_str.format(selector=SELECTORS['url_inputs'], index=index - 1)
|
||||
world.browser.execute_script(js)
|
||||
_step.given('I clear field number {0}'.format(index))
|
||||
|
||||
|
||||
@step('I clear field number (.+)$')
|
||||
def clear_field(_step, index):
|
||||
index = int(index) - 1
|
||||
world.css_fill(SELECTORS['url_inputs'], '', index)
|
||||
# In some reason chromeDriver doesn't trigger 'input' event after filling
|
||||
# field by an empty value. That's why we trigger it manually via jQuery.
|
||||
world.trigger_event(SELECTORS['url_inputs'], event='input', index=index)
|
||||
|
||||
|
||||
@step('I expect (.+) inputs are disabled$')
|
||||
def inputs_are_disabled(_step, indexes):
|
||||
index_list = [int(i.strip()) - 1 for i in indexes.split(',')]
|
||||
for index in index_list:
|
||||
el = world.css_find(SELECTORS['url_inputs'])[index]
|
||||
|
||||
assert el['disabled']
|
||||
|
||||
|
||||
@step('I expect inputs are enabled$')
|
||||
def inputs_are_enabled(_step):
|
||||
for index in range(3):
|
||||
el = world.css_find(SELECTORS['url_inputs'])[index]
|
||||
|
||||
assert not el['disabled']
|
||||
|
||||
|
||||
@step('I do not see error message$')
|
||||
def i_do_not_see_error_message(_step):
|
||||
world.wait(DELAY)
|
||||
|
||||
assert not world.css_visible(SELECTORS['error_bar'])
|
||||
|
||||
|
||||
@step('I see error message "([^"]*)"$')
|
||||
def i_see_error_message(_step, error):
|
||||
world.wait(DELAY)
|
||||
|
||||
assert world.css_has_text(SELECTORS['error_bar'], ERROR_MESSAGES[error.strip()])
|
||||
|
||||
|
||||
@step('I do not see status message$')
|
||||
def i_do_not_see_status_message(_step):
|
||||
world.wait(DELAY)
|
||||
world.wait_for_ajax_complete()
|
||||
|
||||
assert not world.css_visible(SELECTORS['status_bar'])
|
||||
|
||||
|
||||
@step('I see status message "([^"]*)"$')
|
||||
def i_see_status_message(_step, status):
|
||||
world.wait(DELAY)
|
||||
world.wait_for_ajax_complete()
|
||||
|
||||
assert world.css_has_text(SELECTORS['status_bar'], STATUSES[status.strip()])
|
||||
|
||||
|
||||
@step('I (.*)see button "([^"]*)"$')
|
||||
def i_see_button(_step, not_see, button_type):
|
||||
world.wait(DELAY)
|
||||
world.wait_for_ajax_complete()
|
||||
|
||||
button = button_type.strip()
|
||||
|
||||
if not_see.strip():
|
||||
assert world.is_css_not_present(BUTTONS[button][0])
|
||||
else:
|
||||
assert world.css_has_text(BUTTONS[button][0], BUTTONS[button][1])
|
||||
|
||||
|
||||
@step('I (.*)see (.*)button "([^"]*)" number (\d+)$')
|
||||
def i_see_button_with_custom_text(_step, not_see, button_type, custom_text, index):
|
||||
world.wait(DELAY)
|
||||
world.wait_for_ajax_complete()
|
||||
|
||||
button = button_type.strip()
|
||||
custom_text = custom_text.strip()
|
||||
index = int(index.strip()) - 1
|
||||
|
||||
if not_see.strip():
|
||||
assert world.is_css_not_present(BUTTONS[button][0])
|
||||
else:
|
||||
assert world.css_has_text(BUTTONS[button][0], BUTTONS[button][1].format(custom_text), index)
|
||||
|
||||
|
||||
@step('I click button "([^"]*)"$')
|
||||
def click_button(_step, button_type):
|
||||
world.wait(DELAY)
|
||||
world.wait_for_ajax_complete()
|
||||
|
||||
button = button_type.strip()
|
||||
world.css_click(BUTTONS[button][0])
|
||||
|
||||
|
||||
@step('I click button "([^"]*)" number (\d+)$')
|
||||
def click_button_index(_step, button_type, index):
|
||||
world.wait(DELAY)
|
||||
world.wait_for_ajax_complete()
|
||||
|
||||
button = button_type.strip()
|
||||
index = int(index.strip()) - 1
|
||||
|
||||
world.css_click(BUTTONS[button][0], index)
|
||||
|
||||
|
||||
@step('I remove "([^"]+)" transcripts id from store')
|
||||
def remove_transcripts_from_store(_step, subs_id):
|
||||
"""Remove from store, if transcripts content exists."""
|
||||
filename = 'subs_{0}.srt.sjson'.format(subs_id.strip())
|
||||
content_location = StaticContent.compute_location(
|
||||
world.scenario_dict['COURSE'].org,
|
||||
world.scenario_dict['COURSE'].number,
|
||||
filename
|
||||
)
|
||||
try:
|
||||
content = contentstore().find(content_location)
|
||||
contentstore().delete(content.get_id())
|
||||
print('Transcript file was removed from store.')
|
||||
except NotFoundError:
|
||||
print('Transcript file was NOT found and not removed.')
|
||||
|
||||
|
||||
@step('I enter a "([^"]+)" source to field number (\d+)$')
|
||||
def i_enter_a_source(_step, link, index):
|
||||
world.wait(DELAY)
|
||||
world.wait_for_ajax_complete()
|
||||
|
||||
index = int(index) - 1
|
||||
|
||||
if index is not 0 and not world.css_visible(SELECTORS['collapse_bar']):
|
||||
world.css_click(SELECTORS['collapse_link'])
|
||||
|
||||
assert world.css_visible(SELECTORS['collapse_bar'])
|
||||
|
||||
world.css_fill(SELECTORS['url_inputs'], link, index)
|
||||
|
||||
|
||||
@step('I upload the transcripts file "([^"]*)"$')
|
||||
def upload_file(_step, file_name):
|
||||
path = os.path.join(TEST_ROOT, 'uploads/', file_name.strip())
|
||||
world.browser.execute_script("$('form.file-chooser').show()")
|
||||
world.browser.attach_file('file', os.path.abspath(path))
|
||||
|
||||
|
||||
@step('I see "([^"]*)" text in the captions')
|
||||
def check_text_in_the_captions(_step, text):
|
||||
assert world.browser.is_text_present(text.strip(), 5)
|
||||
|
||||
|
||||
@step('I see value "([^"]*)" in the field "([^"]*)"$')
|
||||
def check_transcripts_field(_step, values, field_name):
|
||||
world.wait(DELAY)
|
||||
world.wait_for_ajax_complete()
|
||||
|
||||
world.click_link_by_text('Advanced')
|
||||
field_id = '#' + world.browser.find_by_xpath('//label[text()="%s"]' % field_name.strip())[0]['for']
|
||||
values_list = [i.strip() == world.css_value(field_id) for i in values.split('|')]
|
||||
assert any(values_list)
|
||||
world.click_link_by_text('Basic')
|
||||
|
||||
|
||||
@step('I save changes$')
|
||||
def save_changes(_step):
|
||||
world.wait(DELAY)
|
||||
world.wait_for_ajax_complete()
|
||||
|
||||
save_css = 'a.save-button'
|
||||
world.css_click(save_css)
|
||||
|
||||
|
||||
@step('I open tab "([^"]*)"$')
|
||||
def open_tab(_step, tab_name):
|
||||
world.click_link_by_text(tab_name.strip())
|
||||
|
||||
|
||||
@step('I set value "([^"]*)" to the field "([^"]*)"$')
|
||||
def set_value_transcripts_field(_step, value, field_name):
|
||||
world.wait(DELAY)
|
||||
world.wait_for_ajax_complete()
|
||||
|
||||
field_id = '#' + world.browser.find_by_xpath('//label[text()="%s"]' % field_name.strip())[0]['for']
|
||||
world.css_fill(field_id, value.strip())
|
||||
@@ -19,12 +19,12 @@ Feature: CMS.Video Component Editor
|
||||
@skip_sauce
|
||||
Scenario: Captions are hidden when "show captions" is false
|
||||
Given I have created a Video component with subtitles
|
||||
And I have set "show captions" to False
|
||||
And I have set "show transcript" to False
|
||||
Then when I view the video it does not show the captions
|
||||
|
||||
# Sauce Labs cannot delete cookies
|
||||
@skip_sauce
|
||||
Scenario: Captions are shown when "show captions" is true
|
||||
Given I have created a Video component with subtitles
|
||||
And I have set "show captions" to True
|
||||
And I have set "show transcript" to True
|
||||
Then when I view the video it does show the captions
|
||||
|
||||
@@ -5,14 +5,15 @@ from lettuce import world, step
|
||||
from terrain.steps import reload_the_page
|
||||
|
||||
|
||||
@step('I have set "show captions" to (.*)$')
|
||||
@step('I have set "show transcript" to (.*)$')
|
||||
def set_show_captions(step, setting):
|
||||
# Prevent cookies from overriding course settings
|
||||
world.browser.cookies.delete('hide_captions')
|
||||
|
||||
world.css_click('a.edit-button')
|
||||
world.wait_for(lambda _driver: world.css_visible('a.save-button'))
|
||||
world.browser.select('Show Captions', setting)
|
||||
world.click_link_by_text('Advanced')
|
||||
world.browser.select('Show Transcript', setting)
|
||||
world.css_click('a.save-button')
|
||||
|
||||
|
||||
@@ -33,12 +34,17 @@ def shows_captions(_step, show_captions):
|
||||
@step('I see the correct video settings and default values$')
|
||||
def correct_video_settings(_step):
|
||||
expected_entries = [
|
||||
# basic
|
||||
['Display Name', 'Video', False],
|
||||
['Download Track', '', False],
|
||||
['Video URL', 'http://youtu.be/OEoXaMPEzfM, , ', False],
|
||||
|
||||
# advanced
|
||||
['Display Name', 'Video', False],
|
||||
['Download Transcript', '', False],
|
||||
['Download Video', '', False],
|
||||
['End Time', '0', False],
|
||||
['HTML5 Timed Transcript', '', False],
|
||||
['Show Captions', 'True', False],
|
||||
['HTML5 Transcript', '', False],
|
||||
['Show Transcript', 'True', False],
|
||||
['Start Time', '0', False],
|
||||
['Video Sources', '', False],
|
||||
['Youtube ID', 'OEoXaMPEzfM', False],
|
||||
|
||||
@@ -43,11 +43,7 @@ def i_created_a_video_with_subs_with_name(_step, sub_id):
|
||||
@step('I have uploaded subtitles "([^"]*)"$')
|
||||
def i_have_uploaded_subtitles(_step, sub_id):
|
||||
_step.given('I go to the files and uploads page')
|
||||
|
||||
sub_id = sub_id.strip()
|
||||
if not sub_id:
|
||||
sub_id = 'OEoXaMPEzfM'
|
||||
_step.given('I upload the test file "subs_{}.srt.sjson"'.format(sub_id))
|
||||
_step.given('I upload the test file "subs_{}.srt.sjson"'.format(sub_id.strip()))
|
||||
|
||||
|
||||
@step('when I view the (.*) it does not have autoplay enabled$')
|
||||
|
||||
45
cms/djangoapps/contentstore/features/youtube_setup.py
Normal file
45
cms/djangoapps/contentstore/features/youtube_setup.py
Normal file
@@ -0,0 +1,45 @@
|
||||
#pylint: disable=C0111
|
||||
#pylint: disable=W0621
|
||||
from xmodule.util.mock_youtube_server.mock_youtube_server import MockYoutubeServer
|
||||
from lettuce import before, after, world
|
||||
from django.conf import settings
|
||||
import threading
|
||||
|
||||
from logging import getLogger
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
@before.all
|
||||
def setup_mock_youtube_server():
|
||||
server_host = '127.0.0.1'
|
||||
|
||||
server_port = settings.VIDEO_PORT
|
||||
|
||||
address = (server_host, server_port)
|
||||
|
||||
# Create the mock server instance
|
||||
server = MockYoutubeServer(address)
|
||||
logger.debug("Youtube server started at {} port".format(str(server_port)))
|
||||
|
||||
server.time_to_response = 0.1 # seconds
|
||||
|
||||
server.address = address
|
||||
|
||||
# Start the server running in a separate daemon thread
|
||||
# Because the thread is a daemon, it will terminate
|
||||
# when the main thread terminates.
|
||||
server_thread = threading.Thread(target=server.serve_forever)
|
||||
server_thread.daemon = True
|
||||
server_thread.start()
|
||||
|
||||
# Store the server instance in lettuce's world
|
||||
# so that other steps can access it
|
||||
# (and we can shut it down later)
|
||||
world.youtube_server = server
|
||||
|
||||
|
||||
@after.all
|
||||
def teardown_mock_youtube_server(total):
|
||||
|
||||
# Stop the LTI server and free up the port
|
||||
world.youtube_server.shutdown()
|
||||
@@ -1,14 +1,18 @@
|
||||
from contentstore.tests.utils import CourseTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
from django.core.urlresolvers import reverse
|
||||
from xmodule.capa_module import CapaDescriptor
|
||||
"""Tests for items views."""
|
||||
|
||||
import json
|
||||
from xmodule.modulestore.django import modulestore
|
||||
import datetime
|
||||
from pytz import UTC
|
||||
from django.core.urlresolvers import reverse
|
||||
|
||||
from contentstore.tests.utils import CourseTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
from xmodule.capa_module import CapaDescriptor
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
|
||||
class DeleteItem(CourseTestCase):
|
||||
"""Tests for '/delete_item' url."""
|
||||
def setUp(self):
|
||||
""" Creates the test course with a static page in it. """
|
||||
super(DeleteItem, self).setUp()
|
||||
|
||||
698
cms/djangoapps/contentstore/tests/test_transcripts.py
Normal file
698
cms/djangoapps/contentstore/tests/test_transcripts.py
Normal file
@@ -0,0 +1,698 @@
|
||||
"""Tests for items views."""
|
||||
|
||||
import os
|
||||
import json
|
||||
import tempfile
|
||||
from uuid import uuid4
|
||||
import copy
|
||||
import textwrap
|
||||
from pymongo import MongoClient
|
||||
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.test.utils import override_settings
|
||||
from django.conf import settings
|
||||
|
||||
from contentstore import transcripts_utils
|
||||
from contentstore.tests.utils import CourseTestCase
|
||||
from cache_toolbox.core import del_cached_content
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.contentstore.django import contentstore, _CONTENTSTORE
|
||||
from xmodule.contentstore.content import StaticContent
|
||||
from xmodule.exceptions import NotFoundError
|
||||
|
||||
from contentstore.tests.modulestore_config import TEST_MODULESTORE
|
||||
TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE)
|
||||
TEST_DATA_CONTENTSTORE['DOC_STORE_CONFIG']['db'] = 'test_xcontent_%s' % uuid4().hex
|
||||
|
||||
|
||||
@override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE, MODULESTORE=TEST_MODULESTORE)
|
||||
class Basetranscripts(CourseTestCase):
|
||||
"""Base test class for transcripts tests."""
|
||||
|
||||
org = 'MITx'
|
||||
number = '999'
|
||||
|
||||
def clear_subs_content(self):
|
||||
"""Remove, if transcripts content exists."""
|
||||
for youtube_id in self.get_youtube_ids().values():
|
||||
filename = 'subs_{0}.srt.sjson'.format(youtube_id)
|
||||
content_location = StaticContent.compute_location(
|
||||
self.org, self.number, filename)
|
||||
try:
|
||||
content = contentstore().find(content_location)
|
||||
contentstore().delete(content.get_id())
|
||||
except NotFoundError:
|
||||
pass
|
||||
|
||||
def setUp(self):
|
||||
"""Create initial data."""
|
||||
super(Basetranscripts, self).setUp()
|
||||
|
||||
# Add video module
|
||||
data = {
|
||||
'parent_location': str(self.course_location),
|
||||
'category': 'video',
|
||||
'type': 'video'
|
||||
}
|
||||
resp = self.client.post(reverse('create_item'), data)
|
||||
self.item_location = json.loads(resp.content).get('id')
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
# hI10vDNYz4M - valid Youtube ID with transcripts.
|
||||
# JMD_ifUUfsU, AKqURZnYqpk, DYpADpL7jAY - valid Youtube IDs without transcripts.
|
||||
data = '<video youtube="0.75:JMD_ifUUfsU,1.0:hI10vDNYz4M,1.25:AKqURZnYqpk,1.50:DYpADpL7jAY" />'
|
||||
modulestore().update_item(self.item_location, data)
|
||||
|
||||
self.item = modulestore().get_item(self.item_location)
|
||||
|
||||
# Remove all transcripts for current module.
|
||||
self.clear_subs_content()
|
||||
|
||||
def get_youtube_ids(self):
|
||||
"""Return youtube speeds and ids."""
|
||||
item = modulestore().get_item(self.item_location)
|
||||
|
||||
return {
|
||||
0.75: item.youtube_id_0_75,
|
||||
1: item.youtube_id_1_0,
|
||||
1.25: item.youtube_id_1_25,
|
||||
1.5: item.youtube_id_1_5
|
||||
}
|
||||
|
||||
def tearDown(self):
|
||||
MongoClient().drop_database(TEST_DATA_CONTENTSTORE['DOC_STORE_CONFIG']['db'])
|
||||
_CONTENTSTORE.clear()
|
||||
|
||||
|
||||
class TestUploadtranscripts(Basetranscripts):
|
||||
"""Tests for '/transcripts/upload' url."""
|
||||
|
||||
def setUp(self):
|
||||
"""Create initial data."""
|
||||
super(TestUploadtranscripts, self).setUp()
|
||||
|
||||
self.good_srt_file = tempfile.NamedTemporaryFile(suffix='.srt')
|
||||
self.good_srt_file.write(textwrap.dedent("""
|
||||
1
|
||||
00:00:10,500 --> 00:00:13,000
|
||||
Elephant's Dream
|
||||
|
||||
2
|
||||
00:00:15,000 --> 00:00:18,000
|
||||
At the left we can see...
|
||||
"""))
|
||||
self.good_srt_file.seek(0)
|
||||
|
||||
self.bad_data_srt_file = tempfile.NamedTemporaryFile(suffix='.srt')
|
||||
self.bad_data_srt_file.write('Some BAD data')
|
||||
self.bad_data_srt_file.seek(0)
|
||||
|
||||
self.bad_name_srt_file = tempfile.NamedTemporaryFile(suffix='.BAD')
|
||||
self.bad_name_srt_file.write(textwrap.dedent("""
|
||||
1
|
||||
00:00:10,500 --> 00:00:13,000
|
||||
Elephant's Dream
|
||||
|
||||
2
|
||||
00:00:15,000 --> 00:00:18,000
|
||||
At the left we can see...
|
||||
"""))
|
||||
self.bad_name_srt_file.seek(0)
|
||||
|
||||
def test_success_video_module_source_subs_uploading(self):
|
||||
data = textwrap.dedent("""
|
||||
<video youtube="">
|
||||
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.mp4"/>
|
||||
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.webm"/>
|
||||
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.ogv"/>
|
||||
</video>
|
||||
""")
|
||||
modulestore().update_item(self.item_location, data)
|
||||
|
||||
link = reverse('upload_transcripts')
|
||||
filename = os.path.splitext(os.path.basename(self.good_srt_file.name))[0]
|
||||
resp = self.client.post(link, {
|
||||
'id': self.item_location,
|
||||
'file': self.good_srt_file,
|
||||
'video_list': json.dumps([{
|
||||
'type': 'html5',
|
||||
'video': filename,
|
||||
'mode': 'mp4',
|
||||
}])
|
||||
})
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(json.loads(resp.content).get('status'), 'Success')
|
||||
|
||||
item = modulestore().get_item(self.item_location)
|
||||
self.assertEqual(item.sub, filename)
|
||||
|
||||
content_location = StaticContent.compute_location(
|
||||
self.org, self.number, 'subs_{0}.srt.sjson'.format(filename))
|
||||
self.assertTrue(contentstore().find(content_location))
|
||||
|
||||
def test_fail_data_without_id(self):
|
||||
link = reverse('upload_transcripts')
|
||||
resp = self.client.post(link, {'file': self.good_srt_file})
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
self.assertEqual(json.loads(resp.content).get('status'), 'POST data without "id" form data.')
|
||||
|
||||
def test_fail_data_without_file(self):
|
||||
link = reverse('upload_transcripts')
|
||||
resp = self.client.post(link, {'id': self.item_location})
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
self.assertEqual(json.loads(resp.content).get('status'), 'POST data without "file" form data.')
|
||||
|
||||
def test_fail_data_with_bad_location(self):
|
||||
# Test for raising `InvalidLocationError` exception.
|
||||
link = reverse('upload_transcripts')
|
||||
filename = os.path.splitext(os.path.basename(self.good_srt_file.name))[0]
|
||||
resp = self.client.post(link, {
|
||||
'id': 'BAD_LOCATION',
|
||||
'file': self.good_srt_file,
|
||||
'video_list': json.dumps([{
|
||||
'type': 'html5',
|
||||
'video': filename,
|
||||
'mode': 'mp4',
|
||||
}])
|
||||
})
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
self.assertEqual(json.loads(resp.content).get('status'), "Can't find item by location.")
|
||||
|
||||
# Test for raising `ItemNotFoundError` exception.
|
||||
link = reverse('upload_transcripts')
|
||||
filename = os.path.splitext(os.path.basename(self.good_srt_file.name))[0]
|
||||
resp = self.client.post(link, {
|
||||
'id': '{0}_{1}'.format(self.item_location, 'BAD_LOCATION'),
|
||||
'file': self.good_srt_file,
|
||||
'video_list': json.dumps([{
|
||||
'type': 'html5',
|
||||
'video': filename,
|
||||
'mode': 'mp4',
|
||||
}])
|
||||
})
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
self.assertEqual(json.loads(resp.content).get('status'), "Can't find item by location.")
|
||||
|
||||
def test_fail_for_non_video_module(self):
|
||||
# non_video module: setup
|
||||
data = {
|
||||
'parent_location': str(self.course_location),
|
||||
'category': 'non_video',
|
||||
'type': 'non_video'
|
||||
}
|
||||
resp = self.client.post(reverse('create_item'), data)
|
||||
item_location = json.loads(resp.content).get('id')
|
||||
data = '<non_video youtube="0.75:JMD_ifUUfsU,1.0:hI10vDNYz4M" />'
|
||||
modulestore().update_item(item_location, data)
|
||||
|
||||
# non_video module: testing
|
||||
|
||||
link = reverse('upload_transcripts')
|
||||
filename = os.path.splitext(os.path.basename(self.good_srt_file.name))[0]
|
||||
resp = self.client.post(link, {
|
||||
'id': item_location,
|
||||
'file': self.good_srt_file,
|
||||
'video_list': json.dumps([{
|
||||
'type': 'html5',
|
||||
'video': filename,
|
||||
'mode': 'mp4',
|
||||
}])
|
||||
})
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
self.assertEqual(json.loads(resp.content).get('status'), 'Transcripts are supported only for "video" modules.')
|
||||
|
||||
def test_fail_bad_xml(self):
|
||||
data = '<<<video youtube="0.75:JMD_ifUUfsU,1.25:AKqURZnYqpk,1.50:DYpADpL7jAY" />'
|
||||
modulestore().update_item(self.item_location, data)
|
||||
|
||||
link = reverse('upload_transcripts')
|
||||
filename = os.path.splitext(os.path.basename(self.good_srt_file.name))[0]
|
||||
resp = self.client.post(link, {
|
||||
'id': self.item_location,
|
||||
'file': self.good_srt_file,
|
||||
'video_list': json.dumps([{
|
||||
'type': 'html5',
|
||||
'video': filename,
|
||||
'mode': 'mp4',
|
||||
}])
|
||||
})
|
||||
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
# incorrect xml produces incorrect item category error
|
||||
self.assertEqual(json.loads(resp.content).get('status'), 'Transcripts are supported only for "video" modules.')
|
||||
|
||||
def test_fail_bad_data_srt_file(self):
|
||||
link = reverse('upload_transcripts')
|
||||
filename = os.path.splitext(os.path.basename(self.bad_data_srt_file.name))[0]
|
||||
resp = self.client.post(link, {
|
||||
'id': self.item_location,
|
||||
'file': self.bad_data_srt_file,
|
||||
'video_list': json.dumps([{
|
||||
'type': 'html5',
|
||||
'video': filename,
|
||||
'mode': 'mp4',
|
||||
}])
|
||||
})
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
self.assertEqual(json.loads(resp.content).get('status'), 'Something wrong with SubRip transcripts file during parsing.')
|
||||
|
||||
def test_fail_bad_name_srt_file(self):
|
||||
link = reverse('upload_transcripts')
|
||||
filename = os.path.splitext(os.path.basename(self.bad_name_srt_file.name))[0]
|
||||
resp = self.client.post(link, {
|
||||
'id': self.item_location,
|
||||
'file': self.bad_name_srt_file,
|
||||
'video_list': json.dumps([{
|
||||
'type': 'html5',
|
||||
'video': filename,
|
||||
'mode': 'mp4',
|
||||
}])
|
||||
})
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
self.assertEqual(json.loads(resp.content).get('status'), 'We support only SubRip (*.srt) transcripts format.')
|
||||
|
||||
def test_undefined_file_extension(self):
|
||||
srt_file = tempfile.NamedTemporaryFile(suffix='')
|
||||
srt_file.write(textwrap.dedent("""
|
||||
1
|
||||
00:00:10,500 --> 00:00:13,000
|
||||
Elephant's Dream
|
||||
|
||||
2
|
||||
00:00:15,000 --> 00:00:18,000
|
||||
At the left we can see...
|
||||
"""))
|
||||
srt_file.seek(0)
|
||||
|
||||
link = reverse('upload_transcripts')
|
||||
filename = os.path.splitext(os.path.basename(srt_file.name))[0]
|
||||
resp = self.client.post(link, {
|
||||
'id': self.item_location,
|
||||
'file': srt_file,
|
||||
'video_list': json.dumps([{
|
||||
'type': 'html5',
|
||||
'video': filename,
|
||||
'mode': 'mp4',
|
||||
}])
|
||||
})
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
self.assertEqual(json.loads(resp.content).get('status'), 'Undefined file extension.')
|
||||
|
||||
def tearDown(self):
|
||||
super(TestUploadtranscripts, self).tearDown()
|
||||
|
||||
self.good_srt_file.close()
|
||||
self.bad_data_srt_file.close()
|
||||
self.bad_name_srt_file.close()
|
||||
|
||||
|
||||
class TestDownloadtranscripts(Basetranscripts):
|
||||
"""Tests for '/transcripts/download' url."""
|
||||
|
||||
def save_subs_to_store(self, subs, subs_id):
|
||||
"""Save transcripts into `StaticContent`."""
|
||||
filedata = json.dumps(subs, indent=2)
|
||||
mime_type = 'application/json'
|
||||
filename = 'subs_{0}.srt.sjson'.format(subs_id)
|
||||
|
||||
content_location = StaticContent.compute_location(
|
||||
self.org, self.number, filename)
|
||||
content = StaticContent(content_location, filename, mime_type, filedata)
|
||||
contentstore().save(content)
|
||||
del_cached_content(content_location)
|
||||
return content_location
|
||||
|
||||
def remove_subs_from_store(self, subs_id):
|
||||
"""Remove from store, if transcripts content exists."""
|
||||
filename = 'subs_{0}.srt.sjson'.format(subs_id)
|
||||
content_location = StaticContent.compute_location(
|
||||
self.org, self.number, filename)
|
||||
try:
|
||||
content = contentstore().find(content_location)
|
||||
contentstore().delete(content.get_id())
|
||||
except NotFoundError:
|
||||
pass
|
||||
|
||||
def test_success_download_youtube(self):
|
||||
data = '<video youtube="1:JMD_ifUUfsU" />'
|
||||
modulestore().update_item(self.item_location, data)
|
||||
|
||||
subs = {
|
||||
'start': [100, 200, 240],
|
||||
'end': [200, 240, 380],
|
||||
'text': [
|
||||
'subs #1',
|
||||
'subs #2',
|
||||
'subs #3'
|
||||
]
|
||||
}
|
||||
self.save_subs_to_store(subs, 'JMD_ifUUfsU')
|
||||
|
||||
link = reverse('download_transcripts')
|
||||
resp = self.client.get(link, {'id': self.item_location, 'subs_id': "JMD_ifUUfsU"})
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(resp.content, """0\n00:00:00,100 --> 00:00:00,200\nsubs #1\n\n1\n00:00:00,200 --> 00:00:00,240\nsubs #2\n\n2\n00:00:00,240 --> 00:00:00,380\nsubs #3\n\n""")
|
||||
|
||||
def test_success_download_nonyoutube(self):
|
||||
subs_id = str(uuid4())
|
||||
data = textwrap.dedent("""
|
||||
<video youtube="" sub="{}">
|
||||
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.mp4"/>
|
||||
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.webm"/>
|
||||
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.ogv"/>
|
||||
</video>
|
||||
""".format(subs_id))
|
||||
modulestore().update_item(self.item_location, data)
|
||||
|
||||
subs = {
|
||||
'start': [100, 200, 240],
|
||||
'end': [200, 240, 380],
|
||||
'text': [
|
||||
'subs #1',
|
||||
'subs #2',
|
||||
'subs #3'
|
||||
]
|
||||
}
|
||||
self.save_subs_to_store(subs, subs_id)
|
||||
|
||||
link = reverse('download_transcripts')
|
||||
resp = self.client.get(link, {'id': self.item_location, 'subs_id': subs_id})
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(
|
||||
resp.content,
|
||||
'0\n00:00:00,100 --> 00:00:00,200\nsubs #1\n\n1\n00:00:00,200 --> '
|
||||
'00:00:00,240\nsubs #2\n\n2\n00:00:00,240 --> 00:00:00,380\nsubs #3\n\n'
|
||||
)
|
||||
transcripts_utils.remove_subs_from_store(subs_id, self.item)
|
||||
|
||||
def test_fail_data_without_file(self):
|
||||
link = reverse('download_transcripts')
|
||||
resp = self.client.get(link, {'id': ''})
|
||||
self.assertEqual(resp.status_code, 404)
|
||||
|
||||
resp = self.client.get(link, {})
|
||||
self.assertEqual(resp.status_code, 404)
|
||||
|
||||
def test_fail_data_with_bad_location(self):
|
||||
# Test for raising `InvalidLocationError` exception.
|
||||
link = reverse('download_transcripts')
|
||||
resp = self.client.get(link, {'id': 'BAD_LOCATION'})
|
||||
self.assertEqual(resp.status_code, 404)
|
||||
|
||||
# Test for raising `ItemNotFoundError` exception.
|
||||
link = reverse('download_transcripts')
|
||||
resp = self.client.get(link, {'id': '{0}_{1}'.format(self.item_location, 'BAD_LOCATION')})
|
||||
self.assertEqual(resp.status_code, 404)
|
||||
|
||||
def test_fail_for_non_video_module(self):
|
||||
# Video module: setup
|
||||
data = {
|
||||
'parent_location': str(self.course_location),
|
||||
'category': 'videoalpha',
|
||||
'type': 'videoalpha'
|
||||
}
|
||||
resp = self.client.post(reverse('create_item'), data)
|
||||
item_location = json.loads(resp.content).get('id')
|
||||
subs_id = str(uuid4())
|
||||
data = textwrap.dedent("""
|
||||
<videoalpha youtube="" sub="{}">
|
||||
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.mp4"/>
|
||||
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.webm"/>
|
||||
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.ogv"/>
|
||||
</videoalpha>
|
||||
""".format(subs_id))
|
||||
modulestore().update_item(item_location, data)
|
||||
|
||||
subs = {
|
||||
'start': [100, 200, 240],
|
||||
'end': [200, 240, 380],
|
||||
'text': [
|
||||
'subs #1',
|
||||
'subs #2',
|
||||
'subs #3'
|
||||
]
|
||||
}
|
||||
self.save_subs_to_store(subs, subs_id)
|
||||
|
||||
link = reverse('download_transcripts')
|
||||
resp = self.client.get(link, {'id': item_location})
|
||||
self.assertEqual(resp.status_code, 404)
|
||||
|
||||
def test_fail_nonyoutube_subs_dont_exist(self):
|
||||
data = textwrap.dedent("""
|
||||
<video youtube="" sub="UNDEFINED">
|
||||
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.mp4"/>
|
||||
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.webm"/>
|
||||
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.ogv"/>
|
||||
</video>
|
||||
""")
|
||||
modulestore().update_item(self.item_location, data)
|
||||
|
||||
link = reverse('download_transcripts')
|
||||
resp = self.client.get(link, {'id': self.item_location})
|
||||
self.assertEqual(resp.status_code, 404)
|
||||
|
||||
def test_empty_youtube_attr_and_sub_attr(self):
|
||||
data = textwrap.dedent("""
|
||||
<video youtube="">
|
||||
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.mp4"/>
|
||||
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.webm"/>
|
||||
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.ogv"/>
|
||||
</video>
|
||||
""")
|
||||
modulestore().update_item(self.item_location, data)
|
||||
|
||||
link = reverse('download_transcripts')
|
||||
resp = self.client.get(link, {'id': self.item_location})
|
||||
|
||||
self.assertEqual(resp.status_code, 404)
|
||||
|
||||
def test_fail_bad_sjson_subs(self):
|
||||
subs_id = str(uuid4())
|
||||
data = textwrap.dedent("""
|
||||
<video youtube="" sub="{}">
|
||||
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.mp4"/>
|
||||
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.webm"/>
|
||||
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.ogv"/>
|
||||
</video>
|
||||
""".format(subs_id))
|
||||
modulestore().update_item(self.item_location, data)
|
||||
|
||||
subs = {
|
||||
'start': [100, 200, 240],
|
||||
'end': [200, 240, 380],
|
||||
'text': [
|
||||
'subs #1'
|
||||
]
|
||||
}
|
||||
self.save_subs_to_store(subs, 'JMD_ifUUfsU')
|
||||
|
||||
link = reverse('download_transcripts')
|
||||
resp = self.client.get(link, {'id': self.item_location})
|
||||
|
||||
self.assertEqual(resp.status_code, 404)
|
||||
|
||||
|
||||
class TestChecktranscripts(Basetranscripts):
|
||||
"""Tests for '/transcripts/check' url."""
|
||||
|
||||
def save_subs_to_store(self, subs, subs_id):
|
||||
"""Save transcripts into `StaticContent`."""
|
||||
filedata = json.dumps(subs, indent=2)
|
||||
mime_type = 'application/json'
|
||||
filename = 'subs_{0}.srt.sjson'.format(subs_id)
|
||||
|
||||
content_location = StaticContent.compute_location(
|
||||
self.org, self.number, filename)
|
||||
content = StaticContent(content_location, filename, mime_type, filedata)
|
||||
contentstore().save(content)
|
||||
del_cached_content(content_location)
|
||||
return content_location
|
||||
|
||||
def remove_subs_from_store(self, subs_id):
|
||||
"""Remove from store, if transcripts content exists."""
|
||||
filename = 'subs_{0}.srt.sjson'.format(subs_id)
|
||||
content_location = StaticContent.compute_location(
|
||||
self.org, self.number, filename)
|
||||
try:
|
||||
content = contentstore().find(content_location)
|
||||
contentstore().delete(content.get_id())
|
||||
except NotFoundError:
|
||||
pass
|
||||
|
||||
def test_success_download_nonyoutube(self):
|
||||
subs_id = str(uuid4())
|
||||
data = textwrap.dedent("""
|
||||
<video youtube="" sub="{}">
|
||||
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.mp4"/>
|
||||
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.webm"/>
|
||||
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.ogv"/>
|
||||
</video>
|
||||
""".format(subs_id))
|
||||
modulestore().update_item(self.item_location, data)
|
||||
|
||||
subs = {
|
||||
'start': [100, 200, 240],
|
||||
'end': [200, 240, 380],
|
||||
'text': [
|
||||
'subs #1',
|
||||
'subs #2',
|
||||
'subs #3'
|
||||
]
|
||||
}
|
||||
self.save_subs_to_store(subs, subs_id)
|
||||
|
||||
data = {
|
||||
'id': self.item_location,
|
||||
'videos': [{
|
||||
'type': 'html5',
|
||||
'video': subs_id,
|
||||
'mode': 'mp4',
|
||||
}]
|
||||
}
|
||||
link = reverse('check_transcripts')
|
||||
resp = self.client.get(link, {'data': json.dumps(data)})
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertDictEqual(
|
||||
json.loads(resp.content),
|
||||
{
|
||||
u'status': u'Success',
|
||||
u'subs': unicode(subs_id),
|
||||
u'youtube_local': False,
|
||||
u'is_youtube_mode': False,
|
||||
u'youtube_server': False,
|
||||
u'command': u'found',
|
||||
u'current_item_subs': unicode(subs_id),
|
||||
u'youtube_diff': True,
|
||||
u'html5_local': [unicode(subs_id)],
|
||||
u'html5_equal': False,
|
||||
}
|
||||
)
|
||||
|
||||
transcripts_utils.remove_subs_from_store(subs_id, self.item)
|
||||
|
||||
def test_check_youtube(self):
|
||||
data = '<video youtube="1:JMD_ifUUfsU" />'
|
||||
modulestore().update_item(self.item_location, data)
|
||||
|
||||
subs = {
|
||||
'start': [100, 200, 240],
|
||||
'end': [200, 240, 380],
|
||||
'text': [
|
||||
'subs #1',
|
||||
'subs #2',
|
||||
'subs #3'
|
||||
]
|
||||
}
|
||||
self.save_subs_to_store(subs, 'JMD_ifUUfsU')
|
||||
link = reverse('check_transcripts')
|
||||
data = {
|
||||
'id': self.item_location,
|
||||
'videos': [{
|
||||
'type': 'youtube',
|
||||
'video': 'JMD_ifUUfsU',
|
||||
'mode': 'youtube',
|
||||
}]
|
||||
}
|
||||
resp = self.client.get(link, {'data': json.dumps(data)})
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertDictEqual(
|
||||
json.loads(resp.content),
|
||||
{
|
||||
u'status': u'Success',
|
||||
u'subs': u'JMD_ifUUfsU',
|
||||
u'youtube_local': True,
|
||||
u'is_youtube_mode': True,
|
||||
u'youtube_server': False,
|
||||
u'command': u'found',
|
||||
u'current_item_subs': None,
|
||||
u'youtube_diff': True,
|
||||
u'html5_local': [],
|
||||
u'html5_equal': False,
|
||||
}
|
||||
)
|
||||
|
||||
def test_fail_data_without_id(self):
|
||||
link = reverse('check_transcripts')
|
||||
data = {
|
||||
'id': '',
|
||||
'videos': [{
|
||||
'type': '',
|
||||
'video': '',
|
||||
'mode': '',
|
||||
}]
|
||||
}
|
||||
resp = self.client.get(link, {'data': json.dumps(data)})
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
self.assertEqual(json.loads(resp.content).get('status'), "Can't find item by location.")
|
||||
|
||||
def test_fail_data_with_bad_location(self):
|
||||
# Test for raising `InvalidLocationError` exception.
|
||||
link = reverse('check_transcripts')
|
||||
data = {
|
||||
'id': '',
|
||||
'videos': [{
|
||||
'type': '',
|
||||
'video': '',
|
||||
'mode': '',
|
||||
}]
|
||||
}
|
||||
resp = self.client.get(link, {'data': json.dumps(data)})
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
self.assertEqual(json.loads(resp.content).get('status'), "Can't find item by location.")
|
||||
|
||||
# Test for raising `ItemNotFoundError` exception.
|
||||
data = {
|
||||
'id': '{0}_{1}'.format(self.item_location, 'BAD_LOCATION'),
|
||||
'videos': [{
|
||||
'type': '',
|
||||
'video': '',
|
||||
'mode': '',
|
||||
}]
|
||||
}
|
||||
resp = self.client.get(link, {'data': json.dumps(data)})
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
self.assertEqual(json.loads(resp.content).get('status'), "Can't find item by location.")
|
||||
|
||||
def test_fail_for_non_video_module(self):
|
||||
# Not video module: setup
|
||||
data = {
|
||||
'parent_location': str(self.course_location),
|
||||
'category': 'not_video',
|
||||
'type': 'not_video'
|
||||
}
|
||||
resp = self.client.post(reverse('create_item'), data)
|
||||
item_location = json.loads(resp.content).get('id')
|
||||
subs_id = str(uuid4())
|
||||
data = textwrap.dedent("""
|
||||
<not_video youtube="" sub="{}">
|
||||
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.mp4"/>
|
||||
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.webm"/>
|
||||
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.ogv"/>
|
||||
</videoalpha>
|
||||
""".format(subs_id))
|
||||
modulestore().update_item(item_location, data)
|
||||
|
||||
subs = {
|
||||
'start': [100, 200, 240],
|
||||
'end': [200, 240, 380],
|
||||
'text': [
|
||||
'subs #1',
|
||||
'subs #2',
|
||||
'subs #3'
|
||||
]
|
||||
}
|
||||
self.save_subs_to_store(subs, subs_id)
|
||||
|
||||
data = {
|
||||
'id': item_location,
|
||||
'videos': [{
|
||||
'type': '',
|
||||
'video': '',
|
||||
'mode': '',
|
||||
}]
|
||||
}
|
||||
link = reverse('check_transcripts')
|
||||
resp = self.client.get(link, {'data': json.dumps(data)})
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
self.assertEqual(json.loads(resp.content).get('status'), 'Transcripts are supported only for "video" modules.')
|
||||
407
cms/djangoapps/contentstore/tests/test_transcripts_utils.py
Normal file
407
cms/djangoapps/contentstore/tests/test_transcripts_utils.py
Normal file
@@ -0,0 +1,407 @@
|
||||
""" Tests for transcripts_utils. """
|
||||
import unittest
|
||||
from uuid import uuid4
|
||||
import copy
|
||||
import textwrap
|
||||
|
||||
from pymongo import MongoClient
|
||||
|
||||
from django.test.utils import override_settings
|
||||
from django.conf import settings
|
||||
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
from xmodule.contentstore.content import StaticContent
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.exceptions import NotFoundError
|
||||
from xmodule.contentstore.django import contentstore, _CONTENTSTORE
|
||||
from contentstore import transcripts_utils
|
||||
|
||||
from contentstore.tests.modulestore_config import TEST_MODULESTORE
|
||||
TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE)
|
||||
TEST_DATA_CONTENTSTORE['DOC_STORE_CONFIG']['db'] = 'test_xcontent_%s' % uuid4().hex
|
||||
|
||||
|
||||
class TestGenerateSubs(unittest.TestCase):
|
||||
"""Tests for `generate_subs` function."""
|
||||
def setUp(self):
|
||||
self.source_subs = {
|
||||
'start': [100, 200, 240, 390, 1000],
|
||||
'end': [200, 240, 380, 1000, 1500],
|
||||
'text': [
|
||||
'subs #1',
|
||||
'subs #2',
|
||||
'subs #3',
|
||||
'subs #4',
|
||||
'subs #5'
|
||||
]
|
||||
}
|
||||
|
||||
def test_generate_subs_increase_speed(self):
|
||||
subs = transcripts_utils.generate_subs(2, 1, self.source_subs)
|
||||
self.assertDictEqual(
|
||||
subs,
|
||||
{
|
||||
'start': [200, 400, 480, 780, 2000],
|
||||
'end': [400, 480, 760, 2000, 3000],
|
||||
'text': ['subs #1', 'subs #2', 'subs #3', 'subs #4', 'subs #5']
|
||||
}
|
||||
)
|
||||
|
||||
def test_generate_subs_decrease_speed_1(self):
|
||||
subs = transcripts_utils.generate_subs(0.5, 1, self.source_subs)
|
||||
self.assertDictEqual(
|
||||
subs,
|
||||
{
|
||||
'start': [50, 100, 120, 195, 500],
|
||||
'end': [100, 120, 190, 500, 750],
|
||||
'text': ['subs #1', 'subs #2', 'subs #3', 'subs #4', 'subs #5']
|
||||
}
|
||||
)
|
||||
|
||||
def test_generate_subs_decrease_speed_2(self):
|
||||
"""Test for correct devision during `generate_subs` process."""
|
||||
subs = transcripts_utils.generate_subs(1, 2, self.source_subs)
|
||||
self.assertDictEqual(
|
||||
subs,
|
||||
{
|
||||
'start': [50, 100, 120, 195, 500],
|
||||
'end': [100, 120, 190, 500, 750],
|
||||
'text': ['subs #1', 'subs #2', 'subs #3', 'subs #4', 'subs #5']
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE, MODULESTORE=TEST_MODULESTORE)
|
||||
class TestSaveSubsToStore(ModuleStoreTestCase):
|
||||
"""Tests for `save_subs_to_store` function."""
|
||||
|
||||
org = 'MITx'
|
||||
number = '999'
|
||||
display_name = 'Test course'
|
||||
|
||||
def clear_subs_content(self):
|
||||
"""Remove, if subtitles content exists."""
|
||||
try:
|
||||
content = contentstore().find(self.content_location)
|
||||
contentstore().delete(content.get_id())
|
||||
except NotFoundError:
|
||||
pass
|
||||
|
||||
def setUp(self):
|
||||
|
||||
self.course = CourseFactory.create(
|
||||
org=self.org, number=self.number, display_name=self.display_name)
|
||||
|
||||
self.subs = {
|
||||
'start': [100, 200, 240, 390, 1000],
|
||||
'end': [200, 240, 380, 1000, 1500],
|
||||
'text': [
|
||||
'subs #1',
|
||||
'subs #2',
|
||||
'subs #3',
|
||||
'subs #4',
|
||||
'subs #5'
|
||||
]
|
||||
}
|
||||
|
||||
self.subs_id = str(uuid4())
|
||||
filename = 'subs_{0}.srt.sjson'.format(self.subs_id)
|
||||
self.content_location = StaticContent.compute_location(
|
||||
self.org, self.number, filename
|
||||
)
|
||||
|
||||
# incorrect subs
|
||||
self.unjsonable_subs = set([1]) # set can't be serialized
|
||||
|
||||
self.unjsonable_subs_id = str(uuid4())
|
||||
filename_unjsonable = 'subs_{0}.srt.sjson'.format(self.unjsonable_subs_id)
|
||||
self.content_location_unjsonable = StaticContent.compute_location(
|
||||
self.org, self.number, filename_unjsonable
|
||||
)
|
||||
|
||||
self.clear_subs_content()
|
||||
|
||||
def test_save_subs_to_store(self):
|
||||
with self.assertRaises(NotFoundError):
|
||||
contentstore().find(self.content_location)
|
||||
|
||||
result_location = transcripts_utils.save_subs_to_store(
|
||||
self.subs,
|
||||
self.subs_id,
|
||||
self.course)
|
||||
|
||||
self.assertTrue(contentstore().find(self.content_location))
|
||||
self.assertEqual(result_location, self.content_location)
|
||||
|
||||
def test_save_unjsonable_subs_to_store(self):
|
||||
"""
|
||||
Assures that subs, that can't be dumped, can't be found later.
|
||||
"""
|
||||
with self.assertRaises(NotFoundError):
|
||||
contentstore().find(self.content_location_unjsonable)
|
||||
|
||||
with self.assertRaises(TypeError):
|
||||
transcripts_utils.save_subs_to_store(
|
||||
self.unjsonable_subs,
|
||||
self.unjsonable_subs_id,
|
||||
self.course)
|
||||
|
||||
with self.assertRaises(NotFoundError):
|
||||
contentstore().find(self.content_location_unjsonable)
|
||||
|
||||
def tearDown(self):
|
||||
self.clear_subs_content()
|
||||
MongoClient().drop_database(TEST_DATA_CONTENTSTORE['DOC_STORE_CONFIG']['db'])
|
||||
_CONTENTSTORE.clear()
|
||||
|
||||
|
||||
@override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE, MODULESTORE=TEST_MODULESTORE)
|
||||
class TestDownloadYoutubeSubs(ModuleStoreTestCase):
|
||||
"""Tests for `download_youtube_subs` function."""
|
||||
|
||||
org = 'MITx'
|
||||
number = '999'
|
||||
display_name = 'Test course'
|
||||
|
||||
def clear_subs_content(self, youtube_subs):
|
||||
"""Remove, if subtitles content exists."""
|
||||
for subs_id in youtube_subs.values():
|
||||
filename = 'subs_{0}.srt.sjson'.format(subs_id)
|
||||
content_location = StaticContent.compute_location(
|
||||
self.org, self.number, filename
|
||||
)
|
||||
try:
|
||||
content = contentstore().find(content_location)
|
||||
contentstore().delete(content.get_id())
|
||||
except NotFoundError:
|
||||
pass
|
||||
|
||||
def setUp(self):
|
||||
self.course = CourseFactory.create(
|
||||
org=self.org, number=self.number, display_name=self.display_name)
|
||||
|
||||
def tearDown(self):
|
||||
MongoClient().drop_database(TEST_DATA_CONTENTSTORE['DOC_STORE_CONFIG']['db'])
|
||||
_CONTENTSTORE.clear()
|
||||
|
||||
def test_success_downloading_subs(self):
|
||||
good_youtube_subs = {
|
||||
0.5: 'JMD_ifUUfsU',
|
||||
1.0: 'hI10vDNYz4M',
|
||||
2.0: 'AKqURZnYqpk'
|
||||
}
|
||||
self.clear_subs_content(good_youtube_subs)
|
||||
|
||||
# Check transcripts_utils.GetTranscriptsFromYouTubeException not thrown
|
||||
transcripts_utils.download_youtube_subs(good_youtube_subs, self.course)
|
||||
|
||||
# Check assets status after importing subtitles.
|
||||
for subs_id in good_youtube_subs.values():
|
||||
filename = 'subs_{0}.srt.sjson'.format(subs_id)
|
||||
content_location = StaticContent.compute_location(
|
||||
self.org, self.number, filename
|
||||
)
|
||||
self.assertTrue(contentstore().find(content_location))
|
||||
|
||||
self.clear_subs_content(good_youtube_subs)
|
||||
|
||||
def test_fail_downloading_subs(self):
|
||||
bad_youtube_subs = {
|
||||
0.5: 'BAD_YOUTUBE_ID1',
|
||||
1.0: 'BAD_YOUTUBE_ID2',
|
||||
2.0: 'BAD_YOUTUBE_ID3'
|
||||
}
|
||||
self.clear_subs_content(bad_youtube_subs)
|
||||
|
||||
with self.assertRaises(transcripts_utils.GetTranscriptsFromYouTubeException):
|
||||
transcripts_utils.download_youtube_subs(bad_youtube_subs, self.course)
|
||||
|
||||
# Check assets status after importing subtitles.
|
||||
for subs_id in bad_youtube_subs.values():
|
||||
filename = 'subs_{0}.srt.sjson'.format(subs_id)
|
||||
content_location = StaticContent.compute_location(
|
||||
self.org, self.number, filename
|
||||
)
|
||||
with self.assertRaises(NotFoundError):
|
||||
contentstore().find(content_location)
|
||||
|
||||
self.clear_subs_content(bad_youtube_subs)
|
||||
|
||||
def test_success_downloading_chinise_transcripts(self):
|
||||
good_youtube_subs = {
|
||||
1.0: 'j_jEn79vS3g', # Chinese, utf-8
|
||||
}
|
||||
self.clear_subs_content(good_youtube_subs)
|
||||
|
||||
# Check transcripts_utils.GetTranscriptsFromYouTubeException not thrown
|
||||
transcripts_utils.download_youtube_subs(good_youtube_subs, self.course)
|
||||
|
||||
# Check assets status after importing subtitles.
|
||||
for subs_id in good_youtube_subs.values():
|
||||
filename = 'subs_{0}.srt.sjson'.format(subs_id)
|
||||
content_location = StaticContent.compute_location(
|
||||
self.org, self.number, filename
|
||||
)
|
||||
self.assertTrue(contentstore().find(content_location))
|
||||
|
||||
self.clear_subs_content(good_youtube_subs)
|
||||
|
||||
|
||||
class TestGenerateSubsFromSource(TestDownloadYoutubeSubs):
|
||||
"""Tests for `generate_subs_from_source` function."""
|
||||
|
||||
def test_success_generating_subs(self):
|
||||
youtube_subs = {
|
||||
0.5: 'JMD_ifUUfsU',
|
||||
1.0: 'hI10vDNYz4M',
|
||||
2.0: 'AKqURZnYqpk'
|
||||
}
|
||||
srt_filedata = textwrap.dedent("""
|
||||
1
|
||||
00:00:10,500 --> 00:00:13,000
|
||||
Elephant's Dream
|
||||
|
||||
2
|
||||
00:00:15,000 --> 00:00:18,000
|
||||
At the left we can see...
|
||||
""")
|
||||
self.clear_subs_content(youtube_subs)
|
||||
|
||||
# Check transcripts_utils.TranscriptsGenerationException not thrown
|
||||
transcripts_utils.generate_subs_from_source(youtube_subs, 'srt', srt_filedata, self.course)
|
||||
|
||||
# Check assets status after importing subtitles.
|
||||
for subs_id in youtube_subs.values():
|
||||
filename = 'subs_{0}.srt.sjson'.format(subs_id)
|
||||
content_location = StaticContent.compute_location(
|
||||
self.org, self.number, filename
|
||||
)
|
||||
self.assertTrue(contentstore().find(content_location))
|
||||
|
||||
self.clear_subs_content(youtube_subs)
|
||||
|
||||
def test_fail_bad_subs_type(self):
|
||||
youtube_subs = {
|
||||
0.5: 'JMD_ifUUfsU',
|
||||
1.0: 'hI10vDNYz4M',
|
||||
2.0: 'AKqURZnYqpk'
|
||||
}
|
||||
|
||||
srt_filedata = textwrap.dedent("""
|
||||
1
|
||||
00:00:10,500 --> 00:00:13,000
|
||||
Elephant's Dream
|
||||
|
||||
2
|
||||
00:00:15,000 --> 00:00:18,000
|
||||
At the left we can see...
|
||||
""")
|
||||
|
||||
with self.assertRaises(transcripts_utils.TranscriptsGenerationException) as cm:
|
||||
transcripts_utils.generate_subs_from_source(youtube_subs, 'BAD_FORMAT', srt_filedata, self.course)
|
||||
exception_message = cm.exception.message
|
||||
self.assertEqual(exception_message, "We support only SubRip (*.srt) transcripts format.")
|
||||
|
||||
def test_fail_bad_subs_filedata(self):
|
||||
youtube_subs = {
|
||||
0.5: 'JMD_ifUUfsU',
|
||||
1.0: 'hI10vDNYz4M',
|
||||
2.0: 'AKqURZnYqpk'
|
||||
}
|
||||
|
||||
srt_filedata = """BAD_DATA"""
|
||||
|
||||
with self.assertRaises(transcripts_utils.TranscriptsGenerationException) as cm:
|
||||
transcripts_utils.generate_subs_from_source(youtube_subs, 'srt', srt_filedata, self.course)
|
||||
exception_message = cm.exception.message
|
||||
self.assertEqual(exception_message, "Something wrong with SubRip transcripts file during parsing.")
|
||||
|
||||
|
||||
class TestGenerateSrtFromSjson(TestDownloadYoutubeSubs):
|
||||
"""Tests for `generate_srt_from_sjson` function."""
|
||||
|
||||
def test_success_generating_subs(self):
|
||||
sjson_subs = {
|
||||
'start': [100, 200, 240, 390, 54000],
|
||||
'end': [200, 240, 380, 1000, 78400],
|
||||
'text': [
|
||||
'subs #1',
|
||||
'subs #2',
|
||||
'subs #3',
|
||||
'subs #4',
|
||||
'subs #5'
|
||||
]
|
||||
}
|
||||
srt_subs = transcripts_utils.generate_srt_from_sjson(sjson_subs, 1)
|
||||
self.assertTrue(srt_subs)
|
||||
expected_subs = [
|
||||
'00:00:00,100 --> 00:00:00,200\nsubs #1',
|
||||
'00:00:00,200 --> 00:00:00,240\nsubs #2',
|
||||
'00:00:00,240 --> 00:00:00,380\nsubs #3',
|
||||
'00:00:00,390 --> 00:00:01,000\nsubs #4',
|
||||
'00:00:54,000 --> 00:01:18,400\nsubs #5',
|
||||
]
|
||||
|
||||
for sub in expected_subs:
|
||||
self.assertIn(sub, srt_subs)
|
||||
|
||||
def test_success_generating_subs_speed_up(self):
|
||||
sjson_subs = {
|
||||
'start': [100, 200, 240, 390, 54000],
|
||||
'end': [200, 240, 380, 1000, 78400],
|
||||
'text': [
|
||||
'subs #1',
|
||||
'subs #2',
|
||||
'subs #3',
|
||||
'subs #4',
|
||||
'subs #5'
|
||||
]
|
||||
}
|
||||
srt_subs = transcripts_utils.generate_srt_from_sjson(sjson_subs, 0.5)
|
||||
self.assertTrue(srt_subs)
|
||||
expected_subs = [
|
||||
'00:00:00,050 --> 00:00:00,100\nsubs #1',
|
||||
'00:00:00,100 --> 00:00:00,120\nsubs #2',
|
||||
'00:00:00,120 --> 00:00:00,190\nsubs #3',
|
||||
'00:00:00,195 --> 00:00:00,500\nsubs #4',
|
||||
'00:00:27,000 --> 00:00:39,200\nsubs #5',
|
||||
]
|
||||
for sub in expected_subs:
|
||||
self.assertIn(sub, srt_subs)
|
||||
|
||||
def test_success_generating_subs_speed_down(self):
|
||||
sjson_subs = {
|
||||
'start': [100, 200, 240, 390, 54000],
|
||||
'end': [200, 240, 380, 1000, 78400],
|
||||
'text': [
|
||||
'subs #1',
|
||||
'subs #2',
|
||||
'subs #3',
|
||||
'subs #4',
|
||||
'subs #5'
|
||||
]
|
||||
}
|
||||
srt_subs = transcripts_utils.generate_srt_from_sjson(sjson_subs, 2)
|
||||
self.assertTrue(srt_subs)
|
||||
|
||||
expected_subs = [
|
||||
'00:00:00,200 --> 00:00:00,400\nsubs #1',
|
||||
'00:00:00,400 --> 00:00:00,480\nsubs #2',
|
||||
'00:00:00,480 --> 00:00:00,760\nsubs #3',
|
||||
'00:00:00,780 --> 00:00:02,000\nsubs #4',
|
||||
'00:01:48,000 --> 00:02:36,800\nsubs #5',
|
||||
]
|
||||
for sub in expected_subs:
|
||||
self.assertIn(sub, srt_subs)
|
||||
|
||||
def test_fail_generating_subs(self):
|
||||
sjson_subs = {
|
||||
'start': [100, 200],
|
||||
'end': [100],
|
||||
'text': [
|
||||
'subs #1',
|
||||
'subs #2'
|
||||
]
|
||||
}
|
||||
srt_subs = transcripts_utils.generate_srt_from_sjson(sjson_subs, 1)
|
||||
self.assertFalse(srt_subs)
|
||||
@@ -1,12 +1,22 @@
|
||||
""" Tests for utils. """
|
||||
from contentstore import utils
|
||||
import mock
|
||||
import unittest
|
||||
import collections
|
||||
import copy
|
||||
import json
|
||||
from uuid import uuid4
|
||||
|
||||
from django.test import TestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
from django.test.utils import override_settings
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
|
||||
from xmodule.contentstore.content import StaticContent
|
||||
from xmodule.contentstore.django import contentstore
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.exceptions import NotFoundError
|
||||
|
||||
|
||||
class LMSLinksTestCase(TestCase):
|
||||
""" Tests for LMS links. """
|
||||
@@ -88,8 +98,10 @@ class ExtraPanelTabTestCase(TestCase):
|
||||
else:
|
||||
return []
|
||||
|
||||
def get_course_with_tabs(self, tabs=[]):
|
||||
def get_course_with_tabs(self, tabs=None):
|
||||
""" Returns a mock course object with a tabs attribute. """
|
||||
if tabs is None:
|
||||
tabs = []
|
||||
course = collections.namedtuple('MockCourse', ['tabs'])
|
||||
if isinstance(tabs, basestring):
|
||||
course.tabs = self.get_tab_type_dicts(tabs)
|
||||
|
||||
@@ -61,6 +61,7 @@ class CourseTestCase(ModuleStoreTestCase):
|
||||
number='999',
|
||||
display_name='Robot Super Course',
|
||||
)
|
||||
self.course_location = self.course.location
|
||||
|
||||
def createNonStaffAuthedUserClient(self):
|
||||
"""
|
||||
|
||||
341
cms/djangoapps/contentstore/transcripts_utils.py
Normal file
341
cms/djangoapps/contentstore/transcripts_utils.py
Normal file
@@ -0,0 +1,341 @@
|
||||
"""
|
||||
Utility functions for transcripts.
|
||||
++++++++++++++++++++++++++++++++++
|
||||
"""
|
||||
import copy
|
||||
import json
|
||||
import requests
|
||||
import logging
|
||||
from pysrt import SubRipTime, SubRipItem, SubRipFile
|
||||
from lxml import etree
|
||||
|
||||
from cache_toolbox.core import del_cached_content
|
||||
from django.conf import settings
|
||||
|
||||
from xmodule.exceptions import NotFoundError
|
||||
from xmodule.contentstore.content import StaticContent
|
||||
from xmodule.contentstore.django import contentstore
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.inheritance import own_metadata
|
||||
|
||||
from .utils import get_modulestore
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TranscriptsGenerationException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class GetTranscriptsFromYouTubeException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class TranscriptsRequestValidationException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def generate_subs(speed, source_speed, source_subs):
|
||||
"""
|
||||
Generate transcripts from one speed to another speed.
|
||||
|
||||
Args:
|
||||
`speed`: float, for this speed subtitles will be generated,
|
||||
`source_speed`: float, speed of source_subs
|
||||
`soource_subs`: dict, existing subtitles for speed `source_speed`.
|
||||
|
||||
Returns:
|
||||
`subs`: dict, actual subtitles.
|
||||
"""
|
||||
if speed == source_speed:
|
||||
return source_subs
|
||||
|
||||
coefficient = 1.0 * speed / source_speed
|
||||
subs = {
|
||||
'start': [
|
||||
int(round(timestamp * coefficient)) for
|
||||
timestamp in source_subs['start']
|
||||
],
|
||||
'end': [
|
||||
int(round(timestamp * coefficient)) for
|
||||
timestamp in source_subs['end']
|
||||
],
|
||||
'text': source_subs['text']}
|
||||
return subs
|
||||
|
||||
|
||||
def save_subs_to_store(subs, subs_id, item):
|
||||
"""
|
||||
Save transcripts into `StaticContent`.
|
||||
|
||||
Args:
|
||||
`subs_id`: str, subtitles id
|
||||
`item`: video module instance
|
||||
|
||||
Returns: location of saved subtitles.
|
||||
"""
|
||||
filedata = json.dumps(subs, indent=2)
|
||||
mime_type = 'application/json'
|
||||
filename = 'subs_{0}.srt.sjson'.format(subs_id)
|
||||
|
||||
content_location = StaticContent.compute_location(
|
||||
item.location.org, item.location.course, filename
|
||||
)
|
||||
content = StaticContent(content_location, filename, mime_type, filedata)
|
||||
contentstore().save(content)
|
||||
del_cached_content(content_location)
|
||||
return content_location
|
||||
|
||||
|
||||
def get_transcripts_from_youtube(youtube_id):
|
||||
"""
|
||||
Gets transcripts from youtube for youtube_id.
|
||||
|
||||
Parses only utf-8 encoded transcripts.
|
||||
Other encodings are not supported at the moment.
|
||||
|
||||
Returns (status, transcripts): bool, dict.
|
||||
"""
|
||||
utf8_parser = etree.XMLParser(encoding='utf-8')
|
||||
|
||||
youtube_api = copy.deepcopy(settings.YOUTUBE_API)
|
||||
youtube_api['params']['v'] = youtube_id
|
||||
data = requests.get(youtube_api['url'], params=youtube_api['params'])
|
||||
|
||||
if data.status_code != 200 or not data.text:
|
||||
msg = "Can't receive transcripts from Youtube for {}. Status code: {}.".format(
|
||||
youtube_id, data.status_code)
|
||||
raise GetTranscriptsFromYouTubeException(msg)
|
||||
|
||||
sub_starts, sub_ends, sub_texts = [], [], []
|
||||
xmltree = etree.fromstring(data.content, parser=utf8_parser)
|
||||
for element in xmltree:
|
||||
if element.tag == "text":
|
||||
start = float(element.get("start"))
|
||||
duration = float(element.get("dur", 0)) # dur is not mandatory
|
||||
text = element.text
|
||||
end = start + duration
|
||||
|
||||
if text:
|
||||
# Start and end should be ints representing the millisecond timestamp.
|
||||
sub_starts.append(int(start * 1000))
|
||||
sub_ends.append(int((end + 0.0001) * 1000))
|
||||
sub_texts.append(text.replace('\n', ' '))
|
||||
|
||||
return {'start': sub_starts, 'end': sub_ends, 'text': sub_texts}
|
||||
|
||||
|
||||
def download_youtube_subs(youtube_subs, item):
|
||||
"""
|
||||
Download transcripts from Youtube and save them to assets.
|
||||
|
||||
Args:
|
||||
youtube_subs: dictionary of `speed: youtube_id` key:value pairs.
|
||||
item: video module instance.
|
||||
|
||||
Returns: None, if transcripts were successfully downloaded and saved.
|
||||
Otherwise raises GetTranscriptsFromYouTubeException.
|
||||
"""
|
||||
highest_speed = highest_speed_subs = None
|
||||
missed_speeds = []
|
||||
# Iterate from lowest to highest speed and try to do download transcripts
|
||||
# from the Youtube service.
|
||||
for speed, youtube_id in sorted(youtube_subs.iteritems()):
|
||||
if not youtube_id:
|
||||
continue
|
||||
try:
|
||||
subs = get_transcripts_from_youtube(youtube_id)
|
||||
if not subs: # if empty subs are returned
|
||||
raise GetTranscriptsFromYouTubeException
|
||||
except GetTranscriptsFromYouTubeException:
|
||||
missed_speeds.append(speed)
|
||||
continue
|
||||
|
||||
save_subs_to_store(subs, youtube_id, item)
|
||||
|
||||
log.info(
|
||||
"Transcripts for YouTube id %s (speed %s)"
|
||||
"are downloaded and saved.", youtube_id, speed
|
||||
)
|
||||
|
||||
highest_speed = speed
|
||||
highest_speed_subs = subs
|
||||
|
||||
if not highest_speed:
|
||||
raise GetTranscriptsFromYouTubeException("Can't find any transcripts on the Youtube service.")
|
||||
|
||||
# When we exit from the previous loop, `highest_speed` and `highest_speed_subs`
|
||||
# are the transcripts data for the highest speed available on the
|
||||
# Youtube service. We use the highest speed as main speed for the
|
||||
# generation other transcripts, cause during calculation timestamps
|
||||
# for lower speeds we just use multiplication instead of division.
|
||||
for speed in missed_speeds: # Generate transcripts for missed speeds.
|
||||
save_subs_to_store(
|
||||
generate_subs(speed, highest_speed, highest_speed_subs),
|
||||
youtube_subs[speed],
|
||||
item
|
||||
)
|
||||
|
||||
log.info(
|
||||
"Transcripts for YouTube id %s (speed %s)"
|
||||
"are generated from YouTube id %s (speed %s) and saved",
|
||||
youtube_subs[speed], speed,
|
||||
youtube_subs[highest_speed],
|
||||
highest_speed
|
||||
)
|
||||
|
||||
|
||||
def remove_subs_from_store(subs_id, item):
|
||||
"""
|
||||
Remove from store, if transcripts content exists.
|
||||
"""
|
||||
filename = 'subs_{0}.srt.sjson'.format(subs_id)
|
||||
content_location = StaticContent.compute_location(
|
||||
item.location.org, item.location.course, filename
|
||||
)
|
||||
try:
|
||||
content = contentstore().find(content_location)
|
||||
contentstore().delete(content.get_id())
|
||||
log.info("Removed subs %s from store", subs_id)
|
||||
except NotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def generate_subs_from_source(speed_subs, subs_type, subs_filedata, item):
|
||||
"""Generate transcripts from source files (like SubRip format, etc.)
|
||||
and save them to assets for `item` module.
|
||||
We expect, that speed of source subs equal to 1
|
||||
|
||||
:param speed_subs: dictionary {speed: sub_id, ...}
|
||||
:param subs_type: type of source subs: "srt", ...
|
||||
:param subs_filedata:unicode, content of source subs.
|
||||
:param item: module object.
|
||||
:returns: True, if all subs are generated and saved successfully.
|
||||
"""
|
||||
if subs_type != 'srt':
|
||||
raise TranscriptsGenerationException("We support only SubRip (*.srt) transcripts format.")
|
||||
try:
|
||||
srt_subs_obj = SubRipFile.from_string(subs_filedata)
|
||||
except Exception as e:
|
||||
raise TranscriptsGenerationException(
|
||||
"Something wrong with SubRip transcripts file during parsing. "
|
||||
"Inner message is {}".format(e.message)
|
||||
)
|
||||
if not srt_subs_obj:
|
||||
raise TranscriptsGenerationException("Something wrong with SubRip transcripts file during parsing.")
|
||||
|
||||
sub_starts = []
|
||||
sub_ends = []
|
||||
sub_texts = []
|
||||
|
||||
for sub in srt_subs_obj:
|
||||
sub_starts.append(sub.start.ordinal)
|
||||
sub_ends.append(sub.end.ordinal)
|
||||
sub_texts.append(sub.text.replace('\n', ' '))
|
||||
|
||||
subs = {
|
||||
'start': sub_starts,
|
||||
'end': sub_ends,
|
||||
'text': sub_texts}
|
||||
|
||||
for speed, subs_id in speed_subs.iteritems():
|
||||
save_subs_to_store(
|
||||
generate_subs(speed, 1, subs),
|
||||
subs_id,
|
||||
item
|
||||
)
|
||||
|
||||
return subs
|
||||
|
||||
|
||||
def generate_srt_from_sjson(sjson_subs, speed):
|
||||
"""Generate transcripts with speed = 1.0 from sjson to SubRip (*.srt).
|
||||
|
||||
:param sjson_subs: "sjson" subs.
|
||||
:param speed: speed of `sjson_subs`.
|
||||
:returns: "srt" subs.
|
||||
"""
|
||||
|
||||
output = ''
|
||||
|
||||
equal_len = len(sjson_subs['start']) == len(sjson_subs['end']) == len(sjson_subs['text'])
|
||||
if not equal_len:
|
||||
return output
|
||||
|
||||
sjson_speed_1 = generate_subs(speed, 1, sjson_subs)
|
||||
|
||||
for i in range(len(sjson_speed_1['start'])):
|
||||
item = SubRipItem(
|
||||
index=i,
|
||||
start=SubRipTime(milliseconds=sjson_speed_1['start'][i]),
|
||||
end=SubRipTime(milliseconds=sjson_speed_1['end'][i]),
|
||||
text=sjson_speed_1['text'][i]
|
||||
)
|
||||
output += (unicode(item))
|
||||
output += '\n'
|
||||
return output
|
||||
|
||||
|
||||
def save_module(item):
|
||||
"""
|
||||
Proceed with additional save operations.
|
||||
"""
|
||||
item.save()
|
||||
store = get_modulestore(Location(item.id))
|
||||
store.update_metadata(item.id, own_metadata(item))
|
||||
|
||||
|
||||
def copy_or_rename_transcript(new_name, old_name, item, delete_old=False):
|
||||
"""
|
||||
Renames `old_name` transcript file in storage to `new_name`.
|
||||
|
||||
If `old_name` is not found in storage, raises `NotFoundError`.
|
||||
If `delete_old` is True, removes `old_name` files from storage.
|
||||
"""
|
||||
filename = 'subs_{0}.srt.sjson'.format(old_name)
|
||||
content_location = StaticContent.compute_location(
|
||||
item.location.org, item.location.course, filename
|
||||
)
|
||||
transcripts = contentstore().find(content_location).data
|
||||
save_subs_to_store(json.loads(transcripts), new_name, item)
|
||||
item.sub = new_name
|
||||
save_module(item)
|
||||
if delete_old:
|
||||
remove_subs_from_store(old_name, item)
|
||||
|
||||
|
||||
def manage_video_subtitles_save(old_item, new_item):
|
||||
"""
|
||||
Does some specific things, that can be done only on save.
|
||||
|
||||
Video player item has some video fields: HTML5 ones and Youtube one.
|
||||
|
||||
1. If value of `sub` field of `new_item` is different from values of video fields of `new_item`,
|
||||
and `new_item.sub` file is present, then code in this function creates copies of
|
||||
`new_item.sub` file with new names. That names are equal to values of video fields of `new_item`
|
||||
After that `sub` field of `new_item` is changed to one of values of video fields.
|
||||
This whole action ensures that after user changes video fields, proper `sub` files, corresponding
|
||||
to new values of video fields, will be presented in system.
|
||||
|
||||
old_item is not used here, but is added for future changes.
|
||||
"""
|
||||
|
||||
# 1.
|
||||
# assume '.' and '/' are not in filenames
|
||||
html5_ids = [x.split('/')[-1].split('.')[0] for x in new_item.html5_sources]
|
||||
possible_video_id_list = [new_item.youtube_id_1_0] + html5_ids
|
||||
sub_name = new_item.sub
|
||||
for video_id in possible_video_id_list:
|
||||
if not video_id:
|
||||
continue
|
||||
# copy_or_rename_transcript changes item.sub of module
|
||||
try:
|
||||
# updates item.sub with `video_id`, if it is successful.
|
||||
copy_or_rename_transcript(video_id, sub_name, new_item)
|
||||
except NotFoundError:
|
||||
# subtitles file `sub_name` is not presented in the system. Nothing to copy or rename.
|
||||
log.debug(
|
||||
"Copying %s file content to %s name is failed, "
|
||||
"original file does not exist.",
|
||||
sub_name, video_id
|
||||
)
|
||||
@@ -1,20 +1,23 @@
|
||||
#pylint: disable=E1103, E1101
|
||||
|
||||
from django.conf import settings
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
from xmodule.contentstore.content import StaticContent
|
||||
from xmodule.contentstore.django import contentstore
|
||||
import copy
|
||||
import logging
|
||||
import re
|
||||
from xmodule.modulestore.draft import DIRECT_ONLY_CATEGORIES
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
from xmodule.contentstore.content import StaticContent
|
||||
from xmodule.contentstore.django import contentstore
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
from django_comment_common.utils import unseed_permissions_roles
|
||||
from auth.authz import _delete_course_group
|
||||
from xmodule.modulestore.store_utilities import delete_course
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
from xmodule.modulestore.draft import DIRECT_ONLY_CATEGORIES
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from .preview import *
|
||||
from .public import *
|
||||
from .user import *
|
||||
from .tabs import *
|
||||
from .transcripts_ajax import *
|
||||
try:
|
||||
from .dev import *
|
||||
except ImportError:
|
||||
|
||||
@@ -1,26 +1,33 @@
|
||||
"""Views for items (modules)."""
|
||||
|
||||
import logging
|
||||
from uuid import uuid4
|
||||
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.http import HttpResponseBadRequest
|
||||
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.inheritance import own_metadata
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationError
|
||||
|
||||
from util.json_request import expect_json, JsonResponse
|
||||
|
||||
from ..transcripts_utils import manage_video_subtitles_save
|
||||
|
||||
from ..utils import get_modulestore
|
||||
|
||||
from .access import has_access
|
||||
from .helpers import _xmodule_recurse
|
||||
from xmodule.x_module import XModuleDescriptor
|
||||
|
||||
__all__ = ['save_item', 'create_item', 'delete_item']
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# cdodge: these are categories which should not be parented, they are detached from the hierarchy
|
||||
DETACHED_CATEGORIES = ['about', 'static_tab', 'course_info']
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@login_required
|
||||
@expect_json
|
||||
@@ -52,8 +59,13 @@ def save_item(request):
|
||||
inspect.currentframe().f_back.f_code.co_name,
|
||||
inspect.currentframe().f_back.f_code.co_filename
|
||||
)
|
||||
return HttpResponseBadRequest()
|
||||
return JsonResponse({"error": "Request missing required attribute 'id'."}, 400)
|
||||
|
||||
try:
|
||||
old_item = modulestore().get_item(item_location)
|
||||
except (ItemNotFoundError, InvalidLocationError):
|
||||
log.error("Can't find item by location.")
|
||||
return JsonResponse({"error": "Can't find item by location"}, 404)
|
||||
|
||||
# check permissions for this user within this course
|
||||
if not has_access(request.user, item_location):
|
||||
@@ -101,12 +113,16 @@ def save_item(request):
|
||||
# commit to datastore
|
||||
store.update_metadata(item_location, own_metadata(existing_item))
|
||||
|
||||
if existing_item.category == 'video':
|
||||
manage_video_subtitles_save(old_item, existing_item)
|
||||
|
||||
return JsonResponse()
|
||||
|
||||
|
||||
@login_required
|
||||
@expect_json
|
||||
def create_item(request):
|
||||
"""View for create items."""
|
||||
parent_location = Location(request.POST['parent_location'])
|
||||
category = request.POST['category']
|
||||
|
||||
@@ -149,6 +165,7 @@ def create_item(request):
|
||||
@login_required
|
||||
@expect_json
|
||||
def delete_item(request):
|
||||
"""View for removing items."""
|
||||
item_location = request.POST['id']
|
||||
item_location = Location(item_location)
|
||||
|
||||
|
||||
555
cms/djangoapps/contentstore/views/transcripts_ajax.py
Normal file
555
cms/djangoapps/contentstore/views/transcripts_ajax.py
Normal file
@@ -0,0 +1,555 @@
|
||||
"""
|
||||
Actions manager for transcripts ajax calls.
|
||||
+++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
Module do not support rollback (pressing "Cancel" button in Studio)
|
||||
All user changes are saved immediately.
|
||||
"""
|
||||
import copy
|
||||
import os
|
||||
import logging
|
||||
import json
|
||||
import requests
|
||||
|
||||
from django.http import HttpResponse, Http404
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.conf import settings
|
||||
|
||||
from xmodule.contentstore.content import StaticContent
|
||||
from xmodule.exceptions import NotFoundError
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.contentstore.django import contentstore
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationError
|
||||
|
||||
from util.json_request import JsonResponse
|
||||
|
||||
from ..transcripts_utils import (
|
||||
generate_subs_from_source,
|
||||
generate_srt_from_sjson, remove_subs_from_store,
|
||||
download_youtube_subs, get_transcripts_from_youtube,
|
||||
copy_or_rename_transcript,
|
||||
save_module,
|
||||
manage_video_subtitles_save,
|
||||
TranscriptsGenerationException,
|
||||
GetTranscriptsFromYouTubeException,
|
||||
TranscriptsRequestValidationException
|
||||
)
|
||||
|
||||
from .access import has_access
|
||||
|
||||
__all__ = [
|
||||
'upload_transcripts',
|
||||
'download_transcripts',
|
||||
'check_transcripts',
|
||||
'choose_transcripts',
|
||||
'replace_transcripts',
|
||||
'rename_transcripts',
|
||||
'save_transcripts'
|
||||
]
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def error_response(response, message, status_code=400):
|
||||
"""
|
||||
Simplify similar actions: log message and return JsonResponse with message included in response.
|
||||
|
||||
By default return 400 (Bad Request) Response.
|
||||
"""
|
||||
log.debug(message)
|
||||
response['status'] = message
|
||||
return JsonResponse(response, status_code)
|
||||
|
||||
|
||||
@login_required
|
||||
def upload_transcripts(request):
|
||||
"""
|
||||
Upload transcripts for current module.
|
||||
|
||||
returns: response dict::
|
||||
|
||||
status: 'Success' and HTTP 200 or 'Error' and HTTP 400.
|
||||
subs: Value of uploaded and saved html5 sub field in video item.
|
||||
"""
|
||||
response = {
|
||||
'status': 'Unknown server error',
|
||||
'subs': '',
|
||||
}
|
||||
|
||||
item_location = request.POST.get('id')
|
||||
if not item_location:
|
||||
return error_response(response, 'POST data without "id" form data.')
|
||||
|
||||
# This is placed before has_access() to validate item_location,
|
||||
# because has_access() raises InvalidLocationError if location is invalid.
|
||||
try:
|
||||
item = modulestore().get_item(item_location)
|
||||
except (ItemNotFoundError, InvalidLocationError):
|
||||
return error_response(response, "Can't find item by location.")
|
||||
|
||||
# Check permissions for this user within this course.
|
||||
if not has_access(request.user, item_location):
|
||||
raise PermissionDenied()
|
||||
|
||||
if 'file' not in request.FILES:
|
||||
return error_response(response, 'POST data without "file" form data.')
|
||||
|
||||
video_list = request.POST.get('video_list')
|
||||
if not video_list:
|
||||
return error_response(response, 'POST data without video names.')
|
||||
|
||||
try:
|
||||
video_list = json.loads(video_list)
|
||||
except ValueError:
|
||||
return error_response(response, 'Invalid video_list JSON.')
|
||||
|
||||
source_subs_filedata = request.FILES['file'].read().decode('utf8')
|
||||
source_subs_filename = request.FILES['file'].name
|
||||
|
||||
if '.' not in source_subs_filename:
|
||||
return error_response(response, "Undefined file extension.")
|
||||
|
||||
basename = os.path.basename(source_subs_filename)
|
||||
source_subs_name = os.path.splitext(basename)[0]
|
||||
source_subs_ext = os.path.splitext(basename)[1][1:]
|
||||
|
||||
if item.category != 'video':
|
||||
return error_response(response, 'Transcripts are supported only for "video" modules.')
|
||||
|
||||
# Allow upload only if any video link is presented
|
||||
if video_list:
|
||||
sub_attr = source_subs_name
|
||||
|
||||
try: # Generate and save for 1.0 speed, will create subs_sub_attr.srt.sjson subtitles file in storage.
|
||||
generate_subs_from_source({1: sub_attr}, source_subs_ext, source_subs_filedata, item)
|
||||
except TranscriptsGenerationException as e:
|
||||
return error_response(response, e.message)
|
||||
statuses = {}
|
||||
for video_dict in video_list:
|
||||
video_name = video_dict['video']
|
||||
# We are creating transcripts for every video source,
|
||||
# for the case that in future, some of video sources can be deleted.
|
||||
statuses[video_name] = copy_or_rename_transcript(video_name, sub_attr, item)
|
||||
try:
|
||||
# updates item.sub with `video_name` if it is successful.
|
||||
copy_or_rename_transcript(video_name, sub_attr, item)
|
||||
selected_name = video_name # name to write to item.sub field, chosen at random.
|
||||
except NotFoundError:
|
||||
# subtitles file `sub_attr` is not presented in the system. Nothing to copy or rename.
|
||||
return error_response(response, "Can't find transcripts in storage for {}".format(sub_attr))
|
||||
|
||||
item.sub = selected_name # write one of new subtitles names to item.sub attribute.
|
||||
save_module(item)
|
||||
response['subs'] = item.sub
|
||||
response['status'] = 'Success'
|
||||
else:
|
||||
return error_response(response, 'Empty video sources.')
|
||||
|
||||
return JsonResponse(response)
|
||||
|
||||
|
||||
@login_required
|
||||
def download_transcripts(request):
|
||||
"""
|
||||
Passes to user requested transcripts file.
|
||||
|
||||
Raises Http404 if unsuccessful.
|
||||
"""
|
||||
item_location = request.GET.get('id')
|
||||
if not item_location:
|
||||
log.debug('GET data without "id" property.')
|
||||
raise Http404
|
||||
|
||||
# This is placed before has_access() to validate item_location,
|
||||
# because has_access() raises InvalidLocationError if location is invalid.
|
||||
try:
|
||||
item = modulestore().get_item(item_location)
|
||||
except (ItemNotFoundError, InvalidLocationError):
|
||||
log.debug("Can't find item by location.")
|
||||
raise Http404
|
||||
|
||||
# Check permissions for this user within this course.
|
||||
if not has_access(request.user, item_location):
|
||||
raise PermissionDenied()
|
||||
|
||||
subs_id = request.GET.get('subs_id')
|
||||
if not subs_id:
|
||||
log.debug('GET data without "subs_id" property.')
|
||||
raise Http404
|
||||
|
||||
if item.category != 'video':
|
||||
log.debug('transcripts are supported only for video" modules.')
|
||||
raise Http404
|
||||
|
||||
filename = 'subs_{0}.srt.sjson'.format(subs_id)
|
||||
content_location = StaticContent.compute_location(
|
||||
item.location.org, item.location.course, filename
|
||||
)
|
||||
try:
|
||||
sjson_transcripts = contentstore().find(content_location)
|
||||
log.debug("Downloading subs for %s id", subs_id)
|
||||
str_subs = generate_srt_from_sjson(json.loads(sjson_transcripts.data), speed=1.0)
|
||||
if not str_subs:
|
||||
log.debug('generate_srt_from_sjson produces no subtitles')
|
||||
raise Http404
|
||||
response = HttpResponse(str_subs, content_type='application/x-subrip')
|
||||
response['Content-Disposition'] = 'attachment; filename="{0}.srt"'.format(subs_id)
|
||||
return response
|
||||
except NotFoundError:
|
||||
log.debug("Can't find content in storage for %s subs", subs_id)
|
||||
raise Http404
|
||||
|
||||
|
||||
@login_required
|
||||
def check_transcripts(request):
|
||||
"""
|
||||
Check state of transcripts availability.
|
||||
|
||||
request.GET['data'] has key `videos`, which can contain any of the following::
|
||||
|
||||
[
|
||||
{u'type': u'youtube', u'video': u'OEoXaMPEzfM', u'mode': u'youtube'},
|
||||
{u'type': u'html5', u'video': u'video1', u'mode': u'mp4'}
|
||||
{u'type': u'html5', u'video': u'video2', u'mode': u'webm'}
|
||||
]
|
||||
`type` is youtube or html5
|
||||
`video` is html5 or youtube video_id
|
||||
`mode` is youtube, ,p4 or webm
|
||||
|
||||
Returns transcripts_presence dict::
|
||||
|
||||
html5_local: list of html5 ids, if subtitles exist locally for them;
|
||||
is_youtube_mode: bool, if we have youtube_id, and as youtube mode is of higher priority, reflect this with flag;
|
||||
youtube_local: bool, if youtube transcripts exist locally;
|
||||
youtube_server: bool, if youtube transcripts exist on server;
|
||||
youtube_diff: bool, if youtube transcripts exist on youtube server, and are different from local youtube ones;
|
||||
current_item_subs: string, value of item.sub field;
|
||||
status: string, 'Error' or 'Success';
|
||||
subs: string, new value of item.sub field, that should be set in module;
|
||||
command: string, action to front-end what to do and what to show to user.
|
||||
"""
|
||||
transcripts_presence = {
|
||||
'html5_local': [],
|
||||
'html5_equal': False,
|
||||
'is_youtube_mode': False,
|
||||
'youtube_local': False,
|
||||
'youtube_server': False,
|
||||
'youtube_diff': True,
|
||||
'current_item_subs': None,
|
||||
'status': 'Error',
|
||||
}
|
||||
try:
|
||||
__, videos, item = validate_transcripts_data(request)
|
||||
except TranscriptsRequestValidationException as e:
|
||||
return error_response(transcripts_presence, e.message)
|
||||
|
||||
transcripts_presence['status'] = 'Success'
|
||||
|
||||
filename = 'subs_{0}.srt.sjson'.format(item.sub)
|
||||
content_location = StaticContent.compute_location(
|
||||
item.location.org, item.location.course, filename
|
||||
)
|
||||
try:
|
||||
local_transcripts = contentstore().find(content_location).data
|
||||
transcripts_presence['current_item_subs'] = item.sub
|
||||
except NotFoundError:
|
||||
pass
|
||||
|
||||
# Check for youtube transcripts presence
|
||||
youtube_id = videos.get('youtube', None)
|
||||
if youtube_id:
|
||||
transcripts_presence['is_youtube_mode'] = True
|
||||
|
||||
# youtube local
|
||||
filename = 'subs_{0}.srt.sjson'.format(youtube_id)
|
||||
content_location = StaticContent.compute_location(
|
||||
item.location.org, item.location.course, filename
|
||||
)
|
||||
try:
|
||||
local_transcripts = contentstore().find(content_location).data
|
||||
transcripts_presence['youtube_local'] = True
|
||||
except NotFoundError:
|
||||
log.debug("Can't find transcripts in storage for youtube id: %s", youtube_id)
|
||||
|
||||
# youtube server
|
||||
youtube_api = copy.deepcopy(settings.YOUTUBE_API)
|
||||
youtube_api['params']['v'] = youtube_id
|
||||
youtube_response = requests.get(youtube_api['url'], params=youtube_api['params'])
|
||||
|
||||
if youtube_response.status_code == 200 and youtube_response.text:
|
||||
transcripts_presence['youtube_server'] = True
|
||||
#check youtube local and server transcripts for equality
|
||||
if transcripts_presence['youtube_server'] and transcripts_presence['youtube_local']:
|
||||
try:
|
||||
youtube_server_subs = get_transcripts_from_youtube(youtube_id)
|
||||
if json.loads(local_transcripts) == youtube_server_subs: # check transcripts for equality
|
||||
transcripts_presence['youtube_diff'] = False
|
||||
except GetTranscriptsFromYouTubeException:
|
||||
pass
|
||||
|
||||
# Check for html5 local transcripts presence
|
||||
html5_subs = []
|
||||
for html5_id in videos['html5']:
|
||||
filename = 'subs_{0}.srt.sjson'.format(html5_id)
|
||||
content_location = StaticContent.compute_location(
|
||||
item.location.org, item.location.course, filename
|
||||
)
|
||||
try:
|
||||
html5_subs.append(contentstore().find(content_location).data)
|
||||
transcripts_presence['html5_local'].append(html5_id)
|
||||
except NotFoundError:
|
||||
log.debug("Can't find transcripts in storage for non-youtube video_id: %s", html5_id)
|
||||
if len(html5_subs) == 2: # check html5 transcripts for equality
|
||||
transcripts_presence['html5_equal'] = json.loads(html5_subs[0]) == json.loads(html5_subs[1])
|
||||
|
||||
command, subs_to_use = transcripts_logic(transcripts_presence, videos)
|
||||
transcripts_presence.update({
|
||||
'command': command,
|
||||
'subs': subs_to_use,
|
||||
})
|
||||
return JsonResponse(transcripts_presence)
|
||||
|
||||
|
||||
def transcripts_logic(transcripts_presence, videos):
|
||||
"""
|
||||
By `transcripts_presence` content, figure what show to user:
|
||||
|
||||
returns: `command` and `subs`.
|
||||
|
||||
`command`: string, action to front-end what to do and what show to user.
|
||||
`subs`: string, new value of item.sub field, that should be set in module.
|
||||
|
||||
`command` is one of::
|
||||
|
||||
replace: replace local youtube subtitles with server one's
|
||||
found: subtitles are found
|
||||
import: import subtitles from youtube server
|
||||
choose: choose one from two html5 subtitles
|
||||
not found: subtitles are not found
|
||||
"""
|
||||
command = None
|
||||
|
||||
# new value of item.sub field, that should be set in module.
|
||||
subs = ''
|
||||
|
||||
# youtube transcripts are of high priority than html5 by design
|
||||
if (
|
||||
transcripts_presence['youtube_diff'] and
|
||||
transcripts_presence['youtube_local'] and
|
||||
transcripts_presence['youtube_server']): # youtube server and local exist
|
||||
command = 'replace'
|
||||
subs = videos['youtube']
|
||||
elif transcripts_presence['youtube_local']: # only youtube local exist
|
||||
command = 'found'
|
||||
subs = videos['youtube']
|
||||
elif transcripts_presence['youtube_server']: # only youtube server exist
|
||||
command = 'import'
|
||||
else: # html5 part
|
||||
if transcripts_presence['html5_local']: # can be 1 or 2 html5 videos
|
||||
if len(transcripts_presence['html5_local']) == 1 or transcripts_presence['html5_equal']:
|
||||
command = 'found'
|
||||
subs = transcripts_presence['html5_local'][0]
|
||||
else:
|
||||
command = 'choose'
|
||||
subs = transcripts_presence['html5_local'][0]
|
||||
else: # html5 source have no subtitles
|
||||
# check if item sub has subtitles
|
||||
if transcripts_presence['current_item_subs'] and not transcripts_presence['is_youtube_mode']:
|
||||
log.debug("Command is use existing %s subs", transcripts_presence['current_item_subs'])
|
||||
command = 'use_existing'
|
||||
else:
|
||||
command = 'not_found'
|
||||
log.debug(
|
||||
"Resulted command: %s, current transcripts: %s, youtube mode: %s",
|
||||
command,
|
||||
transcripts_presence['current_item_subs'],
|
||||
transcripts_presence['is_youtube_mode']
|
||||
)
|
||||
return command, subs
|
||||
|
||||
|
||||
@login_required
|
||||
def choose_transcripts(request):
|
||||
"""
|
||||
Replaces html5 subtitles, presented for both html5 sources, with chosen one.
|
||||
|
||||
Code removes rejected html5 subtitles and updates sub attribute with chosen html5_id.
|
||||
|
||||
It does nothing with youtube id's.
|
||||
|
||||
Returns: status `Success` and resulted item.sub value or status `Error` and HTTP 400.
|
||||
"""
|
||||
response = {
|
||||
'status': 'Error',
|
||||
'subs': '',
|
||||
}
|
||||
|
||||
try:
|
||||
data, videos, item = validate_transcripts_data(request)
|
||||
except TranscriptsRequestValidationException as e:
|
||||
return error_response(response, e.message)
|
||||
|
||||
html5_id = data.get('html5_id') # html5_id chosen by user
|
||||
|
||||
# find rejected html5_id and remove appropriate subs from store
|
||||
html5_id_to_remove = [x for x in videos['html5'] if x != html5_id]
|
||||
if html5_id_to_remove:
|
||||
remove_subs_from_store(html5_id_to_remove, item)
|
||||
|
||||
if item.sub != html5_id: # update sub value
|
||||
item.sub = html5_id
|
||||
save_module(item)
|
||||
response = {'status': 'Success', 'subs': item.sub}
|
||||
return JsonResponse(response)
|
||||
|
||||
|
||||
@login_required
|
||||
def replace_transcripts(request):
|
||||
"""
|
||||
Replaces all transcripts with youtube ones.
|
||||
|
||||
Downloads subtitles from youtube and replaces all transcripts with downloaded ones.
|
||||
|
||||
Returns: status `Success` and resulted item.sub value or status `Error` and HTTP 400.
|
||||
"""
|
||||
response = {'status': 'Error', 'subs': ''}
|
||||
|
||||
try:
|
||||
__, videos, item = validate_transcripts_data(request)
|
||||
except TranscriptsRequestValidationException as e:
|
||||
return error_response(response, e.message)
|
||||
|
||||
youtube_id = videos['youtube']
|
||||
if not youtube_id:
|
||||
return error_response(response, 'YouTube id {} is not presented in request data.'.format(youtube_id))
|
||||
|
||||
try:
|
||||
download_youtube_subs({1.0: youtube_id}, item)
|
||||
except GetTranscriptsFromYouTubeException as e:
|
||||
return error_response(response, e.message)
|
||||
|
||||
item.sub = youtube_id
|
||||
save_module(item)
|
||||
response = {'status': 'Success', 'subs': item.sub}
|
||||
return JsonResponse(response)
|
||||
|
||||
|
||||
def validate_transcripts_data(request):
|
||||
"""
|
||||
Validates, that request contains all proper data for transcripts processing.
|
||||
|
||||
Returns tuple of 3 elements::
|
||||
|
||||
data: dict, loaded json from request,
|
||||
videos: parsed `data` to useful format,
|
||||
item: video item from storage
|
||||
|
||||
Raises `TranscriptsRequestValidationException` if validation is unsuccessful
|
||||
or `PermissionDenied` if user has no access.
|
||||
"""
|
||||
data = json.loads(request.GET.get('data', '{}'))
|
||||
if not data:
|
||||
raise TranscriptsRequestValidationException('Incoming video data is empty.')
|
||||
|
||||
item_location = data.get('id')
|
||||
|
||||
# This is placed before has_access() to validate item_location,
|
||||
# because has_access() raises InvalidLocationError if location is invalid.
|
||||
try:
|
||||
item = modulestore().get_item(item_location)
|
||||
except (ItemNotFoundError, InvalidLocationError):
|
||||
raise TranscriptsRequestValidationException("Can't find item by location.")
|
||||
|
||||
# Check permissions for this user within this course.
|
||||
if not has_access(request.user, item_location):
|
||||
raise PermissionDenied()
|
||||
|
||||
if item.category != 'video':
|
||||
raise TranscriptsRequestValidationException('Transcripts are supported only for "video" modules.')
|
||||
|
||||
# parse data form request.GET.['data']['video'] to useful format
|
||||
videos = {'youtube': '', 'html5': {}}
|
||||
for video_data in data.get('videos'):
|
||||
if video_data['type'] == 'youtube':
|
||||
videos['youtube'] = video_data['video']
|
||||
else: # do not add same html5 videos
|
||||
if videos['html5'].get('video') != video_data['video']:
|
||||
videos['html5'][video_data['video']] = video_data['mode']
|
||||
|
||||
return data, videos, item
|
||||
|
||||
|
||||
@login_required
|
||||
def rename_transcripts(request):
|
||||
"""
|
||||
Create copies of existing subtitles with new names of HTML5 sources.
|
||||
|
||||
Old subtitles are not deleted now, because we do not have rollback functionality.
|
||||
|
||||
If succeed, Item.sub will be chosen randomly from html5 video sources provided by front-end.
|
||||
"""
|
||||
response = {'status': 'Error', 'subs': ''}
|
||||
|
||||
try:
|
||||
__, videos, item = validate_transcripts_data(request)
|
||||
except TranscriptsRequestValidationException as e:
|
||||
return error_response(response, e.message)
|
||||
|
||||
old_name = item.sub
|
||||
|
||||
for new_name in videos['html5'].keys(): # copy subtitles for every HTML5 source
|
||||
try:
|
||||
# updates item.sub with new_name if it is successful.
|
||||
copy_or_rename_transcript(new_name, old_name, item)
|
||||
except NotFoundError:
|
||||
# subtitles file `item.sub` is not presented in the system. Nothing to copy or rename.
|
||||
error_response(response, "Can't find transcripts in storage for {}".format(old_name))
|
||||
|
||||
response['status'] = 'Success'
|
||||
response['subs'] = item.sub # item.sub has been changed, it is not equal to old_name.
|
||||
log.debug("Updated item.sub to %s", item.sub)
|
||||
return JsonResponse(response)
|
||||
|
||||
|
||||
@login_required
|
||||
def save_transcripts(request):
|
||||
"""
|
||||
Saves video module with updated values of fields.
|
||||
|
||||
Returns: status `Success` or status `Error` and HTTP 400.
|
||||
"""
|
||||
response = {'status': 'Error'}
|
||||
|
||||
data = json.loads(request.GET.get('data', '{}'))
|
||||
if not data:
|
||||
return error_response(response, 'Incoming video data is empty.')
|
||||
|
||||
item_location = data.get('id')
|
||||
try:
|
||||
item = modulestore().get_item(item_location)
|
||||
except (ItemNotFoundError, InvalidLocationError):
|
||||
return error_response(response, "Can't find item by location.")
|
||||
|
||||
metadata = data.get('metadata')
|
||||
if metadata is not None:
|
||||
new_sub = metadata.get('sub')
|
||||
|
||||
for metadata_key, value in metadata.items():
|
||||
setattr(item, metadata_key, value)
|
||||
|
||||
save_module(item) # item becomes updated with new values
|
||||
|
||||
if new_sub:
|
||||
manage_video_subtitles_save(None, item)
|
||||
else:
|
||||
# If `new_sub` is empty, it means that user explicitly does not want to use
|
||||
# transcripts for current video ids and we remove all transcripts from storage.
|
||||
current_subs = data.get('current_subs')
|
||||
if current_subs is not None:
|
||||
for sub in current_subs:
|
||||
remove_subs_from_store(sub, item)
|
||||
|
||||
response['status'] = 'Success'
|
||||
|
||||
return JsonResponse(response)
|
||||
@@ -114,3 +114,16 @@ if LETTUCE_SELENIUM_CLIENT == 'saucelabs':
|
||||
LETTUCE_SERVER_PORT = choice(PORTS)
|
||||
else:
|
||||
LETTUCE_SERVER_PORT = randint(1024, 65535)
|
||||
|
||||
|
||||
# Set up Video information so that the cms will send
|
||||
# requests to a mock Youtube server running locally
|
||||
if LETTUCE_SELENIUM_CLIENT == 'saucelabs':
|
||||
VIDEO_PORT = choice(PORTS)
|
||||
PORTS.remove(VIDEO_PORT)
|
||||
else:
|
||||
VIDEO_PORT = randint(1024, 65535)
|
||||
|
||||
# for testing Youtube
|
||||
YOUTUBE_API['url'] = "http://127.0.0.1:" + str(VIDEO_PORT) + '/test_transcripts_youtube/'
|
||||
|
||||
|
||||
@@ -423,3 +423,9 @@ TRACKING_BACKENDS = {
|
||||
TRACKING_IGNORE_URL_PATTERNS = [r'^/event', r'^/login', r'^/heartbeat']
|
||||
TRACKING_ENABLED = True
|
||||
|
||||
# Current youtube api for requesting transcripts.
|
||||
# for example: http://video.google.com/timedtext?lang=en&v=j_jEn79vS3g.
|
||||
YOUTUBE_API = {
|
||||
'url': "http://video.google.com/timedtext",
|
||||
'params': {'lang': 'en', 'v': 'set_youtube_id_of_11_symbols_here'}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ requirejs.config({
|
||||
"jquery.form": "xmodule_js/common_static/js/vendor/jquery.form",
|
||||
"jquery.markitup": "xmodule_js/common_static/js/vendor/markitup/jquery.markitup",
|
||||
"jquery.leanModal": "xmodule_js/common_static/js/vendor/jquery.leanModal.min",
|
||||
"jquery.ajaxQueue": "xmodule_js/common_static/js/vendor/jquery.ajaxQueue",
|
||||
"jquery.smoothScroll": "xmodule_js/common_static/js/vendor/jquery.smooth-scroll.min",
|
||||
"jquery.scrollTo": "xmodule_js/common_static/js/vendor/jquery.scrollTo-1.4.2-min",
|
||||
"jquery.timepicker": "xmodule_js/common_static/js/vendor/timepicker/jquery.timepicker",
|
||||
@@ -30,6 +31,7 @@ requirejs.config({
|
||||
"utility": "xmodule_js/common_static/js/src/utility",
|
||||
"sinon": "xmodule_js/common_static/js/vendor/sinon-1.7.1",
|
||||
"squire": "xmodule_js/common_static/js/vendor/Squire",
|
||||
"jasmine-jquery": "xmodule_js/common_static/js/vendor/jasmine-jquery",
|
||||
"jasmine-stealth": "xmodule_js/common_static/js/vendor/jasmine-stealth",
|
||||
"jasmine.async": "xmodule_js/common_static/js/vendor/jasmine.async",
|
||||
"draggabilly": "xmodule_js/common_static/js/vendor/draggabilly.pkgd",
|
||||
@@ -68,6 +70,10 @@ requirejs.config({
|
||||
deps: ["jquery"],
|
||||
exports: "jQuery.fn.smoothScroll"
|
||||
},
|
||||
"jquery.ajaxQueue": {
|
||||
deps: ["jquery"],
|
||||
exports: "jQuery.fn.ajaxQueue"
|
||||
},
|
||||
"jquery.scrollTo": {
|
||||
deps: ["jquery"],
|
||||
exports: "jQuery.fn.scrollTo"
|
||||
@@ -136,6 +142,9 @@ requirejs.config({
|
||||
"sinon": {
|
||||
exports: "sinon"
|
||||
},
|
||||
"jasmine-jquery": {
|
||||
deps: ["jasmine"]
|
||||
},
|
||||
"jasmine-stealth": {
|
||||
deps: ["jasmine"]
|
||||
},
|
||||
@@ -178,6 +187,10 @@ define([
|
||||
"coffee/spec/views/overview_spec",
|
||||
"coffee/spec/views/textbook_spec", "coffee/spec/views/upload_spec",
|
||||
|
||||
"js_spec/transcripts/utils_spec", "js_spec/transcripts/editor_spec",
|
||||
"js_spec/transcripts/videolist_spec", "js_spec/transcripts/message_manager_spec",
|
||||
"js_spec/transcripts/file_uploader_spec"
|
||||
|
||||
# these tests are run separate in the cms-squire suite, due to process
|
||||
# isolation issues with Squire.js
|
||||
# "coffee/spec/views/assets_spec"
|
||||
|
||||
@@ -37,6 +37,8 @@ define ["backbone", "jquery", "underscore", "gettext", "xblock/runtime.v1",
|
||||
collection: new MetadataCollection(models)
|
||||
})
|
||||
|
||||
@module.setMetadataEditor(@metadataEditor) if @module.setMetadataEditor
|
||||
|
||||
# Need to update set "active" class on data editor if there is one.
|
||||
# If we are only showing settings, hide the data editor controls and update settings accordingly.
|
||||
if @hasDataEditor()
|
||||
|
||||
@@ -107,6 +107,7 @@ define(["backbone"], function(Backbone) {
|
||||
Metadata.FLOAT_TYPE = "Float";
|
||||
Metadata.GENERIC_TYPE = "Generic";
|
||||
Metadata.LIST_TYPE = "List";
|
||||
Metadata.VIDEO_LIST_TYPE = "VideoList";
|
||||
|
||||
return Metadata;
|
||||
});
|
||||
|
||||
92
cms/static/js/views/abstract_editor.js
Normal file
92
cms/static/js/views/abstract_editor.js
Normal file
@@ -0,0 +1,92 @@
|
||||
|
||||
define(["backbone", "underscore"], function(Backbone, _) {
|
||||
var AbstractEditor = Backbone.View.extend({
|
||||
|
||||
// Model is MetadataModel
|
||||
initialize : function() {
|
||||
var self = this;
|
||||
var templateName = _.result(this, 'templateName');
|
||||
// Backbone model cid is only unique within the collection.
|
||||
this.uniqueId = _.uniqueId(templateName + "_");
|
||||
|
||||
var tpl = document.getElementById(templateName).text;
|
||||
if(!tpl) {
|
||||
console.error("Couldn't load template: " + templateName);
|
||||
}
|
||||
this.template = _.template(tpl);
|
||||
this.$el.html(this.template({model: this.model, uniqueId: this.uniqueId}));
|
||||
this.listenTo(this.model, 'change', this.render);
|
||||
this.render();
|
||||
},
|
||||
|
||||
/**
|
||||
* The ID/name of the template. Subclasses must override this.
|
||||
*/
|
||||
templateName: '',
|
||||
|
||||
/**
|
||||
* Returns the value currently displayed in the editor/view. Subclasses should implement this method.
|
||||
*/
|
||||
getValueFromEditor : function () {},
|
||||
|
||||
/**
|
||||
* Sets the value currently displayed in the editor/view. Subclasses should implement this method.
|
||||
*/
|
||||
setValueInEditor : function (value) {},
|
||||
|
||||
/**
|
||||
* Sets the value in the model, using the value currently displayed in the view.
|
||||
*/
|
||||
updateModel: function () {
|
||||
this.model.setValue(this.getValueFromEditor());
|
||||
},
|
||||
|
||||
/**
|
||||
* Clears the value currently set in the model (reverting to the default).
|
||||
*/
|
||||
clear: function () {
|
||||
this.model.clear();
|
||||
},
|
||||
|
||||
/**
|
||||
* Shows the clear button, if it is not already showing.
|
||||
*/
|
||||
showClearButton: function() {
|
||||
if (!this.$el.hasClass('is-set')) {
|
||||
this.$el.addClass('is-set');
|
||||
this.getClearButton().removeClass('inactive');
|
||||
this.getClearButton().addClass('active');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the clear button.
|
||||
*/
|
||||
getClearButton: function () {
|
||||
return this.$el.find('.setting-clear');
|
||||
},
|
||||
|
||||
/**
|
||||
* Renders the editor, updating the value displayed in the view, as well as the state of
|
||||
* the clear button.
|
||||
*/
|
||||
render: function () {
|
||||
if (!this.template) return;
|
||||
|
||||
this.setValueInEditor(this.model.getDisplayValue());
|
||||
|
||||
if (this.model.isExplicitlySet()) {
|
||||
this.showClearButton();
|
||||
}
|
||||
else {
|
||||
this.$el.removeClass('is-set');
|
||||
this.getClearButton().addClass('inactive');
|
||||
this.getClearButton().removeClass('active');
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
});
|
||||
|
||||
return AbstractEditor;
|
||||
});
|
||||
@@ -1,5 +1,10 @@
|
||||
|
||||
define(["backbone", "underscore", "js/models/metadata"], function(Backbone, _, MetadataModel) {
|
||||
define(
|
||||
[
|
||||
"backbone", "underscore", "js/models/metadata", "js/views/abstract_editor",
|
||||
"js/views/transcripts/metadata_videolist"
|
||||
],
|
||||
function(Backbone, _, MetadataModel, AbstractEditor, VideoList) {
|
||||
var Metadata = {};
|
||||
|
||||
Metadata.Editor = Backbone.View.extend({
|
||||
@@ -32,6 +37,9 @@ define(["backbone", "underscore", "js/models/metadata"], function(Backbone, _, M
|
||||
else if(model.getType() === MetadataModel.LIST_TYPE) {
|
||||
new Metadata.List(data);
|
||||
}
|
||||
else if(model.getType() === MetadataModel.VIDEO_LIST_TYPE) {
|
||||
new VideoList(data);
|
||||
}
|
||||
else {
|
||||
// Everything else is treated as GENERIC_TYPE, which uses String editor.
|
||||
new Metadata.String(data);
|
||||
@@ -74,95 +82,7 @@ define(["backbone", "underscore", "js/models/metadata"], function(Backbone, _, M
|
||||
}
|
||||
});
|
||||
|
||||
Metadata.AbstractEditor = Backbone.View.extend({
|
||||
|
||||
// Model is MetadataModel
|
||||
initialize : function() {
|
||||
var self = this;
|
||||
var templateName = _.result(this, 'templateName');
|
||||
// Backbone model cid is only unique within the collection.
|
||||
this.uniqueId = _.uniqueId(templateName + "_");
|
||||
|
||||
var tpl = document.getElementById(templateName).text;
|
||||
if(!tpl) {
|
||||
console.error("Couldn't load template: " + templateName);
|
||||
}
|
||||
this.template = _.template(tpl);
|
||||
this.$el.html(this.template({model: this.model, uniqueId: this.uniqueId}));
|
||||
this.listenTo(this.model, 'change', this.render);
|
||||
this.render();
|
||||
},
|
||||
|
||||
/**
|
||||
* The ID/name of the template. Subclasses must override this.
|
||||
*/
|
||||
templateName: '',
|
||||
|
||||
/**
|
||||
* Returns the value currently displayed in the editor/view. Subclasses should implement this method.
|
||||
*/
|
||||
getValueFromEditor : function () {},
|
||||
|
||||
/**
|
||||
* Sets the value currently displayed in the editor/view. Subclasses should implement this method.
|
||||
*/
|
||||
setValueInEditor : function (value) {},
|
||||
|
||||
/**
|
||||
* Sets the value in the model, using the value currently displayed in the view.
|
||||
*/
|
||||
updateModel: function () {
|
||||
this.model.setValue(this.getValueFromEditor());
|
||||
},
|
||||
|
||||
/**
|
||||
* Clears the value currently set in the model (reverting to the default).
|
||||
*/
|
||||
clear: function () {
|
||||
this.model.clear();
|
||||
},
|
||||
|
||||
/**
|
||||
* Shows the clear button, if it is not already showing.
|
||||
*/
|
||||
showClearButton: function() {
|
||||
if (!this.$el.hasClass('is-set')) {
|
||||
this.$el.addClass('is-set');
|
||||
this.getClearButton().removeClass('inactive');
|
||||
this.getClearButton().addClass('active');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the clear button.
|
||||
*/
|
||||
getClearButton: function () {
|
||||
return this.$el.find('.setting-clear');
|
||||
},
|
||||
|
||||
/**
|
||||
* Renders the editor, updating the value displayed in the view, as well as the state of
|
||||
* the clear button.
|
||||
*/
|
||||
render: function () {
|
||||
if (!this.template) return;
|
||||
|
||||
this.setValueInEditor(this.model.getDisplayValue());
|
||||
|
||||
if (this.model.isExplicitlySet()) {
|
||||
this.showClearButton();
|
||||
}
|
||||
else {
|
||||
this.$el.removeClass('is-set');
|
||||
this.getClearButton().addClass('inactive');
|
||||
this.getClearButton().removeClass('active');
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
});
|
||||
|
||||
Metadata.String = Metadata.AbstractEditor.extend({
|
||||
Metadata.String = AbstractEditor.extend({
|
||||
|
||||
events : {
|
||||
"change input" : "updateModel",
|
||||
@@ -181,7 +101,7 @@ define(["backbone", "underscore", "js/models/metadata"], function(Backbone, _, M
|
||||
}
|
||||
});
|
||||
|
||||
Metadata.Number = Metadata.AbstractEditor.extend({
|
||||
Metadata.Number = AbstractEditor.extend({
|
||||
|
||||
events : {
|
||||
"change input" : "updateModel",
|
||||
@@ -191,7 +111,7 @@ define(["backbone", "underscore", "js/models/metadata"], function(Backbone, _, M
|
||||
},
|
||||
|
||||
render: function () {
|
||||
Metadata.AbstractEditor.prototype.render.apply(this);
|
||||
AbstractEditor.prototype.render.apply(this);
|
||||
if (!this.initialized) {
|
||||
var numToString = function (val) {
|
||||
return val.toFixed(4);
|
||||
@@ -279,7 +199,7 @@ define(["backbone", "underscore", "js/models/metadata"], function(Backbone, _, M
|
||||
|
||||
});
|
||||
|
||||
Metadata.Option = Metadata.AbstractEditor.extend({
|
||||
Metadata.Option = AbstractEditor.extend({
|
||||
|
||||
events : {
|
||||
"change select" : "updateModel",
|
||||
@@ -316,7 +236,7 @@ define(["backbone", "underscore", "js/models/metadata"], function(Backbone, _, M
|
||||
}
|
||||
});
|
||||
|
||||
Metadata.List = Metadata.AbstractEditor.extend({
|
||||
Metadata.List = AbstractEditor.extend({
|
||||
|
||||
events : {
|
||||
"click .setting-clear" : "clear",
|
||||
@@ -355,7 +275,7 @@ define(["backbone", "underscore", "js/models/metadata"], function(Backbone, _, M
|
||||
// We don't call updateModel here since it's bound to the
|
||||
// change event
|
||||
var list = this.model.get('value') || [];
|
||||
this.setValueInEditor(list.concat(['']))
|
||||
this.setValueInEditor(list.concat(['']));
|
||||
this.$el.find('.create-setting').addClass('is-disabled');
|
||||
},
|
||||
|
||||
|
||||
234
cms/static/js/views/transcripts/editor.js
Normal file
234
cms/static/js/views/transcripts/editor.js
Normal file
@@ -0,0 +1,234 @@
|
||||
define(
|
||||
[
|
||||
"jquery", "backbone", "underscore",
|
||||
"js/views/transcripts/utils",
|
||||
"js/views/metadata", "js/collections/metadata",
|
||||
"js/views/transcripts/metadata_videolist"
|
||||
],
|
||||
function($, Backbone, _, Utils, MetadataView, MetadataCollection) {
|
||||
|
||||
var Editor = Backbone.View.extend({
|
||||
|
||||
tagName: 'div',
|
||||
|
||||
initialize: function () {
|
||||
// prepare data for MetadataView.Editor
|
||||
|
||||
var metadata = this.$el.data('metadata'),
|
||||
models = this.toModels(metadata);
|
||||
|
||||
this.collection = new MetadataCollection(models);
|
||||
|
||||
// initialize MetadataView.Editor
|
||||
this.metadataEditor = new MetadataView.Editor({
|
||||
el: this.$el,
|
||||
collection: this.collection
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* @function
|
||||
*
|
||||
* Convert JSON metadata to List of models
|
||||
*
|
||||
* @param {object|string} data Data containing information about metadata
|
||||
* setting editors.
|
||||
*
|
||||
* @returns {array} Processed objects list.
|
||||
*
|
||||
* @example:
|
||||
* var metadata = {
|
||||
* field_1: {.1.},
|
||||
* field_2: {.2.}
|
||||
* };
|
||||
*
|
||||
* toModels(metadata) // => [{.1.}, {.2.}]
|
||||
*
|
||||
*/
|
||||
toModels: function (data) {
|
||||
var metadata = (_.isString(data)) ? JSON.parse(data) : data,
|
||||
models = [];
|
||||
|
||||
for (var model in metadata) {
|
||||
if (metadata.hasOwnProperty(model)) {
|
||||
models.push(metadata[model]);
|
||||
}
|
||||
}
|
||||
|
||||
return models;
|
||||
},
|
||||
|
||||
/**
|
||||
* @function
|
||||
*
|
||||
* Synchronize data from `Advanced` tab of Video player with data in
|
||||
* `Basic` tab. It is called when we go from `Advanced` to `Basic` tab.
|
||||
*
|
||||
* @param {object} metadataCollection Collection containing all models
|
||||
* with information about metadata
|
||||
* setting editors in `Advanced` tab.
|
||||
*
|
||||
*/
|
||||
syncBasicTab: function (metadataCollection, metadataView) {
|
||||
var result = [],
|
||||
getField = Utils.getField,
|
||||
component_id = this.$el.closest('.component').data('id'),
|
||||
subs = getField(metadataCollection, 'sub'),
|
||||
values = {},
|
||||
videoUrl, metadata, modifiedValues;
|
||||
|
||||
// If metadataCollection is not passed, just exit.
|
||||
if (!metadataCollection || !metadataView) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get field that should be synchronized with `Advanced` tab fields.
|
||||
videoUrl = getField(this.collection, 'video_url');
|
||||
|
||||
modifiedValues = metadataView.getModifiedMetadataValues();
|
||||
|
||||
var isSubsModified = (function (values) {
|
||||
var isSubsChanged = subs.hasChanged("value");
|
||||
|
||||
return Boolean(isSubsChanged && _.isString(values.sub));
|
||||
}(modifiedValues));
|
||||
|
||||
// When we change value of `sub` field in the `Advanced`,
|
||||
// we update data on backend. That provides possibility to remove
|
||||
// transcripts.
|
||||
if (isSubsModified) {
|
||||
metadata = $.extend(true, {}, modifiedValues);
|
||||
// Save module state
|
||||
Utils.command('save', component_id, null, {
|
||||
metadata: metadata,
|
||||
current_subs: _.pluck(
|
||||
Utils.getVideoList(videoUrl.getDisplayValue()),
|
||||
'video'
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
// Get values from `Advanced` tab fields (`html5_sources`,
|
||||
// `youtube_id_1_0`) that should be synchronized.
|
||||
values.html5Sources = getField(metadataCollection, 'html5_sources')
|
||||
.getDisplayValue();
|
||||
|
||||
values.youtube = getField(metadataCollection, 'youtube_id_1_0')
|
||||
.getDisplayValue();
|
||||
|
||||
// The length of youtube video_id should be 11 characters.
|
||||
if (values.youtube.length === 11) {
|
||||
// Just video id is retrieved from `Advanced` tab field and
|
||||
// it should be transformed to appropriate format.
|
||||
// OEoXaMPEzfM => http://youtu.be/OEoXaMPEzfM
|
||||
values.youtube = Utils.getYoutubeLink(values.youtube);
|
||||
} else {
|
||||
values.youtube = '';
|
||||
}
|
||||
|
||||
result.push(values.youtube);
|
||||
result = result.concat(values.html5Sources);
|
||||
|
||||
videoUrl.setValue(result);
|
||||
|
||||
// Synchronize other fields that has the same `field_name` property.
|
||||
Utils.syncCollections(metadataCollection, this.collection);
|
||||
|
||||
if (isSubsModified){
|
||||
// When `sub` field is changed, clean Storage to avoid overwriting.
|
||||
Utils.Storage.remove('sub');
|
||||
|
||||
// Trigger `change` event manually if `video_url` model
|
||||
// isn't changed.
|
||||
if (!videoUrl.hasChanged()) {
|
||||
videoUrl.trigger('change');
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @function
|
||||
*
|
||||
* Synchronize data from `Basic` tab of Video player with data in
|
||||
* `Advanced` tab. It is called when we go from `Basic` to `Advanced` tab.
|
||||
*
|
||||
* @param {object} metadataCollection Collection containing all models
|
||||
* with information about metadata
|
||||
* setting editors in `Advanced` tab.
|
||||
*
|
||||
*/
|
||||
syncAdvancedTab: function (metadataCollection, metadataView) {
|
||||
var getField = Utils.getField,
|
||||
subsValue = Utils.Storage.get('sub'),
|
||||
subs = getField(metadataCollection, 'sub'),
|
||||
html5Sources, youtube, videoUrlValue, result;
|
||||
|
||||
// if metadataCollection is not passed, just exit.
|
||||
if (!metadataCollection) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get fields from `Advenced` tab (`html5_sources`, `youtube_id_1_0`)
|
||||
// that should be synchronized.
|
||||
html5Sources = getField(metadataCollection, 'html5_sources');
|
||||
|
||||
youtube = getField(metadataCollection, 'youtube_id_1_0');
|
||||
|
||||
// Get value from `Basic` tab `VideoUrl` field that should be
|
||||
// synchronized.
|
||||
videoUrlValue = getField(this.collection, 'video_url')
|
||||
.getDisplayValue();
|
||||
|
||||
// Change list representation format to more convenient and group
|
||||
// them by mode (`youtube`, `html5`).
|
||||
// Before:
|
||||
// [
|
||||
// 'http://youtu.be/OEoXaMPEzfM',
|
||||
// 'video_name.mp4',
|
||||
// 'video_name.webm'
|
||||
// ]
|
||||
// After:
|
||||
// {
|
||||
// youtube: [{mode: `youtube`, type: `youtube`, ...}],
|
||||
// html5: [
|
||||
// {mode: `html5`, type: `mp4`, ...},
|
||||
// {mode: `html5`, type: `webm`, ...}
|
||||
// ]
|
||||
// }
|
||||
result = _.groupBy(
|
||||
videoUrlValue,
|
||||
function (value) {
|
||||
return Utils.parseLink(value).mode;
|
||||
}
|
||||
);
|
||||
|
||||
if (html5Sources) {
|
||||
html5Sources.setValue(result.html5 || []);
|
||||
}
|
||||
|
||||
if (youtube) {
|
||||
if (result.youtube) {
|
||||
result = Utils.parseLink(result.youtube[0]).video;
|
||||
} else {
|
||||
result = '';
|
||||
}
|
||||
|
||||
youtube.setValue(result);
|
||||
}
|
||||
|
||||
// If Utils.Storage contain some subtitles, update them.
|
||||
if (_.isString(subsValue)) {
|
||||
subs.setValue(subsValue);
|
||||
// After updating should be removed, because it might overwrite
|
||||
// subtitles added by user manually.
|
||||
Utils.Storage.remove('sub');
|
||||
}
|
||||
|
||||
// Synchronize other fields that has the same `field_name` property.
|
||||
Utils.syncCollections(this.collection, metadataCollection);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
return Editor;
|
||||
});
|
||||
201
cms/static/js/views/transcripts/file_uploader.js
Normal file
201
cms/static/js/views/transcripts/file_uploader.js
Normal file
@@ -0,0 +1,201 @@
|
||||
define(
|
||||
[
|
||||
"jquery", "backbone", "underscore",
|
||||
"js/views/transcripts/utils"
|
||||
],
|
||||
function($, Backbone, _, Utils) {
|
||||
var FileUploader = Backbone.View.extend({
|
||||
invisibleClass: 'is-invisible',
|
||||
|
||||
// Pre-defined list of supported file formats.
|
||||
validFileExtensions: ['srt'],
|
||||
|
||||
events: {
|
||||
'change .file-input': 'changeHandler',
|
||||
'click .setting-upload': 'clickHandler'
|
||||
},
|
||||
|
||||
uploadTpl: '#file-upload',
|
||||
|
||||
initialize: function () {
|
||||
_.bindAll(this);
|
||||
|
||||
this.file = false;
|
||||
this.render();
|
||||
},
|
||||
|
||||
render: function () {
|
||||
var tpl = $(this.uploadTpl).text(),
|
||||
tplContainer = this.$el.find('.transcripts-file-uploader'),
|
||||
videoList = this.options.videoListObject.getVideoObjectsList();
|
||||
|
||||
if (tplContainer.length) {
|
||||
if (!tpl) {
|
||||
console.error('Couldn\'t load Transcripts File Upload template');
|
||||
|
||||
return;
|
||||
}
|
||||
this.template = _.template(tpl);
|
||||
|
||||
tplContainer.html(this.template({
|
||||
ext: this.validFileExtensions,
|
||||
component_id: this.options.component_id,
|
||||
video_list: videoList
|
||||
}));
|
||||
|
||||
this.$form = this.$el.find('.file-chooser');
|
||||
this.$input = this.$form.find('.file-input');
|
||||
this.$progress = this.$el.find('.progress-fill');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @function
|
||||
*
|
||||
* Uploads file to the server. Get file from the `file` property.
|
||||
*
|
||||
*/
|
||||
upload: function () {
|
||||
if (!this.file) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.$form.ajaxSubmit({
|
||||
beforeSend: this.xhrResetProgressBar,
|
||||
uploadProgress: this.xhrProgressHandler,
|
||||
complete: this.xhrCompleteHandler
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* @function
|
||||
*
|
||||
* Handle click event on `upload` button.
|
||||
*
|
||||
* @param {object} event Event object.
|
||||
*
|
||||
*/
|
||||
clickHandler: function (event) {
|
||||
event.preventDefault();
|
||||
|
||||
this.$input
|
||||
.val(null)
|
||||
// Show system upload window
|
||||
.trigger('click');
|
||||
},
|
||||
|
||||
/**
|
||||
* @function
|
||||
*
|
||||
* Handle change event.
|
||||
*
|
||||
* @param {object} event Event object.
|
||||
*
|
||||
*/
|
||||
changeHandler: function (event) {
|
||||
event.preventDefault();
|
||||
|
||||
this.options.messenger.hideError();
|
||||
this.file = this.$input.get(0).files[0];
|
||||
|
||||
// if file has valid file extension, than upload file.
|
||||
// Otherwise, show error message.
|
||||
if (this.checkExtValidity(this.file)) {
|
||||
this.upload();
|
||||
} else {
|
||||
this.options.messenger
|
||||
.showError('Please select a file in .srt format.');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @function
|
||||
*
|
||||
* Checks that file has supported extension.
|
||||
*
|
||||
* @param {object} file Object with information about file.
|
||||
*
|
||||
* @returns {boolean} Indicate that file has supported or unsupported
|
||||
* extension.
|
||||
*
|
||||
*/
|
||||
checkExtValidity: function (file) {
|
||||
if (!file.name) {
|
||||
return void(0);
|
||||
}
|
||||
|
||||
var fileExtension = file.name
|
||||
.split('.')
|
||||
.pop()
|
||||
.toLowerCase();
|
||||
|
||||
if ($.inArray(fileExtension, this.validFileExtensions) !== -1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
|
||||
/**
|
||||
* @function
|
||||
*
|
||||
* Resets progress bar.
|
||||
*
|
||||
*/
|
||||
xhrResetProgressBar: function () {
|
||||
var percentVal = '0%';
|
||||
|
||||
this.$progress
|
||||
.width(percentVal)
|
||||
.html(percentVal)
|
||||
.removeClass(this.invisibleClass);
|
||||
},
|
||||
|
||||
/**
|
||||
* @function
|
||||
*
|
||||
* Callback function to be invoked with upload progress information
|
||||
* (if supported by the browser).
|
||||
*
|
||||
* @param {object} event Event object.
|
||||
*
|
||||
* @param {integer} position Amount of transmitted bytes.
|
||||
* *
|
||||
* @param {integer} total Total size of file.
|
||||
* *
|
||||
* @param {integer} percentComplete Object with information about file.
|
||||
*
|
||||
*/
|
||||
xhrProgressHandler: function (event, position, total, percentComplete) {
|
||||
var percentVal = percentComplete + '%';
|
||||
|
||||
this.$progress
|
||||
.width(percentVal)
|
||||
.html(percentVal);
|
||||
},
|
||||
|
||||
/**
|
||||
* @function
|
||||
*
|
||||
* Handle complete uploading.
|
||||
*
|
||||
*/
|
||||
xhrCompleteHandler: function (xhr) {
|
||||
var resp = JSON.parse(xhr.responseText),
|
||||
err = resp.status || 'Error: Uploading failed.',
|
||||
sub = resp.subs;
|
||||
|
||||
this.$progress
|
||||
.addClass(this.invisibleClass);
|
||||
|
||||
if (xhr.status === 200) {
|
||||
this.options.messenger.render('uploaded', resp);
|
||||
Utils.Storage.set('sub', sub);
|
||||
} else {
|
||||
this.options.messenger.showError(err);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return FileUploader;
|
||||
});
|
||||
233
cms/static/js/views/transcripts/message_manager.js
Normal file
233
cms/static/js/views/transcripts/message_manager.js
Normal file
@@ -0,0 +1,233 @@
|
||||
define(
|
||||
[
|
||||
"jquery", "backbone", "underscore",
|
||||
"js/views/transcripts/utils", "js/views/transcripts/file_uploader",
|
||||
"gettext"
|
||||
],
|
||||
function($, Backbone, _, Utils, FileUploader, gettext) {
|
||||
var MessageManager = Backbone.View.extend({
|
||||
tagName: 'div',
|
||||
elClass: '.wrapper-transcripts-message',
|
||||
invisibleClass: 'is-invisible',
|
||||
|
||||
events: {
|
||||
'click .setting-import': 'importHandler',
|
||||
'click .setting-replace': 'replaceHandler',
|
||||
'click .setting-choose': 'chooseHandler',
|
||||
'click .setting-use-existing': 'useExistingHandler'
|
||||
},
|
||||
|
||||
// Pre-defined dict with anchors to status templates.
|
||||
templates: {
|
||||
not_found: '#transcripts-not-found',
|
||||
found: '#transcripts-found',
|
||||
import: '#transcripts-import',
|
||||
replace: '#transcripts-replace',
|
||||
uploaded: '#transcripts-uploaded',
|
||||
use_existing: '#transcripts-use-existing',
|
||||
choose: '#transcripts-choose'
|
||||
},
|
||||
|
||||
initialize: function () {
|
||||
_.bindAll(this);
|
||||
|
||||
this.component_id = this.$el.closest('.component').data('id');
|
||||
|
||||
this.fileUploader = new FileUploader({
|
||||
el: this.$el,
|
||||
messenger: this,
|
||||
component_id: this.component_id,
|
||||
videoListObject: this.options.parent
|
||||
});
|
||||
},
|
||||
|
||||
render: function (template_id, params) {
|
||||
var tplHtml = $(this.templates[template_id]).text(),
|
||||
videoList = this.options.parent.getVideoObjectsList(),
|
||||
// Change list representation format to more convenient and group
|
||||
// them by video property.
|
||||
// Before:
|
||||
// [
|
||||
// {mode: `html5`, type: `mp4`, video: `video_name_1`},
|
||||
// {mode: `html5`, type: `webm`, video: `video_name_2`}
|
||||
// ]
|
||||
// After:
|
||||
// {
|
||||
// `video_name_1`: [{mode: `html5`, type: `webm`, ...}],
|
||||
// `video_name_2`: [{mode: `html5`, type: `mp4`, ...}]
|
||||
// }
|
||||
groupedList = _.groupBy(
|
||||
videoList,
|
||||
function (value) {
|
||||
return value.video;
|
||||
}
|
||||
),
|
||||
html5List = (params) ? params.html5_local : [],
|
||||
template;
|
||||
|
||||
if (!tplHtml) {
|
||||
console.error('Couldn\'t load Transcripts status template');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
template = _.template(tplHtml);
|
||||
|
||||
this.$el.find('.transcripts-status')
|
||||
.removeClass('is-invisible')
|
||||
.find(this.elClass).html(template({
|
||||
component_id: encodeURIComponent(this.component_id),
|
||||
html5_list: html5List,
|
||||
grouped_list: groupedList,
|
||||
subs_id: (params) ? params.subs: ''
|
||||
}));
|
||||
|
||||
this.fileUploader.render();
|
||||
|
||||
return this;
|
||||
},
|
||||
|
||||
/**
|
||||
* @function
|
||||
*
|
||||
* Shows error message.
|
||||
*
|
||||
* @param {string} err Error message that will be shown
|
||||
*
|
||||
* @param {boolean} hideButtons Hide buttons
|
||||
*
|
||||
*/
|
||||
showError: function (err, hideButtons) {
|
||||
var $error = this.$el.find('.transcripts-error-message');
|
||||
|
||||
if (err) {
|
||||
// Hide any other error messages.
|
||||
this.hideError();
|
||||
|
||||
$error
|
||||
.html(gettext(err))
|
||||
.removeClass(this.invisibleClass);
|
||||
|
||||
if (hideButtons) {
|
||||
this.$el.find('.wrapper-transcripts-buttons')
|
||||
.addClass(this.invisibleClass);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @function
|
||||
*
|
||||
* Hides error message.
|
||||
*
|
||||
*/
|
||||
hideError: function () {
|
||||
this.$el.find('.transcripts-error-message')
|
||||
.addClass(this.invisibleClass);
|
||||
|
||||
this.$el.find('.wrapper-transcripts-buttons')
|
||||
.removeClass(this.invisibleClass);
|
||||
},
|
||||
|
||||
/**
|
||||
* @function
|
||||
*
|
||||
* Handle import button.
|
||||
*
|
||||
* @params {object} event Event object.
|
||||
*
|
||||
*/
|
||||
importHandler: function (event) {
|
||||
event.preventDefault();
|
||||
|
||||
this.processCommand('replace', 'Error: Import failed.');
|
||||
},
|
||||
|
||||
/**
|
||||
* @function
|
||||
*
|
||||
* Handle replace button.
|
||||
*
|
||||
* @params {object} event Event object.
|
||||
*
|
||||
*/
|
||||
replaceHandler: function (event) {
|
||||
event.preventDefault();
|
||||
|
||||
this.processCommand('replace', 'Error: Replacing failed.');
|
||||
},
|
||||
|
||||
/**
|
||||
* @function
|
||||
*
|
||||
* Handle choose buttons.
|
||||
*
|
||||
* @params {object} event Event object.
|
||||
*
|
||||
*/
|
||||
chooseHandler: function (event) {
|
||||
event.preventDefault();
|
||||
|
||||
var videoId = $(event.currentTarget).data('video-id');
|
||||
|
||||
this.processCommand('choose', 'Error: Choosing failed.', videoId);
|
||||
},
|
||||
|
||||
/**
|
||||
* @function
|
||||
*
|
||||
* Handle `use existing` button.
|
||||
*
|
||||
* @params {object} event Event object.
|
||||
*
|
||||
*/
|
||||
useExistingHandler: function (event) {
|
||||
event.preventDefault();
|
||||
|
||||
this.processCommand('rename', 'Error: Choosing failed.');
|
||||
},
|
||||
|
||||
/**
|
||||
* @function
|
||||
*
|
||||
* Decorator for `command` function in the Utils.
|
||||
*
|
||||
* @params {string} action Action that will be invoked on server. Is a part
|
||||
* of url.
|
||||
*
|
||||
* @params {string} errorMessage Error massage that will be shown if any
|
||||
* connection error occurs
|
||||
*
|
||||
* @params {string} videoId Extra parameter that sometimes should be sent
|
||||
* to the server
|
||||
*
|
||||
*/
|
||||
processCommand: function (action, errorMessage, videoId) {
|
||||
var self = this,
|
||||
component_id = this.component_id,
|
||||
videoList = this.options.parent.getVideoObjectsList(),
|
||||
extraParam, xhr;
|
||||
|
||||
if (videoId) {
|
||||
extraParam = { html5_id: videoId };
|
||||
}
|
||||
|
||||
xhr = Utils.command(action, component_id, videoList, extraParam)
|
||||
.done(function (resp) {
|
||||
var sub = resp.subs;
|
||||
|
||||
self.render('found', resp);
|
||||
Utils.Storage.set('sub', sub);
|
||||
})
|
||||
.fail(function (resp) {
|
||||
var message = resp.status || errorMessage;
|
||||
self.showError(message);
|
||||
});
|
||||
|
||||
return xhr;
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
return MessageManager;
|
||||
});
|
||||
410
cms/static/js/views/transcripts/metadata_videolist.js
Normal file
410
cms/static/js/views/transcripts/metadata_videolist.js
Normal file
@@ -0,0 +1,410 @@
|
||||
define(
|
||||
[
|
||||
"jquery", "backbone", "underscore", "js/views/abstract_editor",
|
||||
"js/views/transcripts/utils", "js/views/transcripts/message_manager",
|
||||
"js/views/metadata"
|
||||
],
|
||||
function($, Backbone, _, AbstractEditor, Utils, MessageManager, MetadataView) {
|
||||
VideoList = AbstractEditor.extend({
|
||||
// Time that we wait since the last time user typed.
|
||||
inputDelay: 300,
|
||||
|
||||
events : {
|
||||
'click .setting-clear' : 'clear',
|
||||
'keypress .setting-input' : 'showClearButton',
|
||||
'click .collapse-setting' : 'toggleExtraVideosBar'
|
||||
},
|
||||
|
||||
templateName: 'metadata-videolist-entry',
|
||||
|
||||
// Pre-defined dict of placeholders: "videoType - placeholder" pairs.
|
||||
placeholders: {
|
||||
'webm': '.webm',
|
||||
'mp4': 'http://somesite.com/video.mp4',
|
||||
'youtube': 'http://youtube.com/'
|
||||
},
|
||||
|
||||
initialize: function () {
|
||||
// Initialize MessageManager that is responsible for
|
||||
// status messages and errors.
|
||||
|
||||
this.messenger = new MessageManager({
|
||||
el: this.$el,
|
||||
parent: this
|
||||
});
|
||||
|
||||
// Call it after MessageManager. This is because
|
||||
// MessageManager is used in `render` method that
|
||||
// is called in `AbstractEditor.prototype.initialize`.
|
||||
AbstractEditor.prototype.initialize
|
||||
.apply(this, arguments);
|
||||
|
||||
this.$el.on(
|
||||
'input', 'input',
|
||||
_.debounce(_.bind(this.inputHandler, this), this.inputDelay)
|
||||
);
|
||||
|
||||
this.component_id = this.$el.closest('.component').data('id');
|
||||
},
|
||||
|
||||
render: function () {
|
||||
// Call inherited `render` method.
|
||||
AbstractEditor.prototype.render
|
||||
.apply(this, arguments);
|
||||
|
||||
var self = this,
|
||||
component_id = this.$el.closest('.component').data('id'),
|
||||
videoList = this.getVideoObjectsList(),
|
||||
|
||||
showServerError = function (response) {
|
||||
var errorMessage = response.status || 'Error: Connection with server failed.';
|
||||
self.messenger
|
||||
.render('not_found')
|
||||
.showError(
|
||||
errorMessage,
|
||||
true // hide buttons
|
||||
);
|
||||
};
|
||||
|
||||
this.$extraVideosBar = this.$el.find('.videolist-extra-videos');
|
||||
|
||||
if (videoList.length === 0) {
|
||||
this.messenger
|
||||
.render('not_found')
|
||||
.showError(
|
||||
'No sources',
|
||||
true // hide buttons
|
||||
);
|
||||
|
||||
return void(0);
|
||||
}
|
||||
|
||||
// Check current state of Timed Transcripts.
|
||||
Utils.command('check', component_id, videoList)
|
||||
.done(function (resp) {
|
||||
var params = resp,
|
||||
len = videoList.length,
|
||||
mode = (len === 1) ? videoList[0].mode : false;
|
||||
|
||||
// If there are more than 1 video or just html5 source is
|
||||
// passed, video sources box should expand
|
||||
if (len > 1 || mode === 'html5') {
|
||||
self.openExtraVideosBar();
|
||||
} else {
|
||||
self.closeExtraVideosBar();
|
||||
}
|
||||
|
||||
self.messenger.render(resp.command, params);
|
||||
self.checkIsUniqVideoTypes();
|
||||
// Synchronize transcripts field in the `Advanced` tab.
|
||||
Utils.Storage.set('sub', resp.subs);
|
||||
})
|
||||
.fail(showServerError);
|
||||
},
|
||||
|
||||
/**
|
||||
* @function
|
||||
*
|
||||
* Returns the values currently displayed in the editor/view.
|
||||
*
|
||||
* @returns {array} List of non-empty values.
|
||||
*
|
||||
*/
|
||||
getValueFromEditor: function () {
|
||||
return _.map(
|
||||
this.$el.find('.input'),
|
||||
function (ele) {
|
||||
return ele.value.trim();
|
||||
}
|
||||
).filter(_.identity);
|
||||
},
|
||||
|
||||
/**
|
||||
* @function
|
||||
*
|
||||
* Returns list of objects with information about the values currently
|
||||
* displayed in the editor/view.
|
||||
*
|
||||
* @returns {array} List of objects.
|
||||
*
|
||||
* @examples
|
||||
* this.getValueFromEditor(); // =>
|
||||
* [
|
||||
* 'http://youtu.be/OEoXaMPEzfM',
|
||||
* 'video_name.mp4',
|
||||
* 'video_name.webm'
|
||||
* ]
|
||||
*
|
||||
* this.getVideoObjectsList(); // =>
|
||||
* [
|
||||
* {mode: `youtube`, type: `youtube`, ...},
|
||||
* {mode: `html5`, type: `mp4`, ...},
|
||||
* {mode: `html5`, type: `webm`, ...}
|
||||
* ]
|
||||
*
|
||||
*/
|
||||
getVideoObjectsList: function () {
|
||||
var links = this.getValueFromEditor();
|
||||
|
||||
return Utils.getVideoList(links);
|
||||
},
|
||||
|
||||
/**
|
||||
* @function
|
||||
*
|
||||
* Sets the values currently displayed in the editor/view.
|
||||
*
|
||||
* @params {array} value List of values.
|
||||
*
|
||||
*/
|
||||
setValueInEditor: function (value) {
|
||||
var parseLink = Utils.parseLink,
|
||||
list = this.$el.find('.input'),
|
||||
val = value.filter(_.identity),
|
||||
placeholders = this.getPlaceholders(val);
|
||||
|
||||
list.each(function (index) {
|
||||
$(this)
|
||||
.val(val[index] || null)
|
||||
.attr('placeholder', placeholders[index]);
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
/**
|
||||
* @function
|
||||
*
|
||||
* Returns the placeholders for the values currently displayed in the
|
||||
* editor/view.
|
||||
*
|
||||
* @returns {array} List of placeholders.
|
||||
*
|
||||
*/
|
||||
getPlaceholders: function (value) {
|
||||
var parseLink = Utils.parseLink,
|
||||
placeholders = _.clone(this.placeholders);
|
||||
|
||||
// Returned list should have the same size as a count of editors/views.
|
||||
return _.map(
|
||||
this.$el.find('.input'),
|
||||
function (element, index) {
|
||||
var linkInfo = parseLink(value[index]),
|
||||
type = (linkInfo) ? linkInfo.type : null,
|
||||
label;
|
||||
|
||||
// If placeholder for current video type exist, retrieve it
|
||||
// and remove from cloned list.
|
||||
// Otherwise, we use the remaining placeholders.
|
||||
if (placeholders[type]) {
|
||||
label = placeholders[type];
|
||||
delete placeholders[type];
|
||||
} else {
|
||||
if ( !($.isArray(placeholders)) ) {
|
||||
placeholders = _.values(placeholders);
|
||||
}
|
||||
|
||||
label = placeholders.pop();
|
||||
}
|
||||
|
||||
return label;
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* @function
|
||||
*
|
||||
* Opens video sources box.
|
||||
*
|
||||
* @params {object} event Event object.
|
||||
*
|
||||
*/
|
||||
openExtraVideosBar: function (event) {
|
||||
if (event && event.preventDefault) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
this.$extraVideosBar.addClass('is-visible');
|
||||
},
|
||||
|
||||
/**
|
||||
* @function
|
||||
*
|
||||
* Closes video sources box.
|
||||
*
|
||||
* @params {object} event Event object.
|
||||
*
|
||||
*/
|
||||
closeExtraVideosBar: function (event) {
|
||||
if (event && event.preventDefault) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
this.$extraVideosBar.removeClass('is-visible');
|
||||
},
|
||||
|
||||
/**
|
||||
* @function
|
||||
*
|
||||
* Toggles video sources box.
|
||||
*
|
||||
* @params {object} event Event object.
|
||||
*
|
||||
*/
|
||||
toggleExtraVideosBar: function (event) {
|
||||
if (event && event.preventDefault) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
if (this.$extraVideosBar.hasClass('is-visible')) {
|
||||
this.closeExtraVideosBar.apply(this, arguments);
|
||||
} else {
|
||||
this.openExtraVideosBar.apply(this, arguments);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @function
|
||||
*
|
||||
* Handle `input` event.
|
||||
*
|
||||
* @params {object} event Event object.
|
||||
*
|
||||
*/
|
||||
inputHandler: function (event) {
|
||||
if (event && event.preventDefault) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
var $el = $(event.currentTarget),
|
||||
$inputs = this.$el.find('.input'),
|
||||
entry = $el.val(),
|
||||
data = Utils.parseLink(entry),
|
||||
isNotEmpty = Boolean(entry);
|
||||
|
||||
// Empty value should not be validated
|
||||
if (this.checkValidity(data, isNotEmpty)) {
|
||||
var fieldsValue = this.getValueFromEditor(),
|
||||
modelValue = this.model.getValue();
|
||||
|
||||
if (modelValue) {
|
||||
// Remove empty values
|
||||
modelValue = modelValue.filter(_.identity);
|
||||
}
|
||||
|
||||
// When some correct value is adjusted (model is changed),
|
||||
// then field changes to incorrect value (no changes to model),
|
||||
// then back to previous correct value (that value is already
|
||||
// in model). In this case Backbone doesn't trigger 'change'
|
||||
// event on model. That's why render method will not be invoked
|
||||
// and we should hide error here.
|
||||
if (_.isEqual(fieldsValue, modelValue)) {
|
||||
this.messenger.hideError();
|
||||
} else {
|
||||
this.updateModel();
|
||||
}
|
||||
|
||||
// Enable inputs.
|
||||
$inputs
|
||||
.prop('disabled', false)
|
||||
.removeClass('is-disabled');
|
||||
|
||||
} else {
|
||||
// If any error occurs, disable all inputs except the current.
|
||||
// User cannot change other inputs before putting valid value in
|
||||
// the current input.
|
||||
$inputs
|
||||
.not($el)
|
||||
.prop('disabled', true)
|
||||
.addClass('is-disabled');
|
||||
|
||||
// If error occurs in the main video input, just close video
|
||||
// sources box.
|
||||
if ($el.hasClass('videolist-url')) {
|
||||
this.closeExtraVideosBar();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @function
|
||||
*
|
||||
* Checks the values currently displayed in the editor/view have unique
|
||||
* types (mp4 | webm | youtube).
|
||||
*
|
||||
* @param {object} videoList List of objects with information about the
|
||||
* values currently displayed in the editor/view
|
||||
*
|
||||
* @returns {boolean} Boolean value that indicate if video types are unique.
|
||||
*
|
||||
*/
|
||||
isUniqVideoTypes: function (videoList) {
|
||||
// Extract a list of "type" property values.
|
||||
var arr = _.pluck(videoList, 'type'), // => ex: ['youtube', 'mp4', 'mp4']
|
||||
// Produces a duplicate-free version of the array.
|
||||
uniqArr = _.uniq(arr); // => ex: ['youtube', 'mp4']
|
||||
|
||||
return arr.length === uniqArr.length;
|
||||
},
|
||||
|
||||
/**
|
||||
* @function
|
||||
*
|
||||
* Shows error message if the values currently displayed in the
|
||||
* editor/view have duplicate types.
|
||||
*
|
||||
* @param {object} list List of objects with information about the
|
||||
* values currently displayed in the editor/view
|
||||
*
|
||||
* @returns {boolean} Boolean value that indicate if video types are unique.
|
||||
*
|
||||
*/
|
||||
checkIsUniqVideoTypes: function (list) {
|
||||
var videoList = list || this.getVideoObjectsList(),
|
||||
isUnique = true;
|
||||
|
||||
if (!this.isUniqVideoTypes(videoList)) {
|
||||
this.messenger
|
||||
.showError('Link types should be unique.', true);
|
||||
|
||||
isUnique = false;
|
||||
}
|
||||
|
||||
return isUnique;
|
||||
},
|
||||
|
||||
/**
|
||||
* @function
|
||||
*
|
||||
* Checks if the values currently displayed in the editor/view have
|
||||
* valid values and show error messages.
|
||||
*
|
||||
* @param {object} data Objects with information about the value
|
||||
* currently displayed in the editor/view
|
||||
*
|
||||
* @param {boolean} showErrorModeMessage Disable mode validation
|
||||
*
|
||||
* @returns {boolean} Boolean value that indicate if value is valid.
|
||||
*
|
||||
*/
|
||||
checkValidity: function (data, showErrorModeMessage) {
|
||||
var self = this,
|
||||
videoList = this.getVideoObjectsList();
|
||||
|
||||
if (!this.checkIsUniqVideoTypes(videoList)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (data.mode === 'incorrect' && showErrorModeMessage) {
|
||||
this.messenger
|
||||
.showError('Incorrect url format.', true);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
return VideoList;
|
||||
});
|
||||
365
cms/static/js/views/transcripts/utils.js
Normal file
365
cms/static/js/views/transcripts/utils.js
Normal file
@@ -0,0 +1,365 @@
|
||||
define(["jquery", "underscore", "jquery.ajaxQueue"], function($, _) {
|
||||
var Utils = (function () {
|
||||
var Storage = {};
|
||||
|
||||
/**
|
||||
* @function
|
||||
*
|
||||
* Adds some data to the Storage object. If data with existent `data_id`
|
||||
* is added, nothing happens.
|
||||
*
|
||||
* @param {string} data_id Unique identifier for the data.
|
||||
* @param {any} data Data that should be stored.
|
||||
*
|
||||
* @returns {object} Object itself for chaining.
|
||||
*/
|
||||
Storage.set = function (data_id, data) {
|
||||
Storage[data_id] = data;
|
||||
|
||||
return this;
|
||||
};
|
||||
|
||||
/**
|
||||
* @function
|
||||
*
|
||||
* Return data from the Storage object by identifier.
|
||||
*
|
||||
* @param {string} data_id Unique identifier of the data.
|
||||
*
|
||||
* @returns {any} Stored data.
|
||||
*/
|
||||
Storage.get= function (data_id) {
|
||||
|
||||
return Storage[data_id];
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* @function
|
||||
*
|
||||
* Deletes data from the Storage object by identifier.
|
||||
*
|
||||
* @param {string} data_id Unique identifier of the data.
|
||||
*
|
||||
* @returns {boolean} Boolean value that indicate if data is removed.
|
||||
*/
|
||||
Storage.remove = function (data_id) {
|
||||
|
||||
return (delete Storage[data_id]);
|
||||
};
|
||||
|
||||
/**
|
||||
* @function
|
||||
*
|
||||
* Returns model from collection by 'field_name' property.
|
||||
*
|
||||
* @param {object} collection The model (CMS.Models.Metadata) containing
|
||||
* information about metadata setting editors.
|
||||
* @param {string} field_name Name of field that should be found.
|
||||
*
|
||||
* @returns {
|
||||
* object: when model exist,
|
||||
* undefined: when model doesn't exist.
|
||||
* }
|
||||
*/
|
||||
var _getField = function (collection, field_name) {
|
||||
var model;
|
||||
|
||||
if (collection && field_name) {
|
||||
model = collection.findWhere({
|
||||
field_name: field_name
|
||||
});
|
||||
}
|
||||
|
||||
return model;
|
||||
};
|
||||
|
||||
/**
|
||||
* @function
|
||||
*
|
||||
* Parses Youtube link and return video id.
|
||||
*
|
||||
* These are the types of URLs supported:
|
||||
* http://www.youtube.com/watch?v=OEoXaMPEzfM&feature=feedrec_grec_index
|
||||
* http://www.youtube.com/user/IngridMichaelsonVEVO#p/a/u/1/OEoXaMPEzfM
|
||||
* http://www.youtube.com/v/OEoXaMPEzfM?fs=1&hl=en_US&rel=0
|
||||
* http://www.youtube.com/watch?v=OEoXaMPEzfM#t=0m10s
|
||||
* http://www.youtube.com/embed/OEoXaMPEzfM?rel=0
|
||||
* http://www.youtube.com/watch?v=OEoXaMPEzfM
|
||||
* http://youtu.be/OEoXaMPEzfM
|
||||
*
|
||||
* @param {string} url Url that should be parsed.
|
||||
*
|
||||
* @returns {
|
||||
* string: Video Id,
|
||||
* undefined: when url has incorrect format or argument is
|
||||
* non-string, video id's length is not equal 11.
|
||||
* }
|
||||
*/
|
||||
var _youtubeParser = (function () {
|
||||
var cache = {};
|
||||
|
||||
return function (url) {
|
||||
if (typeof url !== 'string') {
|
||||
|
||||
return void(0);
|
||||
}
|
||||
|
||||
if (cache[url]) {
|
||||
return cache[url];
|
||||
}
|
||||
|
||||
var regExp = /.*(?:youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=)([^#\&\?]*).*/;
|
||||
var match = url.match(regExp);
|
||||
cache[url] = (match && match[1].length === 11) ? match[1] : void(0);
|
||||
|
||||
return cache[url];
|
||||
};
|
||||
}());
|
||||
|
||||
/**
|
||||
* @function
|
||||
*
|
||||
* Parses links with html5 video sources in mp4 or webm formats.
|
||||
*
|
||||
* @param {string} url Url that should be parsed.
|
||||
*
|
||||
* @returns {
|
||||
* object: Object with information about the video
|
||||
* (file name, video type),
|
||||
* undefined: when url has incorrect format or argument is
|
||||
* non-string.
|
||||
* }
|
||||
*/
|
||||
var _videoLinkParser = (function () {
|
||||
var cache = {};
|
||||
|
||||
return function (url) {
|
||||
if (typeof url !== 'string') {
|
||||
|
||||
return void(0);
|
||||
}
|
||||
|
||||
if (cache[url]) {
|
||||
return cache[url];
|
||||
}
|
||||
|
||||
var link = document.createElement('a'),
|
||||
match;
|
||||
|
||||
link.href = url;
|
||||
match = link.pathname
|
||||
.split('/')
|
||||
.pop()
|
||||
.match(/(.+)\.(mp4|webm)$/);
|
||||
|
||||
if (match) {
|
||||
cache[url] = {
|
||||
video: match[1],
|
||||
type: match[2]
|
||||
};
|
||||
}
|
||||
|
||||
return cache[url];
|
||||
};
|
||||
}());
|
||||
|
||||
/**
|
||||
* @function
|
||||
*
|
||||
* Facade function that parses html5 and youtube links.
|
||||
*
|
||||
* @param {string} url Url that should be parsed.
|
||||
*
|
||||
* @returns {
|
||||
* object: Object with information about the video:
|
||||
* {
|
||||
* mode: "youtube|html5|incorrect",
|
||||
* video: "file_name|youtube_id",
|
||||
* type: "youtube|mp4|webm"
|
||||
* },
|
||||
* undefined: when argument is non-string.
|
||||
* }
|
||||
*/
|
||||
var _linkParser = function (url) {
|
||||
var result;
|
||||
|
||||
if (typeof url !== 'string') {
|
||||
|
||||
return void(0);
|
||||
}
|
||||
|
||||
if (_youtubeParser(url)) {
|
||||
result = {
|
||||
mode: 'youtube',
|
||||
video: _youtubeParser(url),
|
||||
type: 'youtube'
|
||||
};
|
||||
} else if (_videoLinkParser(url)) {
|
||||
result = $.extend({mode: 'html5'}, _videoLinkParser(url));
|
||||
} else {
|
||||
result = {
|
||||
mode: 'incorrect'
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* @function
|
||||
*
|
||||
* Returns short-hand youtube url.
|
||||
*
|
||||
* @param {string} video_id Youtube Video Id that will be added to the link.
|
||||
*
|
||||
* @returns {string} Short-hand Youtube url.
|
||||
*
|
||||
* @example
|
||||
* _getYoutubeLink('OEoXaMPEzfM'); => 'http://youtu.be/OEoXaMPEzfM'
|
||||
*/
|
||||
var _getYoutubeLink = function (video_id) {
|
||||
return 'http://youtu.be/' + video_id;
|
||||
};
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* @function
|
||||
*
|
||||
* Returns list of objects with information about the passed links.
|
||||
*
|
||||
* @param {array} links List of links that will be processed.
|
||||
*
|
||||
* @returns {array} List of objects.
|
||||
*
|
||||
* @examples
|
||||
* var links = [
|
||||
* 'http://youtu.be/OEoXaMPEzfM',
|
||||
* 'video_name.mp4',
|
||||
* 'video_name.webm'
|
||||
* ]
|
||||
*
|
||||
* _getVideoList(links); // =>
|
||||
* [
|
||||
* {mode: `youtube`, type: `youtube`, ...},
|
||||
* {mode: `html5`, type: `mp4`, ...},
|
||||
* {mode: `html5`, type: `webm`, ...}
|
||||
* ]
|
||||
*
|
||||
*/
|
||||
var _getVideoList = function (links) {
|
||||
if ($.isArray(links)) {
|
||||
var arr = [],
|
||||
data;
|
||||
|
||||
for (var i = 0, len = links.length; i < len; i += 1) {
|
||||
data = _linkParser(links[i]);
|
||||
|
||||
if (data.mode !== 'incorrect') {
|
||||
arr.push(data);
|
||||
}
|
||||
}
|
||||
|
||||
return arr;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* @function
|
||||
*
|
||||
* Synchronizes 2 Backbone collections by 'field_name' property.
|
||||
*
|
||||
* @param {object} fromCollection Collection with which synchronization
|
||||
* will happens.
|
||||
* @param {object} toCollection Collection which will synchronized.
|
||||
*
|
||||
*/
|
||||
var _syncCollections = function (fromCollection, toCollection) {
|
||||
fromCollection.each(function (m) {
|
||||
var model = toCollection.findWhere({
|
||||
field_name: m.getFieldName()
|
||||
});
|
||||
|
||||
if (model) {
|
||||
model.setValue(m.getDisplayValue());
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @function
|
||||
*
|
||||
* Sends Ajax requests in appropriate format.
|
||||
*
|
||||
* @param {string} action Action that will be invoked on server. Is a part
|
||||
* of url.
|
||||
* @param {string} component_id Id of component.
|
||||
* @param {array} videoList List of object with information about inserted
|
||||
* urls.
|
||||
* @param {object} extraParams Extra parameters that can be send to the
|
||||
* server
|
||||
*
|
||||
* @returns {object} XMLHttpRequest object. Using this object, we can attach
|
||||
* callbacks to AJAX request events (for example on 'done', 'fail',
|
||||
* etc.).
|
||||
*/
|
||||
var _command = (function () {
|
||||
// We will store the XMLHttpRequest object that $.ajax() function
|
||||
// returns, to abort an ongoing AJAX request (if necessary) upon
|
||||
// subsequent invocations of _command() function.
|
||||
//
|
||||
// A new AJAX request will be made on each invocation of the
|
||||
// _command() function.
|
||||
var xhr = null;
|
||||
|
||||
return function (action, component_id, videoList, extraParams) {
|
||||
var params, data;
|
||||
|
||||
console.log('[_command]: arguments = ', arguments);
|
||||
|
||||
if (extraParams) {
|
||||
if ($.isPlainObject(extraParams)) {
|
||||
params = extraParams;
|
||||
} else {
|
||||
params = {params: extraParams};
|
||||
}
|
||||
}
|
||||
|
||||
data = $.extend(
|
||||
{ id: component_id },
|
||||
{ videos: videoList },
|
||||
params
|
||||
);
|
||||
|
||||
xhr = $.ajaxQueue({
|
||||
url: '/transcripts/' + action,
|
||||
data: { data: JSON.stringify(data) },
|
||||
notifyOnError: false,
|
||||
type: 'get'
|
||||
});
|
||||
|
||||
return xhr;
|
||||
};
|
||||
}());
|
||||
|
||||
return {
|
||||
getField: _getField,
|
||||
parseYoutubeLink: _youtubeParser,
|
||||
parseHTML5Link: _videoLinkParser,
|
||||
parseLink: _linkParser,
|
||||
getYoutubeLink: _getYoutubeLink,
|
||||
syncCollections: _syncCollections,
|
||||
command: _command,
|
||||
getVideoList: _getVideoList,
|
||||
Storage: {
|
||||
set: Storage.set,
|
||||
get: Storage.get,
|
||||
remove: Storage.remove
|
||||
}
|
||||
};
|
||||
}());
|
||||
|
||||
return Utils;
|
||||
});
|
||||
300
cms/static/js_spec/transcripts/editor_spec.js
Normal file
300
cms/static/js_spec/transcripts/editor_spec.js
Normal file
@@ -0,0 +1,300 @@
|
||||
define(
|
||||
[
|
||||
"jquery", "backbone", "underscore",
|
||||
"js/views/transcripts/utils", "js/views/transcripts/editor",
|
||||
"js/views/metadata", "js/models/metadata", "js/collections/metadata",
|
||||
"underscore.string", "xmodule", "js/views/transcripts/metadata_videolist",
|
||||
"jasmine-jquery"
|
||||
],
|
||||
function ($, Backbone, _, Utils, Editor, MetadataView, MetadataModel, MetadataCollection, _str) {
|
||||
describe('Transcripts.Editor', function () {
|
||||
var VideoListEntry = {
|
||||
default_value: ['a thing', 'another thing'],
|
||||
display_name: 'Video URL',
|
||||
explicitly_set: true,
|
||||
field_name: 'video_url',
|
||||
help: 'A list of things.',
|
||||
options: [],
|
||||
type: MetadataModel.VIDEO_LIST_TYPE,
|
||||
value: [
|
||||
'http://youtu.be/12345678901',
|
||||
'video.mp4',
|
||||
'video.webm'
|
||||
]
|
||||
},
|
||||
DisplayNameEntry = {
|
||||
default_value: 'default value',
|
||||
display_name: 'Dispaly Name',
|
||||
explicitly_set: true,
|
||||
field_name: 'display_name',
|
||||
help: 'Specifies the name for this component.',
|
||||
options: [],
|
||||
type: MetadataModel.GENERIC_TYPE,
|
||||
value: 'display value'
|
||||
},
|
||||
models = [DisplayNameEntry, VideoListEntry],
|
||||
testData = {
|
||||
'display_name': DisplayNameEntry,
|
||||
'video_url': VideoListEntry
|
||||
},
|
||||
metadataDict = {
|
||||
object: testData,
|
||||
string: JSON.stringify(testData)
|
||||
},
|
||||
transcripts, container;
|
||||
|
||||
beforeEach(function () {
|
||||
var tpl = sandbox({
|
||||
'class': 'wrapper-comp-settings basic_metadata_edit',
|
||||
'data-metadata': JSON.stringify(metadataDict['object'])
|
||||
});
|
||||
|
||||
appendSetFixtures(tpl);
|
||||
container = $('.basic_metadata_edit');
|
||||
|
||||
spyOn(Utils, 'command');
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
Utils.Storage.remove('sub');
|
||||
});
|
||||
|
||||
describe('Test initialization', function () {
|
||||
|
||||
beforeEach(function () {
|
||||
spyOn(MetadataView, 'Editor');
|
||||
|
||||
transcripts = new Editor({
|
||||
el: container
|
||||
});
|
||||
});
|
||||
|
||||
$.each(metadataDict, function(index, val) {
|
||||
it('toModels with argument as ' + index, function () {
|
||||
|
||||
expect(transcripts.toModels(val)).toEqual(models);
|
||||
});
|
||||
});
|
||||
|
||||
it('MetadataView.Editor is initialized', function () {
|
||||
|
||||
expect(MetadataView.Editor).toHaveBeenCalledWith({
|
||||
el: container,
|
||||
collection: transcripts.collection
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test synchronization', function () {
|
||||
var nameEntry = {
|
||||
default_value: 'default value',
|
||||
display_name: 'Display Name',
|
||||
explicitly_set: true,
|
||||
field_name: 'display_name',
|
||||
help: 'Specifies the name for this component.',
|
||||
options: [],
|
||||
type: MetadataModel.GENERIC_TYPE,
|
||||
value: 'default'
|
||||
},
|
||||
|
||||
subEntry = {
|
||||
default_value: 'default value',
|
||||
display_name: 'Timed Transcript',
|
||||
explicitly_set: true,
|
||||
field_name: 'sub',
|
||||
help: 'Specifies the name for this component.',
|
||||
options: [],
|
||||
type: 'Generic',
|
||||
value: 'default'
|
||||
},
|
||||
|
||||
html5SourcesEntry = {
|
||||
default_value: ['a thing', 'another thing'],
|
||||
display_name: 'Video Sources',
|
||||
explicitly_set: true,
|
||||
field_name: 'html5_sources',
|
||||
help: 'A list of html5 sources.',
|
||||
options: [],
|
||||
type: MetadataModel.LIST_TYPE,
|
||||
value: ['default.mp4', 'default.webm']
|
||||
},
|
||||
|
||||
youtubeEntry = {
|
||||
default_value: 'OEoXaMPEzfM',
|
||||
display_name: 'Youtube ID',
|
||||
explicitly_set: true,
|
||||
field_name: 'youtube_id_1_0',
|
||||
help: 'Specifies the name for this component.',
|
||||
options: [],
|
||||
type: MetadataModel.GENERIC_TYPE,
|
||||
value: 'OEoXaMPEzfM'
|
||||
},
|
||||
metadataCollection,
|
||||
metadataView;
|
||||
|
||||
|
||||
beforeEach(function () {
|
||||
spyOn(MetadataView, 'Editor');
|
||||
|
||||
transcripts = new Editor({
|
||||
el: container
|
||||
});
|
||||
|
||||
metadataCollection = new MetadataCollection(
|
||||
[
|
||||
nameEntry,
|
||||
subEntry,
|
||||
html5SourcesEntry,
|
||||
youtubeEntry
|
||||
]
|
||||
);
|
||||
|
||||
metadataView = jasmine.createSpyObj(
|
||||
'MetadataView.Editor',
|
||||
[
|
||||
'getModifiedMetadataValues'
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
describe('Test Advanced to Basic synchronization', function () {
|
||||
it('Correct data', function () {
|
||||
transcripts.syncBasicTab(metadataCollection, metadataView);
|
||||
|
||||
var collection = transcripts.collection.models,
|
||||
displayNameValue = collection[0].getValue(),
|
||||
videoUrlValue = collection[1].getValue();
|
||||
|
||||
expect(displayNameValue).toBe('default');
|
||||
expect(videoUrlValue).toEqual([
|
||||
'http://youtu.be/OEoXaMPEzfM',
|
||||
'default.mp4',
|
||||
'default.webm'
|
||||
]);
|
||||
});
|
||||
|
||||
it('If metadataCollection is not defined', function () {
|
||||
transcripts.syncBasicTab(null);
|
||||
|
||||
var collection = transcripts.collection.models,
|
||||
videoUrlValue = collection[1].getValue();
|
||||
|
||||
expect(videoUrlValue).toEqual([
|
||||
'http://youtu.be/12345678901',
|
||||
'video.mp4',
|
||||
'video.webm'
|
||||
]);
|
||||
});
|
||||
|
||||
it('Youtube Id has length not eqaul 11', function () {
|
||||
var model = metadataCollection.findWhere({
|
||||
field_name: 'youtube_id_1_0'
|
||||
});
|
||||
|
||||
model.setValue([
|
||||
'12345678',
|
||||
'default.mp4',
|
||||
'default.webm'
|
||||
]);
|
||||
|
||||
transcripts.syncBasicTab(metadataCollection, metadataView);
|
||||
|
||||
var collection = transcripts.collection.models,
|
||||
videoUrlValue = collection[1].getValue();
|
||||
|
||||
expect(videoUrlValue).toEqual([
|
||||
'',
|
||||
'default.mp4',
|
||||
'default.webm'
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test Basic to Advanced synchronization', function () {
|
||||
it('Correct data', function () {
|
||||
transcripts.syncAdvancedTab(metadataCollection);
|
||||
|
||||
var collection = metadataCollection.models,
|
||||
displayNameValue = collection[0].getValue(),
|
||||
subValue = collection[1].getValue(),
|
||||
html5SourcesValue = collection[2].getValue(),
|
||||
youtubeValue = collection[3].getValue();
|
||||
|
||||
expect(displayNameValue).toBe('display value');
|
||||
expect(subValue).toBe('default');
|
||||
expect(html5SourcesValue).toEqual([
|
||||
'video.mp4',
|
||||
'video.webm'
|
||||
]);
|
||||
expect(youtubeValue).toBe('12345678901');
|
||||
});
|
||||
|
||||
it('metadataCollection is not defined', function () {
|
||||
transcripts.syncAdvancedTab(null);
|
||||
|
||||
var collection = metadataCollection.models,
|
||||
displayNameValue = collection[0].getValue(),
|
||||
subValue = collection[1].getValue(),
|
||||
html5SourcesValue = collection[2].getValue(),
|
||||
youtubeValue = collection[3].getValue();
|
||||
|
||||
expect(displayNameValue).toBe('default');
|
||||
expect(subValue).toBe('default');
|
||||
expect(html5SourcesValue).toEqual([
|
||||
'default.mp4',
|
||||
'default.webm'
|
||||
]);
|
||||
expect(youtubeValue).toBe('OEoXaMPEzfM');
|
||||
});
|
||||
|
||||
it('Youtube Id is not adjusted', function () {
|
||||
var model = transcripts.collection.models[1];
|
||||
|
||||
model.setValue([
|
||||
'video.mp4',
|
||||
'video.webm'
|
||||
]);
|
||||
|
||||
transcripts.syncAdvancedTab(metadataCollection);
|
||||
|
||||
var collection = metadataCollection.models,
|
||||
html5SourcesValue = collection[2].getValue(),
|
||||
youtubeValue = collection[3].getValue();
|
||||
|
||||
expect(html5SourcesValue).toEqual([
|
||||
'video.mp4',
|
||||
'video.webm'
|
||||
]);
|
||||
expect(youtubeValue).toBe('');
|
||||
});
|
||||
|
||||
it('Timed Transcript field is updated', function () {
|
||||
Utils.Storage.set('sub', 'test_value');
|
||||
|
||||
transcripts.syncAdvancedTab(metadataCollection);
|
||||
|
||||
var collection = metadataCollection.models,
|
||||
subValue = collection[1].getValue();
|
||||
|
||||
expect(subValue).toBe('test_value');
|
||||
});
|
||||
|
||||
it('Timed Transcript field is updated just once', function () {
|
||||
Utils.Storage.set('sub', 'test_value');
|
||||
|
||||
var collection = metadataCollection.models,
|
||||
subModel = collection[1];
|
||||
|
||||
spyOn(subModel, 'setValue');
|
||||
|
||||
transcripts.syncAdvancedTab(metadataCollection);
|
||||
transcripts.syncAdvancedTab(metadataCollection);
|
||||
transcripts.syncAdvancedTab(metadataCollection);
|
||||
|
||||
expect(subModel.setValue.calls.length).toBe(1);
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
241
cms/static/js_spec/transcripts/file_uploader_spec.js
Normal file
241
cms/static/js_spec/transcripts/file_uploader_spec.js
Normal file
@@ -0,0 +1,241 @@
|
||||
define(
|
||||
[
|
||||
"jquery", "underscore",
|
||||
"js/views/transcripts/utils", "js/views/transcripts/file_uploader",
|
||||
"xmodule", "jquery.form", "jasmine-jquery"
|
||||
],
|
||||
function ($, _, Utils, FileUploader) {
|
||||
describe('Transcripts.FileUploader', function () {
|
||||
var videoListEntryTemplate = readFixtures(
|
||||
'transcripts/metadata-videolist-entry.underscore'
|
||||
),
|
||||
fileUploadTemplate = readFixtures(
|
||||
'transcripts/file-upload.underscore'
|
||||
),
|
||||
view;
|
||||
|
||||
beforeEach(function () {
|
||||
setFixtures(
|
||||
$("<div>", {id: "metadata-videolist-entry"})
|
||||
.html(videoListEntryTemplate)
|
||||
);
|
||||
appendSetFixtures(
|
||||
$("<script>",
|
||||
{
|
||||
id: "file-upload",
|
||||
type: "text/template"
|
||||
}
|
||||
).text(fileUploadTemplate)
|
||||
);
|
||||
|
||||
var messenger = jasmine.createSpyObj(
|
||||
'MessageManager',
|
||||
['render', 'showError', 'hideError']
|
||||
),
|
||||
videoListObject = jasmine.createSpyObj(
|
||||
'MetadataView.VideoList',
|
||||
['render', 'getVideoObjectsList']
|
||||
),
|
||||
$container = $('.transcripts-status');
|
||||
|
||||
$container
|
||||
.append('<div class="transcripts-file-uploader" />')
|
||||
.append('<a class="setting-upload" href="#">Upload</a>');
|
||||
|
||||
spyOn(FileUploader.prototype, 'render').andCallThrough();
|
||||
|
||||
view = new FileUploader({
|
||||
el: $container,
|
||||
messenger: messenger,
|
||||
videoListObject: videoListObject,
|
||||
component_id: 'component_id'
|
||||
});
|
||||
});
|
||||
|
||||
it('Initialize', function () {
|
||||
expect(view.file).toBe(false);
|
||||
expect(FileUploader.prototype.render).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('Render', function () {
|
||||
|
||||
beforeEach(function () {
|
||||
spyOn(_, 'template').andCallThrough();
|
||||
});
|
||||
|
||||
it('Template doesn\'t exist', function () {
|
||||
spyOn(console, 'error');
|
||||
view.uploadTpl = '';
|
||||
view.render();
|
||||
|
||||
expect(console.error).toHaveBeenCalled();
|
||||
expect(view.render).not.toThrow();
|
||||
expect(_.template).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Container where template will be inserted doesn\'t exist',
|
||||
function () {
|
||||
$('.transcripts-file-uploader').remove();
|
||||
|
||||
view.render();
|
||||
|
||||
expect(view.render).not.toThrow();
|
||||
expect(_.template).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('All works okay if all data is okay', function () {
|
||||
var elList = ['$form', '$input', '$progress'],
|
||||
validFileExtensions = ['srt', 'sjson'],
|
||||
result = $.map(validFileExtensions, function(item, index) {
|
||||
return '.' + item;
|
||||
}).join(', ');
|
||||
|
||||
view.validFileExtensions = validFileExtensions;
|
||||
view.render();
|
||||
|
||||
expect(view.render).not.toThrow();
|
||||
expect(_.template).toHaveBeenCalled();
|
||||
$.each(elList, function(index, el) {
|
||||
expect(view[el].length).not.toBe(0);
|
||||
});
|
||||
expect(view.$input.attr('accept')).toBe(result);
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
describe('Upload', function () {
|
||||
it('File is not chosen', function () {
|
||||
spyOn($.fn, 'ajaxSubmit');
|
||||
view.upload();
|
||||
|
||||
expect(view.$form.ajaxSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('File is chosen', function () {
|
||||
spyOn($.fn, 'ajaxSubmit');
|
||||
|
||||
view.file = {};
|
||||
view.upload();
|
||||
|
||||
expect(view.$form.ajaxSubmit).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('clickHandler', function () {
|
||||
spyOn($.fn, 'trigger');
|
||||
|
||||
$('.setting-upload').click();
|
||||
|
||||
expect($('.setting-upload').trigger).toHaveBeenCalledWith('click');
|
||||
expect(view.$input).toHaveValue('');
|
||||
});
|
||||
|
||||
describe('changeHadler', function () {
|
||||
beforeEach(function () {
|
||||
spyOn(view, 'upload');
|
||||
});
|
||||
|
||||
it('Valid File Type - error should be hided', function () {
|
||||
spyOn(view, 'checkExtValidity').andReturn(true);
|
||||
|
||||
view.$input.change();
|
||||
|
||||
expect(view.checkExtValidity).toHaveBeenCalled();
|
||||
expect(view.upload).toHaveBeenCalled();
|
||||
expect(view.options.messenger.hideError).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Invalid File Type - error should be shown', function () {
|
||||
spyOn(view, 'checkExtValidity').andReturn(false);
|
||||
|
||||
view.$input.change();
|
||||
|
||||
expect(view.checkExtValidity).toHaveBeenCalled();
|
||||
expect(view.upload).not.toHaveBeenCalled();
|
||||
expect(view.options.messenger.showError).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkExtValidity', function () {
|
||||
var data = {
|
||||
Correct: {
|
||||
name: 'file_name.srt',
|
||||
isValid: true
|
||||
},
|
||||
Incorrect: {
|
||||
name: 'file_name.mp4',
|
||||
isValid: false
|
||||
}
|
||||
};
|
||||
|
||||
$.each(data, function(fileType, fileInfo) {
|
||||
it(fileType + ' file type', function () {
|
||||
var result = view.checkExtValidity(fileInfo);
|
||||
|
||||
expect(result).toBe(fileInfo.isValid);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('xhrResetProgressBar', function () {
|
||||
view.xhrResetProgressBar();
|
||||
expect(view.$progress.width()).toBe(0);
|
||||
expect(view.$progress.html()).toBe('0%');
|
||||
expect(view.$progress).not.toHaveClass('is-invisible');
|
||||
});
|
||||
|
||||
it('xhrProgressHandler', function () {
|
||||
var percent = 26;
|
||||
|
||||
spyOn($.fn, 'width').andCallThrough();
|
||||
|
||||
view.xhrProgressHandler(null, null, null, percent);
|
||||
expect(view.$progress.width).toHaveBeenCalledWith(percent + '%');
|
||||
expect(view.$progress.html()).toBe(percent + '%');
|
||||
});
|
||||
|
||||
describe('xhrCompleteHandler', function () {
|
||||
it('Ajax Success', function () {
|
||||
var xhr = {
|
||||
status: 200,
|
||||
responseText: JSON.stringify({
|
||||
status: 'Success',
|
||||
subs: 'test'
|
||||
})
|
||||
};
|
||||
spyOn(Utils.Storage, 'set');
|
||||
view.xhrCompleteHandler(xhr);
|
||||
|
||||
expect(view.$progress).toHaveClass('is-invisible');
|
||||
expect(view.options.messenger.render.mostRecentCall.args[0])
|
||||
.toEqual('uploaded');
|
||||
expect(Utils.Storage.set)
|
||||
.toHaveBeenCalledWith('sub', 'test');
|
||||
});
|
||||
|
||||
var assertAjaxError = function (xhr) {
|
||||
spyOn(Utils.Storage, 'set');
|
||||
view.xhrCompleteHandler(xhr);
|
||||
|
||||
expect(view.options.messenger.showError).toHaveBeenCalled();
|
||||
expect(view.$progress).toHaveClass('is-invisible');
|
||||
expect(view.options.messenger.render)
|
||||
.not
|
||||
.toHaveBeenCalled();
|
||||
expect(Utils.Storage.set)
|
||||
.not
|
||||
.toHaveBeenCalledWith('sub', 'test');
|
||||
};
|
||||
|
||||
it('Ajax transport Error', function () {
|
||||
var xhr = {
|
||||
status: 400,
|
||||
responseText: JSON.stringify({})
|
||||
};
|
||||
|
||||
assertAjaxError(xhr);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
284
cms/static/js_spec/transcripts/message_manager_spec.js
Normal file
284
cms/static/js_spec/transcripts/message_manager_spec.js
Normal file
@@ -0,0 +1,284 @@
|
||||
define(
|
||||
[
|
||||
"jquery", "underscore",
|
||||
"js/views/transcripts/utils", "js/views/transcripts/message_manager",
|
||||
"js/views/transcripts/file_uploader", "sinon", "jasmine-jquery",
|
||||
"xmodule"
|
||||
],
|
||||
function ($, _, Utils, MessageManager, FileUploader, sinon) {
|
||||
|
||||
describe('Transcripts.MessageManager', function () {
|
||||
var videoListEntryTemplate = readFixtures(
|
||||
'transcripts/metadata-videolist-entry.underscore'
|
||||
),
|
||||
foundTemplate = readFixtures(
|
||||
'transcripts/messages/transcripts-found.underscore'
|
||||
),
|
||||
handlers = {
|
||||
importHandler: ['replace', 'Error: Import failed.'],
|
||||
replaceHandler: ['replace', 'Error: Replacing failed.'],
|
||||
chooseHandler: ['choose', 'Error: Choosing failed.', 'video_id']
|
||||
},
|
||||
view, fileUploader, sinonXhr;
|
||||
|
||||
beforeEach(function () {
|
||||
var videoList, $container;
|
||||
|
||||
fileUploader = FileUploader.prototype;
|
||||
|
||||
setFixtures(
|
||||
$("<div>", {id: "metadata-videolist-entry"})
|
||||
.html(videoListEntryTemplate)
|
||||
);
|
||||
appendSetFixtures(
|
||||
$("<script>",
|
||||
{
|
||||
id: "transcripts-found",
|
||||
type: "text/template"
|
||||
}
|
||||
).text(foundTemplate)
|
||||
);
|
||||
|
||||
videoList = jasmine.createSpyObj(
|
||||
'MetadataView.VideoList',
|
||||
['getVideoObjectsList']
|
||||
);
|
||||
$container = $('#metadata-videolist-entry');
|
||||
|
||||
spyOn(fileUploader, 'initialize');
|
||||
spyOn(console, 'error');
|
||||
spyOn(Utils.Storage, 'set');
|
||||
|
||||
view = new MessageManager({
|
||||
el: $container,
|
||||
parent: videoList,
|
||||
component_id: 'component_id'
|
||||
});
|
||||
});
|
||||
|
||||
it('Initialize', function () {
|
||||
expect(fileUploader.initialize).toHaveBeenCalledWith({
|
||||
el: view.$el,
|
||||
messenger: view,
|
||||
component_id: view.component_id,
|
||||
videoListObject: view.options.parent
|
||||
});
|
||||
});
|
||||
|
||||
describe('Render', function () {
|
||||
|
||||
beforeEach(function () {
|
||||
spyOn(_,'template').andCallThrough();
|
||||
spyOn(fileUploader, 'render');
|
||||
});
|
||||
|
||||
it('Template doesn\'t exist', function () {
|
||||
view.render('incorrect_template_name');
|
||||
|
||||
expect(console.error).toHaveBeenCalled();
|
||||
expect(_.template).not.toHaveBeenCalled();
|
||||
expect(view.$el.find('.transcripts-status'))
|
||||
.toHaveClass('is-invisible');
|
||||
expect(fileUploader.render).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('All works okay if correct data is passed', function () {
|
||||
view.render('found');
|
||||
|
||||
expect(console.error).not.toHaveBeenCalled();
|
||||
expect(_.template).toHaveBeenCalled();
|
||||
expect(view.$el).not.toHaveClass('is-invisible');
|
||||
expect(fileUploader.render).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('showError', function () {
|
||||
var errorMessage ='error',
|
||||
$error, $buttons;
|
||||
|
||||
beforeEach(function () {
|
||||
view.render('found');
|
||||
spyOn(view, 'hideError');
|
||||
spyOn($.fn, 'html').andCallThrough();
|
||||
$error = view.$el.find('.transcripts-error-message');
|
||||
$buttons = view.$el.find('.wrapper-transcripts-buttons');
|
||||
});
|
||||
|
||||
it('Error message is not passed', function () {
|
||||
view.showError(null);
|
||||
|
||||
expect(view.hideError).not.toHaveBeenCalled();
|
||||
expect($error.html).not.toHaveBeenCalled();
|
||||
expect($error).toHaveClass('is-invisible');
|
||||
expect($buttons).not.toHaveClass('is-invisible');
|
||||
});
|
||||
|
||||
it('Show message and buttons', function () {
|
||||
view.showError(errorMessage);
|
||||
|
||||
expect(view.hideError).toHaveBeenCalled();
|
||||
expect($error.html).toHaveBeenCalled();
|
||||
expect($error).not.toHaveClass('is-invisible');
|
||||
expect($buttons).not.toHaveClass('is-invisible');
|
||||
});
|
||||
|
||||
it('Show message and hide buttons', function () {
|
||||
view.showError(errorMessage, true);
|
||||
|
||||
expect(view.hideError).toHaveBeenCalled();
|
||||
expect($error.html).toHaveBeenCalled();
|
||||
expect($error).not.toHaveClass('is-invisible');
|
||||
expect($buttons).toHaveClass('is-invisible');
|
||||
});
|
||||
});
|
||||
|
||||
it('hideError', function () {
|
||||
view.render('found');
|
||||
|
||||
var $error = view.$el.find('.transcripts-error-message'),
|
||||
$buttons = view.$el.find('.wrapper-transcripts-buttons');
|
||||
|
||||
expect($error).toHaveClass('is-invisible');
|
||||
expect($buttons).not.toHaveClass('is-invisible');
|
||||
});
|
||||
|
||||
$.each(handlers, function(key, value) {
|
||||
it(key, function () {
|
||||
var eventObj = jasmine.createSpyObj('event', ['preventDefault']);
|
||||
spyOn($.fn, 'data').andReturn('video_id');
|
||||
spyOn(view, 'processCommand');
|
||||
view[key](eventObj);
|
||||
expect(view.processCommand.mostRecentCall.args).toEqual(value);
|
||||
});
|
||||
});
|
||||
|
||||
describe('processCommand', function () {
|
||||
var action = 'replace',
|
||||
errorMessage = 'errorMessage',
|
||||
videoList = void(0),
|
||||
extraParamas = 'video_id';
|
||||
|
||||
beforeEach(function () {
|
||||
view.render('found');
|
||||
spyOn(Utils, 'command').andCallThrough();
|
||||
spyOn(view, 'render');
|
||||
spyOn(view, 'showError');
|
||||
|
||||
sinonXhr = sinon.fakeServer.create();
|
||||
sinonXhr.autoRespond = true;
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
sinonXhr.restore();
|
||||
});
|
||||
|
||||
var assertCommand = function (config, expectFunc) {
|
||||
var flag = false,
|
||||
defaults = {
|
||||
action: 'replace',
|
||||
errorMessage: 'errorMessage',
|
||||
extraParamas: void(0)
|
||||
};
|
||||
args = $.extend({}, defaults, config);
|
||||
|
||||
runs(function() {
|
||||
view
|
||||
.processCommand(
|
||||
args.action,
|
||||
args.errorMessage,
|
||||
args.extraParamas
|
||||
)
|
||||
.always(function () { flag = true; });
|
||||
});
|
||||
|
||||
waitsFor(function() {
|
||||
return flag;
|
||||
}, "Ajax Timeout", 750);
|
||||
|
||||
|
||||
runs(expectFunc);
|
||||
};
|
||||
|
||||
it('Invoke without extraParamas', function () {
|
||||
|
||||
sinonXhr.respondWith([
|
||||
200,
|
||||
{ "Content-Type": "application/json"},
|
||||
JSON.stringify({
|
||||
status: 'Success',
|
||||
subs: 'video_id'
|
||||
})
|
||||
]);
|
||||
|
||||
assertCommand(
|
||||
{ },
|
||||
function() {
|
||||
expect(Utils.command).toHaveBeenCalledWith(
|
||||
action,
|
||||
view.component_id,
|
||||
videoList,
|
||||
void(0)
|
||||
);
|
||||
expect(view.showError).not.toHaveBeenCalled();
|
||||
expect(view.render.mostRecentCall.args[0])
|
||||
.toEqual('found');
|
||||
expect(Utils.Storage.set).toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('Invoke with extraParamas', function () {
|
||||
|
||||
sinonXhr.respondWith([
|
||||
200,
|
||||
{ "Content-Type": "application/json"},
|
||||
JSON.stringify({
|
||||
status: 'Success',
|
||||
subs: 'video_id'
|
||||
})
|
||||
]);
|
||||
|
||||
view.processCommand(action, errorMessage, extraParamas);
|
||||
|
||||
assertCommand(
|
||||
{ extraParamas : extraParamas },
|
||||
function () {
|
||||
expect(Utils.command).toHaveBeenCalledWith(
|
||||
action,
|
||||
view.component_id,
|
||||
videoList,
|
||||
{
|
||||
html5_id: extraParamas
|
||||
}
|
||||
);
|
||||
expect(view.showError).not.toHaveBeenCalled();
|
||||
expect(view.render.mostRecentCall.args[0])
|
||||
.toEqual('found');
|
||||
expect(Utils.Storage.set).toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('Fail', function () {
|
||||
|
||||
sinonXhr.respondWith([400, {}, '']);
|
||||
|
||||
assertCommand(
|
||||
{ },
|
||||
function () {
|
||||
expect(Utils.command).toHaveBeenCalledWith(
|
||||
action,
|
||||
view.component_id,
|
||||
videoList,
|
||||
void(0)
|
||||
);
|
||||
expect(view.showError).toHaveBeenCalled();
|
||||
expect(view.render).not.toHaveBeenCalled();
|
||||
expect(Utils.Storage.set).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
264
cms/static/js_spec/transcripts/utils_spec.js
Normal file
264
cms/static/js_spec/transcripts/utils_spec.js
Normal file
@@ -0,0 +1,264 @@
|
||||
define(
|
||||
[
|
||||
"jquery", "underscore",
|
||||
"js/views/transcripts/utils",
|
||||
"underscore.string", "xmodule", "jasmine-jquery"
|
||||
],
|
||||
function ($, _, Utils, _str) {
|
||||
describe('Transcripts.Utils', function () {
|
||||
var videoId = 'OEoXaMPEzfM',
|
||||
ytLinksList = (function (id) {
|
||||
var links = [
|
||||
'http://www.youtube.com/watch?v=%s&feature=feedrec_grec_index',
|
||||
'http://www.youtube.com/user/IngridMichaelsonVEVO#p/a/u/1/%s',
|
||||
'http://www.youtube.com/v/%s?fs=1&hl=en_US&rel=0',
|
||||
'http://www.youtube.com/watch?v=%s#t=0m10s',
|
||||
'http://www.youtube.com/embed/%s?rel=0',
|
||||
'http://www.youtube.com/watch?v=%s',
|
||||
'http://youtu.be/%s'
|
||||
];
|
||||
|
||||
return $.map(links, function (link) {
|
||||
return _str.sprintf(link, id);
|
||||
});
|
||||
|
||||
} (videoId)),
|
||||
html5FileName = 'file_name',
|
||||
html5LinksList = (function (videoName) {
|
||||
var videoTypes = ['mp4', 'webm'],
|
||||
links = [
|
||||
'http://somelink.com/%s.%s?param=1¶m=2#hash',
|
||||
'http://somelink.com/%s.%s#hash',
|
||||
'http://somelink.com/%s.%s?param=1¶m=2',
|
||||
'http://somelink.com/%s.%s',
|
||||
'ftp://somelink.com/%s.%s',
|
||||
'https://somelink.com/%s.%s',
|
||||
'somelink.com/%s.%s',
|
||||
'%s.%s'
|
||||
],
|
||||
data = {};
|
||||
|
||||
$.each(videoTypes, function (index, type) {
|
||||
data[type] = $.map(links, function (link) {
|
||||
return _str.sprintf(link, videoName, type);
|
||||
});
|
||||
});
|
||||
|
||||
return data;
|
||||
|
||||
} (html5FileName));
|
||||
|
||||
describe('Method: getField', function (){
|
||||
var collection,
|
||||
testFieldName = 'test_field';
|
||||
|
||||
beforeEach(function() {
|
||||
collection = jasmine.createSpyObj(
|
||||
'Collection',
|
||||
[
|
||||
'findWhere'
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
it('All works okay if all arguments are passed', function () {
|
||||
Utils.getField(collection, testFieldName);
|
||||
|
||||
expect(collection.findWhere).toHaveBeenCalledWith({
|
||||
field_name: testFieldName
|
||||
});
|
||||
});
|
||||
|
||||
var wrongArgumentLists = [
|
||||
{
|
||||
argName: 'collection',
|
||||
list: [undefined, testFieldName]
|
||||
},
|
||||
{
|
||||
argName: 'field name',
|
||||
list: [collection, undefined]
|
||||
},
|
||||
{
|
||||
argName: 'both',
|
||||
list: [undefined, undefined]
|
||||
}
|
||||
];
|
||||
|
||||
$.each(wrongArgumentLists, function (index, element) {
|
||||
it(element.argName + ' argument(s) is/are absent', function () {
|
||||
var result = Utils.getField.apply(this, element.list);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Method: parseYoutubeLink', function () {
|
||||
describe('Supported urls', function () {
|
||||
$.each(ytLinksList, function (index, link) {
|
||||
it(link, function () {
|
||||
var result = Utils.parseYoutubeLink(link);
|
||||
|
||||
expect(result).toBe(videoId);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Wrong arguments ', function () {
|
||||
|
||||
beforeEach(function(){
|
||||
spyOn(console, 'log');
|
||||
});
|
||||
|
||||
it('no arguments', function () {
|
||||
var result = Utils.parseYoutubeLink();
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('wrong data type', function () {
|
||||
var result = Utils.parseYoutubeLink(1);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('videoId is wrong', function () {
|
||||
var videoId = 'wrong_id',
|
||||
link = 'http://youtu.be/' + videoId,
|
||||
result = Utils.parseYoutubeLink(link);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
var wrongUrls = [
|
||||
'http://youtu.bee/' + videoId,
|
||||
'http://youtu.be/',
|
||||
'example.com',
|
||||
'http://google.com/somevideo.mp4'
|
||||
];
|
||||
|
||||
$.each(wrongUrls, function (index, link) {
|
||||
it(link, function () {
|
||||
var result = Utils.parseYoutubeLink(link);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Method: parseHTML5Link', function () {
|
||||
describe('Supported urls', function () {
|
||||
$.each(html5LinksList, function (format, linksList) {
|
||||
$.each(linksList, function (index, link) {
|
||||
it(link, function () {
|
||||
var result = Utils.parseHTML5Link(link);
|
||||
|
||||
expect(result).toEqual({
|
||||
video: html5FileName,
|
||||
type: format
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Wrong arguments ', function () {
|
||||
|
||||
beforeEach(function(){
|
||||
spyOn(console, 'log');
|
||||
});
|
||||
|
||||
it('no arguments', function () {
|
||||
var result = Utils.parseHTML5Link();
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('wrong data type', function () {
|
||||
var result = Utils.parseHTML5Link(1);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
var html5WrongUrls = [
|
||||
'http://youtu.bee/' + videoId,
|
||||
'http://youtu.be/',
|
||||
'example.com',
|
||||
'http://google.com/somevideo.mp1',
|
||||
'http://google.com/somevideomp4',
|
||||
'http://google.com/somevideo_mp4',
|
||||
'http://google.com/somevideo:mp4',
|
||||
'http://google.com/somevideo',
|
||||
'http://google.com/somevideo.webm_'
|
||||
];
|
||||
|
||||
$.each(html5WrongUrls, function (index, link) {
|
||||
it(link, function () {
|
||||
var result = Utils.parseHTML5Link(link);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('Method: getYoutubeLink', function () {
|
||||
var videoId = 'video_id',
|
||||
result = Utils.getYoutubeLink(videoId),
|
||||
expectedResult = 'http://youtu.be/' + videoId;
|
||||
|
||||
expect(result).toBe(expectedResult);
|
||||
});
|
||||
|
||||
describe('Method: parseLink', function () {
|
||||
var resultDataDict = {
|
||||
'html5': {
|
||||
link: html5LinksList['mp4'][0],
|
||||
resp: {
|
||||
mode: 'html5',
|
||||
video: html5FileName,
|
||||
type: 'mp4'
|
||||
}
|
||||
},
|
||||
'youtube': {
|
||||
link: ytLinksList[0],
|
||||
resp: {
|
||||
mode: 'youtube',
|
||||
video: videoId,
|
||||
type: 'youtube'
|
||||
}
|
||||
},
|
||||
'incorrect': {
|
||||
link: 'http://example.com',
|
||||
resp: {
|
||||
mode: 'incorrect'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
$.each(resultDataDict, function (mode, data) {
|
||||
it(mode, function () {
|
||||
var result = Utils.parseLink(data.link);
|
||||
|
||||
expect(result).toEqual(data.resp);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Wrong arguments ', function () {
|
||||
|
||||
it('no arguments', function () {
|
||||
var result = Utils.parseLink();
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('wrong data type', function () {
|
||||
var result = Utils.parseLink(1);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
554
cms/static/js_spec/transcripts/videolist_spec.js
Normal file
554
cms/static/js_spec/transcripts/videolist_spec.js
Normal file
@@ -0,0 +1,554 @@
|
||||
define(
|
||||
[
|
||||
"jquery", "underscore",
|
||||
"js/views/transcripts/utils", "js/views/transcripts/metadata_videolist",
|
||||
"js/views/transcripts/message_manager",
|
||||
"js/views/metadata", "js/models/metadata", "js/views/abstract_editor",
|
||||
"sinon", "xmodule", "jasmine-jquery"
|
||||
],
|
||||
function ($, _, Utils, VideoList, MessageManager, MetadataView, MetadataModel, AbstractEditor, sinon) {
|
||||
describe('CMS.Views.Metadata.VideoList', function () {
|
||||
var videoListEntryTemplate = readFixtures(
|
||||
'transcripts/metadata-videolist-entry.underscore'
|
||||
),
|
||||
correctMessanger = MessageManager,
|
||||
messenger = correctMessanger.prototype,
|
||||
abstractEditor = AbstractEditor.prototype,
|
||||
component_id = 'component_id',
|
||||
videoList = [
|
||||
{
|
||||
mode: "youtube",
|
||||
type: "youtube",
|
||||
video: "12345678901"
|
||||
},
|
||||
{
|
||||
mode: "html5",
|
||||
type: "mp4",
|
||||
video: "video"
|
||||
},
|
||||
{
|
||||
mode: "html5",
|
||||
type: "webm",
|
||||
video: "video"
|
||||
}
|
||||
],
|
||||
modelStub = {
|
||||
default_value: ['a thing', 'another thing'],
|
||||
display_name: 'Video URL',
|
||||
explicitly_set: true,
|
||||
field_name: 'video_url',
|
||||
help: 'A list of things.',
|
||||
options: [],
|
||||
type: MetadataModel.VIDEO_LIST_TYPE,
|
||||
value: [
|
||||
'http://youtu.be/12345678901',
|
||||
'video.mp4',
|
||||
'video.webm'
|
||||
]
|
||||
},
|
||||
response = JSON.stringify({
|
||||
command: 'found',
|
||||
status: 'Success',
|
||||
subs: 'video_id'
|
||||
}),
|
||||
view, sinonXhr;
|
||||
|
||||
beforeEach(function () {
|
||||
sinonXhr = sinon.fakeServer.create();
|
||||
sinonXhr.respondWith([
|
||||
200,
|
||||
{ "Content-Type": "application/json"},
|
||||
response
|
||||
]);
|
||||
sinonXhr.autoRespond = true;
|
||||
|
||||
var tpl = sandbox({
|
||||
'class': 'component',
|
||||
'data-id': component_id
|
||||
}),
|
||||
model = new MetadataModel(modelStub),
|
||||
videoList, $el;
|
||||
|
||||
setFixtures(tpl);
|
||||
|
||||
appendSetFixtures(
|
||||
$("<script>",
|
||||
{
|
||||
id: "metadata-videolist-entry",
|
||||
type: "text/template"
|
||||
}
|
||||
).text(videoListEntryTemplate)
|
||||
);
|
||||
|
||||
spyOn(messenger, 'initialize');
|
||||
spyOn(messenger, 'render').andReturn(messenger);
|
||||
spyOn(messenger, 'showError');
|
||||
spyOn(messenger, 'hideError');
|
||||
spyOn(Utils, 'command').andCallThrough();
|
||||
spyOn(abstractEditor, 'initialize').andCallThrough();
|
||||
spyOn(abstractEditor, 'render').andCallThrough();
|
||||
|
||||
MessageManager = function () {
|
||||
messenger.initialize();
|
||||
|
||||
return messenger;
|
||||
};
|
||||
|
||||
$el = $('.component');
|
||||
|
||||
spyOn(console, 'error');
|
||||
|
||||
view = new VideoList({
|
||||
el: $el,
|
||||
model: model
|
||||
});
|
||||
|
||||
this.addMatchers({
|
||||
assertValueInView: function(expected) {
|
||||
var actualValue = this.actual.getValueFromEditor();
|
||||
return this.env.equals_(actualValue, expected);
|
||||
},
|
||||
assertCanUpdateView: function (expected) {
|
||||
var actual = this.actual,
|
||||
actualValue;
|
||||
|
||||
actual.setValueInEditor(expected);
|
||||
actualValue = actual.getValueFromEditor();
|
||||
|
||||
return this.env.equals_(actualValue, expected);
|
||||
},
|
||||
assertIsCorrectVideoList: function (expected) {
|
||||
var actualValue = this.actual.getVideoObjectsList();
|
||||
|
||||
return this.env.equals_(actualValue, expected);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
MessageManager = correctMessanger;
|
||||
sinonXhr.restore();
|
||||
});
|
||||
|
||||
|
||||
var waitsForResponse = function (expectFunc, prep) {
|
||||
var flag = false;
|
||||
|
||||
if (prep) {
|
||||
runs(prep);
|
||||
}
|
||||
|
||||
waitsFor(function() {
|
||||
var req = sinonXhr.requests,
|
||||
len = req.length;
|
||||
|
||||
if (len && req[0].readyState === 4) {
|
||||
flag = true;
|
||||
}
|
||||
return flag;
|
||||
}, "Ajax Timeout", 750);
|
||||
|
||||
runs(expectFunc);
|
||||
};
|
||||
|
||||
|
||||
it('Initialize', function () {
|
||||
expect(abstractEditor.initialize).toHaveBeenCalled();
|
||||
expect(messenger.initialize).toHaveBeenCalled();
|
||||
expect(view.component_id).toBe(component_id);
|
||||
expect(view.$el).toHandle('input');
|
||||
});
|
||||
|
||||
describe('Render', function () {
|
||||
var assertToHaveBeenRendered = function (videoList) {
|
||||
expect(abstractEditor.render).toHaveBeenCalled();
|
||||
expect(Utils.command).toHaveBeenCalledWith(
|
||||
'check',
|
||||
component_id,
|
||||
videoList
|
||||
);
|
||||
|
||||
expect(messenger.render).toHaveBeenCalled();
|
||||
},
|
||||
resetSpies = function() {
|
||||
abstractEditor.render.reset();
|
||||
Utils.command.reset();
|
||||
messenger.render.reset();
|
||||
sinonXhr.requests.length = 0;
|
||||
};
|
||||
|
||||
it('is rendered in correct way', function () {
|
||||
waitsForResponse(function () {
|
||||
assertToHaveBeenRendered(videoList);
|
||||
});
|
||||
});
|
||||
|
||||
it('is rendered with opened extra videos bar', function () {
|
||||
var videoListLength = [
|
||||
{
|
||||
mode: "youtube",
|
||||
type: "youtube",
|
||||
video: "12345678901"
|
||||
},
|
||||
{
|
||||
mode: "html5",
|
||||
type: "mp4",
|
||||
video: "video"
|
||||
}
|
||||
],
|
||||
videoListHtml5mode = [
|
||||
{
|
||||
mode: "html5",
|
||||
type: "mp4",
|
||||
video: "video"
|
||||
}
|
||||
];
|
||||
|
||||
spyOn(view, 'getVideoObjectsList').andReturn(videoListLength);
|
||||
spyOn(view, 'openExtraVideosBar');
|
||||
|
||||
waitsForResponse(
|
||||
function () {
|
||||
assertToHaveBeenRendered(videoListLength);
|
||||
view.getVideoObjectsList.andReturn(videoListLength);
|
||||
expect(view.openExtraVideosBar).toHaveBeenCalled();
|
||||
},
|
||||
function () {
|
||||
resetSpies();
|
||||
view.render();
|
||||
}
|
||||
);
|
||||
|
||||
waitsForResponse(
|
||||
function () {
|
||||
assertToHaveBeenRendered(videoListHtml5mode);
|
||||
expect(view.openExtraVideosBar).toHaveBeenCalled();
|
||||
},
|
||||
function () {
|
||||
resetSpies();
|
||||
view.openExtraVideosBar.reset();
|
||||
view.getVideoObjectsList.andReturn(videoListHtml5mode);
|
||||
view.render();
|
||||
}
|
||||
);
|
||||
|
||||
});
|
||||
|
||||
it('is rendered without opened extra videos bar', function () {
|
||||
var videoList = [
|
||||
{
|
||||
mode: "youtube",
|
||||
type: "youtube",
|
||||
video: "12345678901"
|
||||
}
|
||||
];
|
||||
|
||||
spyOn(view, 'getVideoObjectsList').andReturn(videoList);
|
||||
spyOn(view, 'closeExtraVideosBar');
|
||||
|
||||
waitsForResponse(
|
||||
function () {
|
||||
assertToHaveBeenRendered(videoList);
|
||||
expect(view.closeExtraVideosBar).toHaveBeenCalled();
|
||||
},
|
||||
function () {
|
||||
resetSpies();
|
||||
view.render();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('isUniqVideoTypes', function () {
|
||||
|
||||
it('Unique data - return true', function () {
|
||||
var data = videoList,
|
||||
result = view.isUniqVideoTypes(data);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('Not Unique data - return false', function () {
|
||||
var data = [
|
||||
{
|
||||
mode: "html5",
|
||||
type: "mp4",
|
||||
video: "video"
|
||||
},
|
||||
{
|
||||
mode: "html5",
|
||||
type: "mp4",
|
||||
video: "video"
|
||||
},
|
||||
{
|
||||
mode: "youtube",
|
||||
type: "youtube",
|
||||
video: "12345678901"
|
||||
}
|
||||
],
|
||||
result = view.isUniqVideoTypes(data);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkIsUniqVideoTypes', function () {
|
||||
|
||||
it('Error is shown', function () {
|
||||
var data = [
|
||||
{
|
||||
mode: "html5",
|
||||
type: "mp4",
|
||||
video: "video"
|
||||
},
|
||||
{
|
||||
mode: "html5",
|
||||
type: "mp4",
|
||||
video: "video"
|
||||
},
|
||||
{
|
||||
mode: "youtube",
|
||||
type: "youtube",
|
||||
video: "12345678901"
|
||||
}
|
||||
],
|
||||
result = view.checkIsUniqVideoTypes(data);
|
||||
|
||||
expect(messenger.showError).toHaveBeenCalled();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('All works okay if arguments are not passed', function () {
|
||||
spyOn(view, 'getVideoObjectsList').andReturn(videoList);
|
||||
var result = view.checkIsUniqVideoTypes();
|
||||
|
||||
expect(view.getVideoObjectsList).toHaveBeenCalled();
|
||||
expect(messenger.showError).not.toHaveBeenCalled();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkValidity', function () {
|
||||
beforeEach(function () {
|
||||
spyOn(view, 'checkIsUniqVideoTypes').andReturn(true);
|
||||
});
|
||||
|
||||
it('Error message are shown', function () {
|
||||
var data = { mode: 'incorrect' },
|
||||
result = view.checkValidity(data, true);
|
||||
|
||||
expect(messenger.showError).toHaveBeenCalled();
|
||||
expect(view.checkIsUniqVideoTypes).toHaveBeenCalled();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('Error message are shown when flag is not passed', function () {
|
||||
var data = { mode: 'incorrect' },
|
||||
result = view.checkValidity(data);
|
||||
|
||||
expect(messenger.showError).not.toHaveBeenCalled();
|
||||
expect(view.checkIsUniqVideoTypes).toHaveBeenCalled();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('All works okay if correct data is passed', function () {
|
||||
var data = videoList,
|
||||
result = view.checkValidity(data);
|
||||
|
||||
expect(messenger.showError).not.toHaveBeenCalled();
|
||||
expect(view.checkIsUniqVideoTypes).toHaveBeenCalled();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('openExtraVideosBar', function () {
|
||||
view.$extraVideosBar.removeClass('is-visible');
|
||||
|
||||
view.openExtraVideosBar();
|
||||
expect(view.$extraVideosBar).toHaveClass('is-visible');
|
||||
});
|
||||
|
||||
it('closeExtraVideosBar', function () {
|
||||
view.$extraVideosBar.addClass('is-visible');
|
||||
|
||||
view.closeExtraVideosBar();
|
||||
expect(view.$extraVideosBar).not.toHaveClass('is-visible');
|
||||
});
|
||||
|
||||
it('toggleExtraVideosBar', function () {
|
||||
view.$extraVideosBar.addClass('is-visible');
|
||||
view.toggleExtraVideosBar();
|
||||
expect(view.$extraVideosBar).not.toHaveClass('is-visible');
|
||||
view.toggleExtraVideosBar();
|
||||
expect(view.$extraVideosBar).toHaveClass('is-visible');
|
||||
});
|
||||
|
||||
it('getValueFromEditor', function () {
|
||||
expect(view).assertValueInView(modelStub.value);
|
||||
});
|
||||
|
||||
it('setValueInEditor', function () {
|
||||
expect(view).assertCanUpdateView(['abc.mp4']);
|
||||
});
|
||||
|
||||
it('getVideoObjectsList', function () {
|
||||
var value = [
|
||||
{
|
||||
mode: 'youtube',
|
||||
type: 'youtube',
|
||||
video: '12345678901'
|
||||
},
|
||||
{
|
||||
mode: 'html5',
|
||||
type: 'mp4',
|
||||
video: 'video'
|
||||
}
|
||||
];
|
||||
|
||||
view.setValueInEditor([
|
||||
'http://youtu.be/12345678901',
|
||||
'video.mp4',
|
||||
'video'
|
||||
]);
|
||||
expect(view).assertIsCorrectVideoList(value);
|
||||
});
|
||||
|
||||
describe('getPlaceholders', function () {
|
||||
var defaultPlaceholders;
|
||||
|
||||
beforeEach(function () {
|
||||
defaultPlaceholders = view.placeholders;
|
||||
});
|
||||
|
||||
it('All works okay if empty values are passed', function () {
|
||||
var result = view.getPlaceholders([]),
|
||||
expectedResult = _.values(defaultPlaceholders).reverse();
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
});
|
||||
|
||||
|
||||
it('On filling less than 3 fields, remaining fields should have ' +
|
||||
'placeholders for video types that were not filled yet',
|
||||
function () {
|
||||
var dataDict = {
|
||||
youtube: {
|
||||
value: [modelStub.value[0]],
|
||||
expectedResult: [
|
||||
defaultPlaceholders.youtube,
|
||||
defaultPlaceholders.mp4,
|
||||
defaultPlaceholders.webm
|
||||
]
|
||||
},
|
||||
mp4: {
|
||||
value: [modelStub.value[1]],
|
||||
expectedResult: [
|
||||
defaultPlaceholders.mp4,
|
||||
defaultPlaceholders.youtube,
|
||||
defaultPlaceholders.webm
|
||||
]
|
||||
},
|
||||
webm: {
|
||||
value: [modelStub.value[2]],
|
||||
expectedResult: [
|
||||
defaultPlaceholders.webm,
|
||||
defaultPlaceholders.youtube,
|
||||
defaultPlaceholders.mp4
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
$.each(dataDict, function(index, val) {
|
||||
var result = view.getPlaceholders(val.value);
|
||||
|
||||
expect(result).toEqual(val.expectedResult);
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('inputHandler', function () {
|
||||
var eventObject;
|
||||
|
||||
var resetSpies = function () {
|
||||
messenger.hideError.reset();
|
||||
view.updateModel.reset();
|
||||
view.closeExtraVideosBar.reset();
|
||||
};
|
||||
|
||||
beforeEach(function () {
|
||||
eventObject = jQuery.Event('input');
|
||||
|
||||
spyOn(view, 'updateModel');
|
||||
spyOn(view, 'closeExtraVideosBar');
|
||||
spyOn(view, 'checkValidity');
|
||||
spyOn($.fn, 'hasClass');
|
||||
spyOn($.fn, 'addClass');
|
||||
spyOn($.fn, 'removeClass');
|
||||
spyOn($.fn, 'prop').andCallThrough();
|
||||
spyOn(_, 'isEqual');
|
||||
|
||||
resetSpies();
|
||||
});
|
||||
|
||||
it('Field has invalid value - nothing should happen',
|
||||
function () {
|
||||
$.fn.hasClass.andReturn(false);
|
||||
view.checkValidity.andReturn(false);
|
||||
view.inputHandler(eventObject);
|
||||
|
||||
expect(messenger.hideError).not.toHaveBeenCalled();
|
||||
expect(view.updateModel).not.toHaveBeenCalled();
|
||||
expect(view.closeExtraVideosBar).not.toHaveBeenCalled();
|
||||
expect($.fn.prop).toHaveBeenCalledWith('disabled', true);
|
||||
expect($.fn.addClass).toHaveBeenCalledWith('is-disabled');
|
||||
}
|
||||
);
|
||||
|
||||
it('Main field has invalid value - extra Videos Bar should be closed',
|
||||
function () {
|
||||
$.fn.hasClass.andReturn(true);
|
||||
view.checkValidity.andReturn(false);
|
||||
view.inputHandler(eventObject);
|
||||
|
||||
expect(messenger.hideError).not.toHaveBeenCalled();
|
||||
expect(view.updateModel).not.toHaveBeenCalled();
|
||||
expect(view.closeExtraVideosBar).toHaveBeenCalled();
|
||||
expect($.fn.prop).toHaveBeenCalledWith('disabled', true);
|
||||
expect($.fn.addClass).toHaveBeenCalledWith('is-disabled');
|
||||
}
|
||||
);
|
||||
|
||||
it('Model is updated if value is valid',
|
||||
function () {
|
||||
view.checkValidity.andReturn(true);
|
||||
_.isEqual.andReturn(false);
|
||||
view.inputHandler(eventObject);
|
||||
|
||||
expect(messenger.hideError).not.toHaveBeenCalled();
|
||||
expect(view.updateModel).toHaveBeenCalled();
|
||||
expect(view.closeExtraVideosBar).not.toHaveBeenCalled();
|
||||
expect($.fn.prop).toHaveBeenCalledWith('disabled', false);
|
||||
expect($.fn.removeClass).toHaveBeenCalledWith('is-disabled');
|
||||
}
|
||||
);
|
||||
|
||||
it('Corner case: Error is hided',
|
||||
function () {
|
||||
view.checkValidity.andReturn(true);
|
||||
_.isEqual.andReturn(true);
|
||||
view.inputHandler(eventObject);
|
||||
|
||||
expect(messenger.hideError).toHaveBeenCalled();
|
||||
expect(view.updateModel).not.toHaveBeenCalled();
|
||||
expect(view.closeExtraVideosBar).not.toHaveBeenCalled();
|
||||
expect($.fn.prop).toHaveBeenCalledWith('disabled', false);
|
||||
expect($.fn.removeClass).toHaveBeenCalledWith('is-disabled');
|
||||
}
|
||||
);
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
@@ -40,6 +40,7 @@ lib_paths:
|
||||
- xmodule_js/common_static/js/vendor/backbone-associations-min.js
|
||||
- xmodule_js/common_static/js/vendor/timepicker/jquery.timepicker.js
|
||||
- xmodule_js/common_static/js/vendor/jquery.leanModal.min.js
|
||||
- xmodule_js/common_static/js/vendor/jquery.ajaxQueue.js
|
||||
- xmodule_js/common_static/js/vendor/jquery.form.js
|
||||
- xmodule_js/common_static/js/vendor/html5-input-polyfills/number-polyfill.js
|
||||
- xmodule_js/common_static/js/vendor/sinon-1.7.1.js
|
||||
@@ -66,6 +67,7 @@ src_paths:
|
||||
spec_paths:
|
||||
- coffee/spec/main.js
|
||||
- coffee/spec
|
||||
- js_spec
|
||||
|
||||
# Paths to fixture files (optional)
|
||||
# The fixture path will be set automatically when using jasmine-jquery.
|
||||
|
||||
@@ -13,3 +13,192 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.xmodule_VideoDescriptor {
|
||||
.wrapper-comp-settings.basic_metadata_edit{
|
||||
.list-input.settings-list {
|
||||
.field.comp-setting-entry {
|
||||
|
||||
.setting-label {
|
||||
vertical-align: top;
|
||||
margin-top: ($baseline/2);
|
||||
}
|
||||
|
||||
.setting-help{
|
||||
display: block;
|
||||
width: 45%;
|
||||
max-width: auto;
|
||||
margin-left: 33%;
|
||||
padding: 0 13px;
|
||||
}
|
||||
|
||||
.collapse-setting {
|
||||
@extend %t-action3;
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: ($baseline/2);
|
||||
font-weight: 600;
|
||||
|
||||
*[class^="icon-"] {
|
||||
margin-right: ($baseline/4);
|
||||
}
|
||||
}
|
||||
.videolist-url-tip.setting-help,
|
||||
.videolist-extra-videos-tip.setting-help{
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
padding: 0 10px 10px;
|
||||
}
|
||||
|
||||
.videolist-url-tip.setting-help{
|
||||
padding: 0 0 10px;
|
||||
}
|
||||
|
||||
.wrapper-comp-setting{
|
||||
width: 100%;
|
||||
display: block;
|
||||
max-width: auto;
|
||||
}
|
||||
|
||||
// inputs and labels
|
||||
.wrapper-videolist-settings {
|
||||
width: 45%;
|
||||
display: inline-block;
|
||||
min-width: ($baseline*5);
|
||||
|
||||
|
||||
// inputs
|
||||
.input {
|
||||
width: 100%;
|
||||
vertical-align: middle;
|
||||
|
||||
&.is-disabled,
|
||||
&[disabled="disabled"]{
|
||||
opacity: .5;
|
||||
}
|
||||
}
|
||||
|
||||
.wrapper-videolist-url{
|
||||
margin-bottom: ($baseline/2);
|
||||
}
|
||||
|
||||
.wrapper-videolist-urls{
|
||||
background: $lightGrey;
|
||||
padding: ($baseline/3);
|
||||
|
||||
// enumerated fields
|
||||
.videolist-extra-videos {
|
||||
display: none;
|
||||
|
||||
&.is-visible{
|
||||
display: block;
|
||||
}
|
||||
|
||||
.videolist-settings-item {
|
||||
margin-bottom: ($baseline/2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.transcripts-status{
|
||||
margin-top: $baseline;
|
||||
|
||||
&.is-invisible{
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.wrapper-transcripts-message{
|
||||
width: 60%;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
min-width: ($baseline*5);
|
||||
margin-top: 10px;
|
||||
|
||||
.transcripts-message{
|
||||
@include font-size(12);
|
||||
}
|
||||
|
||||
.transcripts-message-status{
|
||||
color: $green;
|
||||
font-weight: 700;
|
||||
|
||||
&.status-error{
|
||||
color: $red;
|
||||
}
|
||||
|
||||
[class^="icon-"],
|
||||
[class*=" icon-"]{
|
||||
margin-right: 5px;
|
||||
@include font-size(18);
|
||||
}
|
||||
}
|
||||
|
||||
.transcripts-error-message{
|
||||
background: $red;
|
||||
color: $white;
|
||||
@include font-size(14);
|
||||
padding: ($baseline/3);
|
||||
|
||||
&.is-invisible{
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.wrapper-transcripts-buttons{
|
||||
|
||||
&.is-invisible{
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
.action{
|
||||
@extend %btn-primary-blue;
|
||||
@extend %t-action3;
|
||||
margin-bottom: ($baseline/2);
|
||||
}
|
||||
}
|
||||
|
||||
// TYPE: enumerated video lists of metadata sets
|
||||
.metadata-videolist-enum {
|
||||
* {
|
||||
@include box-sizing(border-box);
|
||||
}
|
||||
}
|
||||
|
||||
.file-chooser{
|
||||
display: none;
|
||||
}
|
||||
|
||||
.progress-bar{
|
||||
display: block;
|
||||
height: 30px;
|
||||
margin: 10px 0;
|
||||
border: 1px solid $blue;
|
||||
text-align: center;
|
||||
font-size: 1.14em;
|
||||
|
||||
&.is-invisible {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.loaded {
|
||||
border-color: #66b93d;
|
||||
|
||||
.progress-fill {
|
||||
background: #66b93d;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
display: block;
|
||||
width: 0%;
|
||||
height: 30px;
|
||||
background: $blue;
|
||||
color: #fff;
|
||||
line-height: 28px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ var require = {
|
||||
"jquery.form": "js/vendor/jquery.form",
|
||||
"jquery.markitup": "js/vendor/markitup/jquery.markitup",
|
||||
"jquery.leanModal": "js/vendor/jquery.leanModal.min",
|
||||
"jquery.ajaxQueue": "js/vendor/jquery.ajaxQueue",
|
||||
"jquery.smoothScroll": "js/vendor/jquery.smooth-scroll.min",
|
||||
"jquery.timepicker": "js/vendor/timepicker/jquery.timepicker",
|
||||
"jquery.cookie": "js/vendor/jquery.cookie",
|
||||
@@ -100,6 +101,10 @@ var require = {
|
||||
deps: ["jquery"],
|
||||
exports: "jQuery.fn.leanModal"
|
||||
},
|
||||
"jquery.ajaxQueue": {
|
||||
deps: ["jquery"],
|
||||
exports: "jQuery.fn.ajaxQueue"
|
||||
},
|
||||
"jquery.smoothScroll": {
|
||||
deps: ["jquery"],
|
||||
exports: "jQuery.fn.smoothScroll"
|
||||
@@ -210,6 +215,7 @@ var require = {
|
||||
<%static:include path="js/system-feedback.underscore" />
|
||||
</script>
|
||||
|
||||
|
||||
% if context_course:
|
||||
<script type="text/javascript">
|
||||
require(['js/models/course'], function(Course) {
|
||||
|
||||
10
cms/templates/js/transcripts/file-upload.underscore
Normal file
10
cms/templates/js/transcripts/file-upload.underscore
Normal file
@@ -0,0 +1,10 @@
|
||||
<div class="progress-bar is-invisible">
|
||||
<div class="progress-fill"></div>
|
||||
</div>
|
||||
<form class="file-chooser" action="/transcripts/upload"
|
||||
method="post" enctype="multipart/form-data">
|
||||
<input type="file" class="file-input" name="file"
|
||||
accept="<%= _.map(ext, function(val){ return '.' + val; }).join(', ') %>">
|
||||
<input type="hidden" name="id" value="<%= component_id %>">
|
||||
<input type="hidden" name="video_list" value='<%= JSON.stringify(video_list) %>'>
|
||||
</form>
|
||||
@@ -0,0 +1,36 @@
|
||||
<div class="transcripts-message-status status-error">
|
||||
<i class="icon-remove"></i>
|
||||
<%= gettext("Timed Transcript Conflict") %>
|
||||
</div>
|
||||
|
||||
<p class="transcripts-message">
|
||||
<%= gettext("The timed transcript for the first HTML5 source does not appear to be the same as the timed transcript for the second HTML5 source.") %>
|
||||
<strong>
|
||||
<%= gettext("Which one would you like to use?") %>
|
||||
</strong>
|
||||
</p>
|
||||
|
||||
<p class="transcripts-error-message is-invisible">
|
||||
<%= gettext("Error.") %>
|
||||
</p>
|
||||
|
||||
<div class="wrapper-transcripts-buttons">
|
||||
<% _.each(html5_list, function(value, index) {
|
||||
var type = grouped_list[value][0].type,
|
||||
file_name = value + ((type) ? ('.' + type) : ''),
|
||||
message = gettext("Timed Transcript from ") + file_name;
|
||||
%>
|
||||
<button
|
||||
class="action setting-choose"
|
||||
type="button"
|
||||
name="setting-choose"
|
||||
data-video-id="<%= value %>"
|
||||
value="<%= message %>"
|
||||
data-tooltip="<%= message %>"
|
||||
>
|
||||
<span>
|
||||
<%= message %>
|
||||
</span>
|
||||
</button>
|
||||
<% }) %>
|
||||
</div>
|
||||
@@ -0,0 +1,16 @@
|
||||
<div class="transcripts-message-status"><i class="icon-ok"></i><%= gettext("Timed Transcript Found") %></div>
|
||||
<p class="transcripts-message">
|
||||
<%= gettext("We have a timed transcript on edX for this video. You can upload a new .srt file to replace it or download to edit.") %>
|
||||
</p>
|
||||
<div class="transcripts-file-uploader"></div>
|
||||
<p class="transcripts-error-message is-invisible">
|
||||
<%= gettext("Error.") %>
|
||||
</p>
|
||||
<div class="wrapper-transcripts-buttons">
|
||||
<button class="action setting-upload" type="button" name="setting-upload" value="<%= gettext("Upload New Timed Transcript") %>" data-tooltip="<%= gettext("Upload New Timed Transcript") %>">
|
||||
<span><%= gettext("Upload New Timed Transcript") %></span>
|
||||
</button>
|
||||
<a class="action setting-download" href="/transcripts/download?id=<%= component_id %>&subs_id=<%= subs_id %>" data-tooltip="<%= gettext("Download to Edit") %>">
|
||||
<span><%= gettext("Download to Edit") %></span>
|
||||
</a>
|
||||
</div>
|
||||
@@ -0,0 +1,16 @@
|
||||
<div class="transcripts-message-status status-error"><i class="icon-remove"></i><%= gettext("No Timed Transcript") %></div>
|
||||
<p class="transcripts-message">
|
||||
<%= gettext("We don\'t have a timed transcript for this video on edX, but we found a transcript for this video on YouTube. Would you like to import it to edX?") %>
|
||||
</p>
|
||||
<div class="transcripts-file-uploader"></div>
|
||||
<p class="transcripts-error-message is-invisible">
|
||||
<%= gettext("Error.") %>
|
||||
</p>
|
||||
<div class="wrapper-transcripts-buttons">
|
||||
<button class="action setting-upload" type="button" name="setting-upload" value="<%= gettext("Upload New Timed Transcript") %>" data-tooltip="<%= gettext("Upload New Timed Transcript") %>">
|
||||
<span><%= gettext("Upload New Timed Transcript") %></span>
|
||||
</button>
|
||||
<button class="action setting-import" type="button" name="setting-import" value="<%= gettext("Import from YouTube") %>" data-tooltip="<%= gettext("Import from YouTube") %>">
|
||||
<span><%= gettext("Import from YouTube") %></span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -0,0 +1,16 @@
|
||||
<div class="transcripts-message-status status-error"><i class="icon-remove"></i><%= gettext("No Timed Transcript") %></div>
|
||||
<p class="transcripts-message">
|
||||
<%= gettext("We don\'t have a timed transcript for this video. Please upload a .srt file:") %>
|
||||
</p>
|
||||
<div class="transcripts-file-uploader"></div>
|
||||
<p class="transcripts-error-message is-invisible">
|
||||
<%= gettext("Error.") %>
|
||||
</p>
|
||||
<div class="wrapper-transcripts-buttons">
|
||||
<button class="action setting-upload" type="button" name="setting-upload" value="<%= gettext("Upload New Timed Transcript") %>" data-tooltip="<%= gettext("Upload New Timed Transcript") %>">
|
||||
<%= gettext("Upload New Timed Transcript") %>
|
||||
</button>
|
||||
<a class="action setting-download is-disabled" href="javascropt: void(0);" data-tooltip="<%= gettext("Download to Edit") %>">
|
||||
<%= gettext("Download to Edit") %>
|
||||
</a>
|
||||
</div>
|
||||
@@ -0,0 +1,29 @@
|
||||
<div class="transcripts-message-status status-error">
|
||||
<i class="icon-remove"></i>
|
||||
<%= gettext("Timed Transcript Conflict") %>
|
||||
</div>
|
||||
|
||||
<p class="transcripts-message">
|
||||
<%= gettext("The timed transcript file on YouTube does not appear to be the same as the timed transcript file on edX.") %>
|
||||
<strong>
|
||||
<%= gettext("Would you like to replace the edX timed transcript with the ones from YouTube?") %>
|
||||
</strong>
|
||||
</p>
|
||||
|
||||
<p class="transcripts-error-message is-invisible">
|
||||
<%= gettext("Error.") %>
|
||||
</p>
|
||||
|
||||
<div class="wrapper-transcripts-buttons">
|
||||
<button
|
||||
class="action setting-replace"
|
||||
type="button"
|
||||
name="setting-replace"
|
||||
value="<%= gettext("Yes, Replace EdX Timed Transcript with YouTube Timed Transcript") %>"
|
||||
data-tooltip="<%= gettext("Yes, Replace EdX Timed Transcript with YouTube Timed Transcript") %>"
|
||||
>
|
||||
<span>
|
||||
<%= gettext("Yes, Replace EdX Timed Transcript with YouTube Timed Transcript") %>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -0,0 +1,16 @@
|
||||
<div class="transcripts-message-status"><i class="icon-ok"></i><%= gettext("Timed Transcript uploaded successfully") %></div>
|
||||
<p class="transcripts-message">
|
||||
<%= gettext("We have a timed transcript on edX for this video. You can upload a new .srt file to replace it or download to edit.") %>
|
||||
</p>
|
||||
<div class="transcripts-file-uploader"></div>
|
||||
<p class="transcripts-error-message is-invisible">
|
||||
<%= gettext("Error.") %>
|
||||
</p>
|
||||
<div class="wrapper-transcripts-buttons">
|
||||
<button class="action setting-upload" type="button" name="setting-upload" value="<%= gettext("Upload New Timed Transcript") %>" data-tooltip="<%= gettext("Upload New Timed Transcript") %>">
|
||||
<span><%= gettext("Upload New Timed Transcript") %></span>
|
||||
</button>
|
||||
<a class="action setting-download" href="/transcripts/download?id=<%= component_id %>" data-tooltip="<%= gettext("Download to Edit") %>">
|
||||
<span><%= gettext("Download to Edit") %></span>
|
||||
</a>
|
||||
</div>
|
||||
@@ -0,0 +1,39 @@
|
||||
<div class="transcripts-message-status status-error">
|
||||
<i class="icon-remove"></i>
|
||||
<%= gettext("Timed Transcript Not Updated") %>
|
||||
</div>
|
||||
|
||||
<p class="transcripts-message">
|
||||
<%= gettext("You changed a video source, but did not update the timed transcript file. Do you want to upload new timed transcript?") %>
|
||||
</p>
|
||||
|
||||
<div class="transcripts-file-uploader"></div>
|
||||
|
||||
<p class="transcripts-error-message is-invisible">
|
||||
<%= gettext("Error.") %>
|
||||
</p>
|
||||
|
||||
<div class="wrapper-transcripts-buttons">
|
||||
<button
|
||||
class="action setting-use-existing"
|
||||
type="button"
|
||||
name="setting-use-existing"
|
||||
value="<%= gettext("Use Existing Timed Transcript") %>"
|
||||
data-tooltip="<%= gettext("Use Existing Timed Transcript") %>"
|
||||
>
|
||||
<span>
|
||||
<%= gettext("Use Existing Timed Transcript") %>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="action setting-upload"
|
||||
type="button"
|
||||
name="setting-upload"
|
||||
value="<%= gettext("Upload New Timed Transcript") %>"
|
||||
data-tooltip="<%= gettext("Upload New Timed Transcript") %>"
|
||||
>
|
||||
<span>
|
||||
<%= gettext("Upload New Timed Transcript") %>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -0,0 +1,27 @@
|
||||
<div class="wrapper-comp-setting metadata-videolist-enum">
|
||||
<label class="label setting-label" for="<%= uniqueId %>"><%= model.get('display_name')%></label>
|
||||
<div class="wrapper-videolist-settings">
|
||||
<div class="wrapper-videolist-url videolist-settings-item"><input type="text" id="<%= uniqueId %>" class="input videolist-url" value="<%= model.get('value')[0] %>"></div>
|
||||
<div class="tip videolist-url-tip setting-help"><%= model.get('help') %></div>
|
||||
<div class="wrapper-videolist-urls">
|
||||
<a href="#" class="collapse-action collapse-setting">
|
||||
<i class="icon-plus"></i><%= gettext("Add more video sources") %> <span class="sr"><%= model.get('display_name')%></span>
|
||||
</a>
|
||||
<div class="videolist-extra-videos">
|
||||
<span class="tip videolist-extra-videos-tip setting-help"><%= gettext('To be sure all students can view the video, we recommend providing alternate versions of the same video: mp4, webm and youtube (if available).') %></span>
|
||||
<ol class="videolist-settings">
|
||||
<li class="videolist-settings-item">
|
||||
<input type="text" class="input" value="<%= model.get('value')[1] %>">
|
||||
</li>
|
||||
<li class="videolist-settings-item">
|
||||
<input type="text" class="input" value="<%= model.get('value')[2] %>">
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="transcripts-status is-invisible">
|
||||
<label class="label setting-label transcripts-label"><%= gettext("Timed Transcript") %></label>
|
||||
<div class="wrapper-transcripts-message"></div>
|
||||
</div>
|
||||
66
cms/templates/widgets/video/transcripts.html
Normal file
66
cms/templates/widgets/video/transcripts.html
Normal file
@@ -0,0 +1,66 @@
|
||||
<%! from django.utils.translation import ugettext as _ %>
|
||||
<%namespace name='static' file='../../static_content.html'/>
|
||||
<%page args="tabName"/>
|
||||
|
||||
<%
|
||||
import json
|
||||
%>
|
||||
|
||||
## include js templates:
|
||||
|
||||
% for template_name in ["metadata-videolist-entry", "file-upload"]:
|
||||
<script type="text/template" id="${template_name}">
|
||||
<%static:include path="js/transcripts/${template_name}.underscore" />
|
||||
</script>
|
||||
% endfor
|
||||
|
||||
% for template_name in ["transcripts-found", "transcripts-uploaded", "transcripts-use-existing", "transcripts-not-found", "transcripts-replace", "transcripts-import", "transcripts-choose"]:
|
||||
<script type="text/template" id="${template_name}">
|
||||
<%static:include path="js/transcripts/messages/${template_name}.underscore" />
|
||||
</script>
|
||||
% endfor
|
||||
|
||||
<div class="wrapper-comp-settings basic_metadata_edit" data-metadata='${json.dumps(transcripts_basic_tab_metadata) | h}'></div>
|
||||
|
||||
<script type="text/javascript">
|
||||
require(
|
||||
[
|
||||
"domReady!",
|
||||
"jquery",
|
||||
"js/views/transcripts/editor"
|
||||
],
|
||||
|
||||
function(doc, $, Editor) {
|
||||
var transcripts = new Editor({
|
||||
el: $('#editor-tab-${html_id}').find('.basic_metadata_edit')
|
||||
}),
|
||||
storage = TabsEditingDescriptor.getStorage();
|
||||
|
||||
TabsEditingDescriptor.Model.addModelUpdate(
|
||||
'${html_id}',
|
||||
'${tabName}',
|
||||
function () {
|
||||
// Advanced, Save
|
||||
metadataEditor = storage.MetadataEditor;
|
||||
|
||||
if (metadataEditor) {
|
||||
transcripts.syncAdvancedTab(metadataEditor.collection, metadataEditor);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
TabsEditingDescriptor.Model.addOnSwitch(
|
||||
'${html_id}',
|
||||
'${tabName}',
|
||||
function () {
|
||||
// Basic
|
||||
metadataEditor = storage.MetadataEditor;
|
||||
|
||||
if (metadataEditor) {
|
||||
transcripts.syncBasicTab(metadataEditor.collection, metadataEditor);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
</script>
|
||||
@@ -21,6 +21,15 @@ urlpatterns = patterns('', # nopep8
|
||||
url(r'^save_item$', 'contentstore.views.save_item', name='save_item'),
|
||||
url(r'^delete_item$', 'contentstore.views.delete_item', name='delete_item'),
|
||||
url(r'^create_item$', 'contentstore.views.create_item', name='create_item'),
|
||||
|
||||
url(r'^transcripts/upload$', 'contentstore.views.upload_transcripts', name='upload_transcripts'),
|
||||
url(r'^transcripts/download$', 'contentstore.views.download_transcripts', name='download_transcripts'),
|
||||
url(r'^transcripts/check$', 'contentstore.views.check_transcripts', name='check_transcripts'),
|
||||
url(r'^transcripts/choose$', 'contentstore.views.choose_transcripts', name='choose_transcripts'),
|
||||
url(r'^transcripts/replace$', 'contentstore.views.replace_transcripts', name='replace_transcripts'),
|
||||
url(r'^transcripts/rename$', 'contentstore.views.rename_transcripts', name='rename_transcripts'),
|
||||
url(r'^transcripts/save$', 'contentstore.views.save_transcripts', name='save_transcripts'),
|
||||
|
||||
url(r'^create_draft$', 'contentstore.views.create_draft', name='create_draft'),
|
||||
url(r'^publish_draft$', 'contentstore.views.publish_draft', name='publish_draft'),
|
||||
url(r'^unpublish_unit$', 'contentstore.views.unpublish_unit', name='unpublish_unit'),
|
||||
|
||||
@@ -212,3 +212,11 @@ def i_answer_prompts_with(step, prompt):
|
||||
In addition, this method changes the functionality of ONLY future alerts
|
||||
"""
|
||||
world.browser.execute_script('window.prompt = function(){return %s;}') % prompt
|
||||
|
||||
|
||||
@step('I run ipdb')
|
||||
def run_ipdb(_step):
|
||||
"""Run ipdb as step for easy debugging"""
|
||||
import ipdb
|
||||
ipdb.set_trace()
|
||||
assert True
|
||||
|
||||
@@ -467,6 +467,11 @@ def click_link(partial_text, index=0):
|
||||
wait_for_js_to_load()
|
||||
|
||||
|
||||
@world.absorb
|
||||
def click_link_by_text(text, index=0):
|
||||
retry_on_exception(lambda: world.browser.find_link_by_text(text)[index].click())
|
||||
|
||||
|
||||
@world.absorb
|
||||
def css_text(css_selector, index=0, timeout=30):
|
||||
# Wait for the css selector to appear
|
||||
|
||||
3
common/lib/xmodule/xmodule/js/src/.gitignore
vendored
3
common/lib/xmodule/xmodule/js/src/.gitignore
vendored
@@ -5,4 +5,5 @@
|
||||
|
||||
|
||||
# Video are written in pure JavaScript.
|
||||
!video/*.js
|
||||
!video/*.js
|
||||
!video/transcripts/*.js
|
||||
@@ -65,6 +65,15 @@ class @TabsEditingDescriptor
|
||||
current_tab = @$tabs.filter('.current').html()
|
||||
data: TabsEditingDescriptor.Model.getValue(@html_id, current_tab)
|
||||
|
||||
setMetadataEditor : (metadataEditor) ->
|
||||
TabsEditingDescriptor.setMetadataEditor.apply(TabsEditingDescriptor, arguments)
|
||||
|
||||
getStorage : () ->
|
||||
TabsEditingDescriptor.getStorage()
|
||||
|
||||
addToStorage : (id, data) ->
|
||||
TabsEditingDescriptor.addToStorage.apply(TabsEditingDescriptor, arguments)
|
||||
|
||||
@Model :
|
||||
addModelUpdate : (id, tabName, modelUpdateFunction) ->
|
||||
###
|
||||
@@ -115,6 +124,7 @@ class @TabsEditingDescriptor
|
||||
# html_id's of descriptors will be stored in modules variable as
|
||||
# containers for callbacks.
|
||||
modules: {}
|
||||
Storage: {}
|
||||
|
||||
initialize : (id) ->
|
||||
###
|
||||
@@ -123,3 +133,13 @@ class @TabsEditingDescriptor
|
||||
@modules[id] = @modules[id] or {}
|
||||
@modules[id].tabSwitch = @modules[id]['tabSwitch'] or {}
|
||||
@modules[id].modelUpdate = @modules[id]['modelUpdate'] or {}
|
||||
|
||||
@setMetadataEditor : (metadataEditor) ->
|
||||
TabsEditingDescriptor.Model.Storage['MetadataEditor'] = metadataEditor
|
||||
|
||||
@addToStorage : (id, data) ->
|
||||
TabsEditingDescriptor.Model.Storage[id] = data
|
||||
|
||||
@getStorage : () ->
|
||||
TabsEditingDescriptor.Model.Storage
|
||||
|
||||
|
||||
@@ -141,9 +141,13 @@ class VideoDescriptorTest(unittest.TestCase):
|
||||
""""test get_context"""
|
||||
correct_tabs = [
|
||||
{
|
||||
'name': "Settings",
|
||||
'template': "tabs/metadata-edit-tab.html",
|
||||
'name': "Basic",
|
||||
'template': "video/transcripts.html",
|
||||
'current': True
|
||||
},
|
||||
{
|
||||
'name': 'Advanced',
|
||||
'template': 'tabs/metadata-edit-tab.html'
|
||||
}
|
||||
]
|
||||
rendered_context = self.descriptor.get_context()
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
from BaseHTTPServer import HTTPServer, BaseHTTPRequestHandler
|
||||
import urlparse
|
||||
import mock
|
||||
import threading
|
||||
import json
|
||||
from logging import getLogger
|
||||
logger = getLogger(__name__)
|
||||
import time
|
||||
|
||||
class MockYoutubeRequestHandler(BaseHTTPRequestHandler):
|
||||
'''
|
||||
A handler for Youtube GET requests.
|
||||
'''
|
||||
|
||||
protocol = "HTTP/1.0"
|
||||
|
||||
def do_HEAD(self):
|
||||
code = 200
|
||||
if 'test_transcripts_youtube' in self.path:
|
||||
if not 'trans_exist' in self.path:
|
||||
code = 404
|
||||
self._send_head(code)
|
||||
|
||||
def do_GET(self):
|
||||
'''
|
||||
Handle a GET request from the client and sends response back.
|
||||
'''
|
||||
logger.debug("Youtube provider received GET request to path {}".format(
|
||||
self.path)
|
||||
) # Log the request
|
||||
|
||||
if 'test_transcripts_youtube' in self.path:
|
||||
if 't__eq_exist' in self.path:
|
||||
status_message = """<?xml version="1.0" encoding="utf-8" ?><transcript><text start="1.0" dur="1.0">Equal transcripts</text></transcript>"""
|
||||
self._send_head()
|
||||
self._send_transcripts_response(status_message)
|
||||
elif 't_neq_exist' in self.path:
|
||||
status_message = """<?xml version="1.0" encoding="utf-8" ?><transcript><text start="1.1" dur="5.5">Transcripts sample, different that on server</text></transcript>"""
|
||||
self._send_head()
|
||||
self._send_transcripts_response(status_message)
|
||||
else:
|
||||
self._send_head(404)
|
||||
elif 'test_youtube' in self.path:
|
||||
self._send_head()
|
||||
#testing videoplayers
|
||||
status_message = "I'm youtube."
|
||||
response_timeout = float(self.server.time_to_response)
|
||||
|
||||
# threading timer produces TypeError: 'NoneType' object is not callable here
|
||||
# so we use time.sleep, as we already in separate thread.
|
||||
time.sleep(response_timeout)
|
||||
self._send_video_response(status_message)
|
||||
else:
|
||||
# unused url
|
||||
self._send_head()
|
||||
self._send_transcripts_response('Unused url')
|
||||
logger.debug("Request to unused url.")
|
||||
|
||||
def _send_head(self, code=200):
|
||||
'''
|
||||
Send the response code and MIME headers
|
||||
'''
|
||||
|
||||
self.send_response(code)
|
||||
self.send_header('Content-type', 'text/html')
|
||||
self.end_headers()
|
||||
|
||||
def _send_transcripts_response(self, message):
|
||||
'''
|
||||
Send message back to the client for transcripts ajax requests.
|
||||
'''
|
||||
response = message
|
||||
# Log the response
|
||||
logger.debug("Youtube: sent response {}".format(message))
|
||||
|
||||
self.wfile.write(response)
|
||||
|
||||
def _send_video_response(self, message):
|
||||
'''
|
||||
Send message back to the client for video player requests.
|
||||
Requires sending back callback id.
|
||||
'''
|
||||
callback = urlparse.parse_qs(self.path)['callback'][0]
|
||||
response = callback + '({})'.format(json.dumps({'message': message}))
|
||||
# Log the response
|
||||
logger.debug("Youtube: sent response {}".format(message))
|
||||
|
||||
self.wfile.write(response)
|
||||
|
||||
|
||||
class MockYoutubeServer(HTTPServer):
|
||||
'''
|
||||
A mock Youtube provider server that responds
|
||||
to GET requests to localhost.
|
||||
'''
|
||||
|
||||
def __init__(self, address):
|
||||
'''
|
||||
Initialize the mock XQueue server instance.
|
||||
|
||||
*address* is the (host, host's port to listen to) tuple.
|
||||
'''
|
||||
handler = MockYoutubeRequestHandler
|
||||
HTTPServer.__init__(self, address, handler)
|
||||
|
||||
def shutdown(self):
|
||||
'''
|
||||
Stop the server and free up the port
|
||||
'''
|
||||
# First call superclass shutdown()
|
||||
HTTPServer.shutdown(self)
|
||||
# We also need to manually close the socket
|
||||
self.socket.close()
|
||||
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
Test for Mock_Youtube_Server
|
||||
"""
|
||||
import unittest
|
||||
import threading
|
||||
import requests
|
||||
from mock_youtube_server import MockYoutubeServer
|
||||
|
||||
|
||||
class MockYoutubeServerTest(unittest.TestCase):
|
||||
'''
|
||||
A mock version of the YouTube provider server that listens on a local
|
||||
port and responds with jsonp.
|
||||
|
||||
Used for lettuce BDD tests in lms/courseware/features/video.feature
|
||||
'''
|
||||
|
||||
def setUp(self):
|
||||
|
||||
# Create the server
|
||||
server_port = 8034
|
||||
server_host = '127.0.0.1'
|
||||
address = (server_host, server_port)
|
||||
self.server = MockYoutubeServer(address, )
|
||||
self.server.time_to_response = 0.5
|
||||
# Start the server in a separate daemon thread
|
||||
server_thread = threading.Thread(target=self.server.serve_forever)
|
||||
server_thread.daemon = True
|
||||
server_thread.start()
|
||||
|
||||
def tearDown(self):
|
||||
|
||||
# Stop the server, freeing up the port
|
||||
self.server.shutdown()
|
||||
|
||||
def test_request(self):
|
||||
"""
|
||||
Tests that Youtube server processes request with right program
|
||||
path, and responses with incorrect signature.
|
||||
"""
|
||||
# GET request
|
||||
|
||||
# unused url
|
||||
response = requests.get(
|
||||
'http://127.0.0.1:8034/some url',
|
||||
)
|
||||
self.assertEqual("Unused url", response.content)
|
||||
|
||||
# video player test url, callback shoud be presented in url params
|
||||
response = requests.get(
|
||||
'http://127.0.0.1:8034/test_youtube/OEoXaMPEzfM?v=2&alt=jsonc&callback=callback_func',
|
||||
)
|
||||
self.assertEqual("""callback_func({"message": "I\'m youtube."})""", response.content)
|
||||
|
||||
# transcripts test url
|
||||
response = requests.get(
|
||||
'http://127.0.0.1:8034/test_transcripts_youtube/t__eq_exist',
|
||||
)
|
||||
self.assertEqual(
|
||||
'<?xml version="1.0" encoding="utf-8" ?><transcript><text start="1.0" dur="1.0">Equal transcripts</text></transcript>',
|
||||
response.content
|
||||
)
|
||||
|
||||
# transcripts test url
|
||||
response = requests.get(
|
||||
'http://127.0.0.1:8034/test_transcripts_youtube/t_neq_exist',
|
||||
)
|
||||
self.assertEqual(
|
||||
'<?xml version="1.0" encoding="utf-8" ?><transcript><text start="1.1" dur="5.5">Transcripts sample, different that on server</text></transcript>',
|
||||
response.content
|
||||
)
|
||||
|
||||
# transcripts test url, not trans_exist youtube_id, so 404 should be returned
|
||||
response = requests.get(
|
||||
'http://127.0.0.1:8034/test_transcripts_youtube/some_id',
|
||||
)
|
||||
self.assertEqual(404, response.status_code)
|
||||
@@ -17,9 +17,11 @@ from lxml import etree
|
||||
from pkg_resources import resource_string
|
||||
import datetime
|
||||
import time
|
||||
import copy
|
||||
|
||||
from django.http import Http404
|
||||
from django.conf import settings
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
from xmodule.x_module import XModule
|
||||
from xmodule.editing_module import TabsEditingDescriptor
|
||||
@@ -30,7 +32,6 @@ from xblock.fields import Scope, String, Boolean, Float, List, Integer, ScopeIds
|
||||
|
||||
from xmodule.modulestore.inheritance import InheritanceKeyValueStore
|
||||
from xblock.runtime import DbModel
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -48,7 +49,7 @@ class VideoFields(object):
|
||||
)
|
||||
show_captions = Boolean(
|
||||
help="This controls whether or not captions are shown by default.",
|
||||
display_name="Show Captions",
|
||||
display_name="Show Transcript",
|
||||
scope=Scope.settings,
|
||||
default=True
|
||||
)
|
||||
@@ -103,13 +104,13 @@ class VideoFields(object):
|
||||
)
|
||||
track = String(
|
||||
help="The external URL to download the timed transcript track. This appears as a link beneath the video.",
|
||||
display_name="Download Track",
|
||||
display_name="Download Transcript",
|
||||
scope=Scope.settings,
|
||||
default=""
|
||||
)
|
||||
sub = String(
|
||||
help="The name of the timed transcript track (for non-Youtube videos).",
|
||||
display_name="HTML5 Timed Transcript",
|
||||
display_name="HTML5 Transcript",
|
||||
scope=Scope.settings,
|
||||
default=""
|
||||
)
|
||||
@@ -196,14 +197,14 @@ class VideoDescriptor(VideoFields, TabsEditingDescriptor, EmptyDataRawDescriptor
|
||||
module_class = VideoModule
|
||||
|
||||
tabs = [
|
||||
# {
|
||||
# 'name': "Subtitles",
|
||||
# 'template': "video/subtitles.html",
|
||||
# },
|
||||
{
|
||||
'name': "Settings",
|
||||
'template': "tabs/metadata-edit-tab.html",
|
||||
'name': "Basic",
|
||||
'template': "video/transcripts.html",
|
||||
'current': True
|
||||
},
|
||||
{
|
||||
'name': "Advanced",
|
||||
'template': "tabs/metadata-edit-tab.html"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -286,6 +287,45 @@ class VideoDescriptor(VideoFields, TabsEditingDescriptor, EmptyDataRawDescriptor
|
||||
xml.append(ele)
|
||||
return xml
|
||||
|
||||
def get_context(self):
|
||||
"""
|
||||
Extend context by data for transcripts basic tab.
|
||||
"""
|
||||
_context = super(VideoDescriptor, self).get_context()
|
||||
|
||||
metadata_fields = copy.deepcopy(self.editable_metadata_fields)
|
||||
|
||||
display_name = metadata_fields['display_name']
|
||||
video_url = metadata_fields['html5_sources']
|
||||
youtube_id_1_0 = metadata_fields['youtube_id_1_0']
|
||||
|
||||
def get_youtube_link(video_id):
|
||||
if video_id:
|
||||
return 'http://youtu.be/{0}'.format(video_id)
|
||||
else:
|
||||
return ''
|
||||
|
||||
video_url.update({
|
||||
'help': _('A YouTube URL or a link to a file hosted anywhere on the web.'),
|
||||
'display_name': 'Video URL',
|
||||
'field_name': 'video_url',
|
||||
'type': 'VideoList',
|
||||
'default_value': [get_youtube_link(youtube_id_1_0['default_value'])]
|
||||
})
|
||||
|
||||
youtube_id_1_0_value = get_youtube_link(youtube_id_1_0['value'])
|
||||
|
||||
if youtube_id_1_0_value:
|
||||
video_url['value'].insert(0, youtube_id_1_0_value)
|
||||
|
||||
metadata = {
|
||||
'display_name': display_name,
|
||||
'video_url': video_url
|
||||
}
|
||||
|
||||
_context.update({'transcripts_basic_tab_metadata': metadata})
|
||||
return _context
|
||||
|
||||
@classmethod
|
||||
def _parse_youtube(cls, data):
|
||||
"""
|
||||
|
||||
53
common/static/js/vendor/jquery.ajaxQueue.js
vendored
Normal file
53
common/static/js/vendor/jquery.ajaxQueue.js
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* jQuery.ajaxQueue - A queue for ajax requests
|
||||
*
|
||||
* @copyright: Copyright (c) 2013 Corey Frang
|
||||
* @license: Licensed under the MIT license. See https://github.com/gnarf/jquery-ajaxQueue/blob/master/LICENSE-MIT.
|
||||
* @website: https://github.com/gnarf/jquery-ajaxQueue
|
||||
*/
|
||||
(function($) {
|
||||
|
||||
// jQuery on an empty object, we are going to use this as our Queue
|
||||
var ajaxQueue = $({});
|
||||
|
||||
$.ajaxQueue = function( ajaxOpts ) {
|
||||
var jqXHR,
|
||||
dfd = $.Deferred(),
|
||||
promise = dfd.promise();
|
||||
|
||||
// run the actual query
|
||||
function doRequest( next ) {
|
||||
jqXHR = $.ajax( ajaxOpts );
|
||||
jqXHR.done( dfd.resolve )
|
||||
.fail( dfd.reject )
|
||||
.then( next, next );
|
||||
}
|
||||
|
||||
// queue our ajax request
|
||||
ajaxQueue.queue( doRequest );
|
||||
|
||||
// add the abort method
|
||||
promise.abort = function( statusText ) {
|
||||
|
||||
// proxy abort to the jqXHR if it is active
|
||||
if ( jqXHR ) {
|
||||
return jqXHR.abort( statusText );
|
||||
}
|
||||
|
||||
// if there wasn't already a jqXHR we need to remove from queue
|
||||
var queue = ajaxQueue.queue(),
|
||||
index = $.inArray( doRequest, queue );
|
||||
|
||||
if ( index > -1 ) {
|
||||
queue.splice( index, 1 );
|
||||
}
|
||||
|
||||
// and then reject the deferred
|
||||
dfd.rejectWith( ajaxOpts.context || ajaxOpts, [ promise, statusText, "" ] );
|
||||
return promise;
|
||||
};
|
||||
|
||||
return promise;
|
||||
};
|
||||
|
||||
})(jQuery);
|
||||
1092
common/test/data/uploads/chinese_transcripts.srt
Normal file
1092
common/test/data/uploads/chinese_transcripts.srt
Normal file
@@ -0,0 +1,1092 @@
|
||||
1
|
||||
00:00:00,293 --> 00:00:01,245
|
||||
好 各位同学
|
||||
|
||||
2
|
||||
00:00:01,493 --> 00:00:03,821
|
||||
我们今天要讲的题目是
|
||||
|
||||
3
|
||||
00:00:04,037 --> 00:00:05,813
|
||||
从算筹到ENIAC
|
||||
|
||||
4
|
||||
00:00:06,181 --> 00:00:07,347
|
||||
那么今天的主要内容
|
||||
|
||||
5
|
||||
00:00:07,733 --> 00:00:10,904
|
||||
我会从远古的手动的一些计算工具
|
||||
|
||||
6
|
||||
00:00:11,197 --> 00:00:14,445
|
||||
一直讲到我们现代的电子计算机ENIAC
|
||||
|
||||
7
|
||||
00:00:15,165 --> 00:00:18,925
|
||||
首先我们来介绍一下远古的计算工具
|
||||
|
||||
8
|
||||
00:00:19,221 --> 00:00:21,125
|
||||
我们看
|
||||
|
||||
9
|
||||
00:00:21,491 --> 00:00:24,829
|
||||
我们人类社会最早的计算工具是什么呢
|
||||
|
||||
10
|
||||
00:00:25,126 --> 00:00:27,365
|
||||
其实早先可以有结绳记事
|
||||
|
||||
11
|
||||
00:00:27,749 --> 00:00:28,789
|
||||
但是那个呢谈不上计算
|
||||
|
||||
12
|
||||
00:00:29,221 --> 00:00:30,989
|
||||
只能称得上是存储
|
||||
|
||||
13
|
||||
00:00:31,405 --> 00:00:32,653
|
||||
那么最早的计算工具
|
||||
|
||||
14
|
||||
00:00:32,877 --> 00:00:36,149
|
||||
是出现在中国的商周时期
|
||||
|
||||
15
|
||||
00:00:36,357 --> 00:00:37,063
|
||||
是什么呢
|
||||
|
||||
16
|
||||
00:00:37,357 --> 00:00:39,805
|
||||
就是这样的一些东西
|
||||
|
||||
17
|
||||
00:00:40,117 --> 00:00:40,965
|
||||
叫算筹
|
||||
|
||||
18
|
||||
00:00:41,320 --> 00:00:42,557
|
||||
那么我们古代成语
|
||||
|
||||
19
|
||||
00:00:43,021 --> 00:00:44,445
|
||||
“运筹帷幄”之中的“筹”
|
||||
|
||||
20
|
||||
00:00:44,741 --> 00:00:46,141
|
||||
就是这个算筹
|
||||
|
||||
21
|
||||
00:00:47,729 --> 00:00:49,109
|
||||
这是普通的算筹
|
||||
|
||||
22
|
||||
00:00:49,373 --> 00:00:50,109
|
||||
就是一些小木棍
|
||||
|
||||
23
|
||||
00:00:50,557 --> 00:00:52,173
|
||||
它的高端产品是什么呢
|
||||
|
||||
24
|
||||
00:00:52,509 --> 00:00:53,869
|
||||
就是一些小骨头棍
|
||||
|
||||
25
|
||||
00:00:54,205 --> 00:00:57,237
|
||||
这个算筹用来怎么计数呢
|
||||
|
||||
26
|
||||
00:00:57,526 --> 00:01:00,741
|
||||
我们古代有“横式”“纵式”两种计数方式
|
||||
|
||||
27
|
||||
00:01:01,077 --> 00:01:04,477
|
||||
其实就是不同的排放组合来代表不同的数字
|
||||
|
||||
28
|
||||
00:01:05,109 --> 00:01:06,125
|
||||
那么大家想
|
||||
|
||||
29
|
||||
00:01:06,405 --> 00:01:09,509
|
||||
这些小木棍用来作为计算工具
|
||||
|
||||
30
|
||||
00:01:09,821 --> 00:01:13,253
|
||||
能够完成什么样的工作呢
|
||||
|
||||
31
|
||||
00:01:13,565 --> 00:01:18,013
|
||||
事实上我们中国古代有一位老大爷
|
||||
|
||||
32
|
||||
00:01:18,261 --> 00:01:21,389
|
||||
他用算筹还是做了一些工作
|
||||
|
||||
33
|
||||
00:01:21,702 --> 00:01:23,173
|
||||
我们来认识一下这位老大爷
|
||||
|
||||
34
|
||||
00:01:25,422 --> 00:01:26,149
|
||||
他是谁呢
|
||||
|
||||
35
|
||||
00:01:26,613 --> 00:01:27,691
|
||||
他叫祖冲之
|
||||
|
||||
36
|
||||
00:01:28,582 --> 00:01:30,501
|
||||
他用算筹做出了什么呢
|
||||
|
||||
37
|
||||
00:01:30,820 --> 00:01:36,453
|
||||
他把圆周率从3.1415926到3.1415927之间估算出来
|
||||
|
||||
38
|
||||
00:01:36,845 --> 00:01:41,333
|
||||
所以算筹还是可以做一些很好的工作的
|
||||
|
||||
39
|
||||
00:01:42,181 --> 00:01:44,533
|
||||
同样的这个年代
|
||||
|
||||
40
|
||||
00:01:44,845 --> 00:01:47,807
|
||||
在欧洲也有类似的一些工具
|
||||
|
||||
41
|
||||
00:01:48,253 --> 00:01:50,493
|
||||
那就是欧洲的Napier算筹
|
||||
|
||||
42
|
||||
00:01:51,157 --> 00:01:52,413
|
||||
欧洲的这个Napier算筹
|
||||
|
||||
43
|
||||
00:01:52,661 --> 00:01:54,989
|
||||
它是依据一定的计算原理来做的
|
||||
|
||||
44
|
||||
00:01:55,317 --> 00:01:56,030
|
||||
什么原理呢
|
||||
|
||||
45
|
||||
00:01:56,373 --> 00:01:57,101
|
||||
我说是“格子原理”
|
||||
|
||||
46
|
||||
00:01:57,501 --> 00:01:58,693
|
||||
那什么是“格子原理”呢
|
||||
|
||||
47
|
||||
00:01:58,965 --> 00:02:00,091
|
||||
我们来看一下
|
||||
|
||||
48
|
||||
00:02:00,462 --> 00:02:01,157
|
||||
举个简单例子
|
||||
|
||||
49
|
||||
00:02:01,541 --> 00:02:04,205
|
||||
比如说我们要计算24乘以36
|
||||
|
||||
50
|
||||
00:02:05,585 --> 00:02:07,765
|
||||
这个时候我们用一个格子把它画出来
|
||||
|
||||
51
|
||||
00:02:08,277 --> 00:02:10,933
|
||||
把2、4、3、6分别对应横向的格和纵向的格
|
||||
|
||||
52
|
||||
00:02:11,597 --> 00:02:14,837
|
||||
这时候我们把每一个格子分成两个部分
|
||||
|
||||
53
|
||||
00:02:15,197 --> 00:02:16,917
|
||||
其中要填上数字
|
||||
|
||||
54
|
||||
00:02:17,237 --> 00:02:17,775
|
||||
填什么数字呢
|
||||
|
||||
55
|
||||
00:02:18,037 --> 00:02:19,357
|
||||
比如说2乘以6
|
||||
|
||||
56
|
||||
00:02:21,234 --> 00:02:22,461
|
||||
它的这个相应的位置
|
||||
|
||||
57
|
||||
00:02:22,901 --> 00:02:23,862
|
||||
2乘以6等于12
|
||||
|
||||
58
|
||||
00:02:24,165 --> 00:02:26,021
|
||||
相应的位置就填上1和2
|
||||
|
||||
59
|
||||
00:02:27,117 --> 00:02:28,117
|
||||
类似的方法
|
||||
|
||||
60
|
||||
00:02:28,461 --> 00:02:30,013
|
||||
我们把所有的格子的数字都填满
|
||||
|
||||
61
|
||||
00:02:30,525 --> 00:02:32,949
|
||||
那么好 24乘36到底等于多少
|
||||
|
||||
62
|
||||
00:02:33,269 --> 00:02:36,533
|
||||
我们看 个位数就是4
|
||||
|
||||
63
|
||||
00:02:36,776 --> 00:02:39,741
|
||||
十位数呢就是这样相加 就是6
|
||||
|
||||
64
|
||||
00:02:40,037 --> 00:02:42,517
|
||||
百位数就是这样相加 就是8
|
||||
|
||||
65
|
||||
00:02:42,864 --> 00:02:45,981
|
||||
864 大家去验算一下是不是对的
|
||||
|
||||
66
|
||||
00:02:46,525 --> 00:02:47,638
|
||||
是对的
|
||||
|
||||
67
|
||||
00:02:48,581 --> 00:02:50,181
|
||||
利用这样的一个“格子原理”
|
||||
|
||||
68
|
||||
00:02:50,525 --> 00:02:53,829
|
||||
欧洲的Napier发明了这种算筹
|
||||
|
||||
69
|
||||
00:02:54,173 --> 00:02:55,277
|
||||
就是这个样子的
|
||||
|
||||
70
|
||||
00:02:55,603 --> 00:02:57,837
|
||||
大家看 就是这样
|
||||
|
||||
71
|
||||
00:03:00,402 --> 00:03:03,605
|
||||
后来中国又出现了一种
|
||||
|
||||
72
|
||||
00:03:03,893 --> 00:03:06,644
|
||||
低碳 环保 便携
|
||||
|
||||
73
|
||||
00:03:07,229 --> 00:03:09,540
|
||||
同时解决问题又非常利索
|
||||
|
||||
74
|
||||
00:03:09,797 --> 00:03:11,965
|
||||
三下五除二就可以搞定的计算工具
|
||||
|
||||
75
|
||||
00:03:12,557 --> 00:03:13,277
|
||||
是什么呢
|
||||
|
||||
76
|
||||
00:03:13,904 --> 00:03:15,079
|
||||
就是算盘
|
||||
|
||||
77
|
||||
00:03:16,205 --> 00:03:18,709
|
||||
这个算盘应该说在中国古代
|
||||
|
||||
78
|
||||
00:03:19,021 --> 00:03:21,709
|
||||
社会发展中起到了重要的作用
|
||||
|
||||
79
|
||||
00:03:22,060 --> 00:03:22,789
|
||||
就不用多说了
|
||||
|
||||
80
|
||||
00:03:25,157 --> 00:03:27,445
|
||||
它出现在宋 元 大概这个年代
|
||||
|
||||
81
|
||||
00:03:29,189 --> 00:03:31,900
|
||||
还是大概同一时代
|
||||
|
||||
82
|
||||
00:03:32,597 --> 00:03:34,061
|
||||
在欧洲有一个数学家
|
||||
|
||||
83
|
||||
00:03:34,853 --> 00:03:36,420
|
||||
英国的数学家奥特雷德
|
||||
|
||||
84
|
||||
00:03:36,842 --> 00:03:40,629
|
||||
他发明了一种计算工具叫“计算尺”
|
||||
|
||||
85
|
||||
00:03:41,701 --> 00:03:43,324
|
||||
这个计算尺实际上
|
||||
|
||||
86
|
||||
00:03:43,660 --> 00:03:45,772
|
||||
尽管是在十五世纪发明的
|
||||
|
||||
87
|
||||
00:03:46,029 --> 00:03:48,277
|
||||
但是真正推广应用的
|
||||
|
||||
88
|
||||
00:03:48,956 --> 00:03:49,941
|
||||
让世人所知的
|
||||
|
||||
89
|
||||
00:03:50,221 --> 00:03:51,060
|
||||
是谁呢
|
||||
|
||||
90
|
||||
00:03:51,364 --> 00:03:53,381
|
||||
是到了十八世纪的时候
|
||||
|
||||
91
|
||||
00:03:53,820 --> 00:03:54,412
|
||||
一位人物
|
||||
|
||||
92
|
||||
00:03:54,717 --> 00:03:55,495
|
||||
叫做瓦特
|
||||
|
||||
93
|
||||
00:03:56,509 --> 00:03:58,717
|
||||
就是发明蒸汽机的瓦特
|
||||
|
||||
94
|
||||
00:03:59,564 --> 00:04:01,565
|
||||
他把计算尺做了一点点改进
|
||||
|
||||
95
|
||||
00:04:01,893 --> 00:04:02,317
|
||||
怎么改进呢
|
||||
|
||||
96
|
||||
00:04:02,821 --> 00:04:03,573
|
||||
大家看
|
||||
|
||||
97
|
||||
00:04:03,941 --> 00:04:09,076
|
||||
计算尺上增加了一个滑动的标
|
||||
|
||||
98
|
||||
00:04:09,405 --> 00:04:11,645
|
||||
这个标是用来作为
|
||||
|
||||
99
|
||||
00:04:12,052 --> 00:04:14,901
|
||||
记录中间的计算结果用的
|
||||
|
||||
100
|
||||
00:04:15,652 --> 00:04:17,596
|
||||
所以瓦特用它做了大量的计算
|
||||
|
||||
101
|
||||
00:04:17,949 --> 00:04:20,789
|
||||
也为后来的工业发展起到了重要作用
|
||||
|
||||
102
|
||||
00:04:21,348 --> 00:04:22,740
|
||||
这是计算尺
|
||||
|
||||
103
|
||||
00:04:24,445 --> 00:04:25,973
|
||||
这是远古的计算工具
|
||||
|
||||
104
|
||||
00:04:26,261 --> 00:04:27,348
|
||||
我们说远古的计算工具
|
||||
|
||||
105
|
||||
00:04:27,724 --> 00:04:30,116
|
||||
大体可以称为是手动计算工具
|
||||
|
||||
106
|
||||
00:04:30,869 --> 00:04:32,772
|
||||
后来又有一些先辈
|
||||
|
||||
107
|
||||
00:04:33,196 --> 00:04:34,365
|
||||
他们利用他们的智慧
|
||||
|
||||
108
|
||||
00:04:34,748 --> 00:04:38,386
|
||||
为我们发明了机械式的计算工具
|
||||
|
||||
109
|
||||
00:04:38,893 --> 00:04:40,469
|
||||
下面呢我们来看一下
|
||||
|
||||
110
|
||||
00:04:40,837 --> 00:04:41,740
|
||||
机械式计算工具
|
||||
|
||||
111
|
||||
00:04:44,260 --> 00:04:47,052
|
||||
在这一部分我要讲四个案例
|
||||
|
||||
112
|
||||
00:04:47,716 --> 00:04:50,224
|
||||
按照历史的顺序我要将四个案例
|
||||
|
||||
113
|
||||
00:04:50,772 --> 00:04:52,564
|
||||
其实我们还有一些别的计算工具
|
||||
|
||||
114
|
||||
00:04:53,244 --> 00:04:54,724
|
||||
我们首先来看
|
||||
|
||||
115
|
||||
00:04:55,988 --> 00:04:59,332
|
||||
在欧洲文艺复兴的期间
|
||||
|
||||
116
|
||||
00:05:00,294 --> 00:05:05,103
|
||||
有一位老人家设计了这样的一个装置
|
||||
|
||||
117
|
||||
00:05:05,670 --> 00:05:09,102
|
||||
由十三个齿轮来做加法
|
||||
|
||||
118
|
||||
00:05:10,078 --> 00:05:10,998
|
||||
这样一个装置
|
||||
|
||||
119
|
||||
00:05:11,518 --> 00:05:13,238
|
||||
那么这个装置实际的形状
|
||||
|
||||
120
|
||||
00:05:13,886 --> 00:05:14,622
|
||||
就是这样的形状
|
||||
|
||||
121
|
||||
00:05:14,750 --> 00:05:17,084
|
||||
那么是哪一位老人家设计的呢
|
||||
|
||||
122
|
||||
00:05:17,924 --> 00:05:18,804
|
||||
我们来认识一下
|
||||
|
||||
123
|
||||
00:05:21,364 --> 00:05:22,356
|
||||
哇 蒙娜丽莎
|
||||
|
||||
124
|
||||
00:05:23,084 --> 00:05:24,012
|
||||
的作者达芬奇
|
||||
|
||||
125
|
||||
00:05:25,716 --> 00:05:27,524
|
||||
他设计的这个装置
|
||||
|
||||
126
|
||||
00:05:29,756 --> 00:05:32,908
|
||||
一直是停留在手稿状态
|
||||
|
||||
127
|
||||
00:05:33,692 --> 00:05:35,268
|
||||
后来无意当中被人发现
|
||||
|
||||
128
|
||||
00:05:35,837 --> 00:05:37,268
|
||||
说达芬奇怎么还有这么一个发明
|
||||
|
||||
129
|
||||
00:05:38,581 --> 00:05:39,852
|
||||
但是一直到1968年
|
||||
|
||||
130
|
||||
00:05:40,284 --> 00:05:41,748
|
||||
才有人真正把他这个装置
|
||||
|
||||
131
|
||||
00:05:42,148 --> 00:05:43,884
|
||||
按照他的这个说明恢复了出来
|
||||
|
||||
132
|
||||
00:05:44,348 --> 00:05:45,332
|
||||
就是下面这个图
|
||||
|
||||
133
|
||||
00:05:45,996 --> 00:05:47,516
|
||||
这个图大家可以看到
|
||||
|
||||
134
|
||||
00:05:48,004 --> 00:05:48,412
|
||||
就是一个真实的装置
|
||||
|
||||
135
|
||||
00:05:48,935 --> 00:05:51,380
|
||||
你们在某些博物馆也许会看到这个装置
|
||||
|
||||
136
|
||||
00:05:51,828 --> 00:05:56,204
|
||||
这是最早达芬奇做了一个加法器的设计
|
||||
|
||||
137
|
||||
00:05:56,852 --> 00:05:57,316
|
||||
是一个机械式的
|
||||
|
||||
138
|
||||
00:05:58,291 --> 00:06:00,772
|
||||
这个呢大约是在十四世纪的时候
|
||||
|
||||
139
|
||||
00:06:01,116 --> 00:06:04,180
|
||||
又过了大约两百年
|
||||
|
||||
140
|
||||
00:06:05,700 --> 00:06:07,933
|
||||
一位德国的科学家叫契克卡德
|
||||
|
||||
141
|
||||
00:06:08,836 --> 00:06:12,620
|
||||
他又设计了一个机械式的计算装置
|
||||
|
||||
142
|
||||
00:06:13,636 --> 00:06:14,616
|
||||
这是契克卡德
|
||||
|
||||
143
|
||||
00:06:15,021 --> 00:06:18,428
|
||||
他发明的这个装置是这个样子的
|
||||
|
||||
144
|
||||
00:06:19,268 --> 00:06:19,957
|
||||
为什么这样呢?
|
||||
|
||||
145
|
||||
00:06:20,164 --> 00:06:22,476
|
||||
因为他最早发明这个装置是木头的
|
||||
|
||||
146
|
||||
00:06:23,108 --> 00:06:24,005
|
||||
放到他的家乡
|
||||
|
||||
147
|
||||
00:06:25,445 --> 00:06:26,604
|
||||
结果就因为是木头的
|
||||
|
||||
148
|
||||
00:06:27,015 --> 00:06:28,452
|
||||
有一次家乡不小心失火了
|
||||
|
||||
149
|
||||
00:06:29,748 --> 00:06:30,452
|
||||
一把火烧掉了
|
||||
|
||||
150
|
||||
00:06:31,356 --> 00:06:32,284
|
||||
所以留下的只有图纸
|
||||
|
||||
151
|
||||
00:06:33,212 --> 00:06:35,228
|
||||
后人又根据他的图纸
|
||||
|
||||
152
|
||||
00:06:35,916 --> 00:06:37,277
|
||||
真实地再现了
|
||||
|
||||
153
|
||||
00:06:37,917 --> 00:06:39,278
|
||||
他所设计的这个计算装置
|
||||
|
||||
154
|
||||
00:06:40,284 --> 00:06:40,989
|
||||
也还是木头的
|
||||
|
||||
155
|
||||
00:06:41,572 --> 00:06:43,796
|
||||
发现这个装置运行非常好
|
||||
|
||||
156
|
||||
00:06:45,220 --> 00:06:46,692
|
||||
这是德国的科学家当时
|
||||
|
||||
157
|
||||
00:06:47,677 --> 00:06:50,004
|
||||
用木头做的一个计算装置
|
||||
|
||||
158
|
||||
00:06:51,101 --> 00:06:54,789
|
||||
大体上和契克卡德在同一年代
|
||||
|
||||
159
|
||||
00:06:55,573 --> 00:06:57,149
|
||||
又有一位年轻人
|
||||
|
||||
160
|
||||
00:06:57,565 --> 00:07:00,709
|
||||
他在19岁的时候设计了一个
|
||||
|
||||
161
|
||||
00:07:01,085 --> 00:07:03,381
|
||||
也是机械式计算装置
|
||||
|
||||
162
|
||||
00:07:03,925 --> 00:07:05,013
|
||||
叫齿轮计算器
|
||||
|
||||
163
|
||||
00:07:05,669 --> 00:07:06,317
|
||||
就是这样的
|
||||
|
||||
164
|
||||
00:07:06,661 --> 00:07:07,765
|
||||
这个计算器的特点是什么
|
||||
|
||||
165
|
||||
00:07:08,173 --> 00:07:10,325
|
||||
十进制 带进位
|
||||
|
||||
166
|
||||
00:07:10,989 --> 00:07:11,669
|
||||
这样的一个特点
|
||||
|
||||
167
|
||||
00:07:12,029 --> 00:07:14,189
|
||||
这个年轻人为什么要设计这样一个装置呢
|
||||
|
||||
168
|
||||
00:07:14,989 --> 00:07:18,365
|
||||
是因为他的父亲是政府官员
|
||||
|
||||
169
|
||||
00:07:18,901 --> 00:07:22,725
|
||||
负责的工作是每天都要计算复杂的税率
|
||||
|
||||
170
|
||||
00:07:23,293 --> 00:07:24,805
|
||||
计算任务非常重
|
||||
|
||||
171
|
||||
00:07:25,490 --> 00:07:28,309
|
||||
他年纪轻轻的时候就觉得
|
||||
|
||||
172
|
||||
00:07:28,685 --> 00:07:29,485
|
||||
父亲很辛苦
|
||||
|
||||
173
|
||||
00:07:29,869 --> 00:07:32,277
|
||||
我希望能够给父亲做一点事情
|
||||
|
||||
174
|
||||
00:07:32,613 --> 00:07:33,221
|
||||
所以他就想
|
||||
|
||||
175
|
||||
00:07:33,509 --> 00:07:34,661
|
||||
我可不可以用一个机械的装置
|
||||
|
||||
176
|
||||
00:07:35,013 --> 00:07:36,741
|
||||
来代替父亲的这种繁琐的工作
|
||||
|
||||
177
|
||||
00:07:37,069 --> 00:07:40,789
|
||||
所以他就设计了这么一个装置
|
||||
|
||||
178
|
||||
00:07:41,117 --> 00:07:42,629
|
||||
叫齿轮计算器
|
||||
|
||||
179
|
||||
00:07:44,803 --> 00:07:45,547
|
||||
谁设计的呢
|
||||
|
||||
180
|
||||
00:07:45,771 --> 00:07:46,569
|
||||
就是这位年轻人
|
||||
|
||||
181
|
||||
00:07:46,875 --> 00:07:47,880
|
||||
大家可能就不太认识
|
||||
|
||||
182
|
||||
00:07:48,253 --> 00:07:50,701
|
||||
这位年轻人的名字叫做帕斯卡
|
||||
|
||||
183
|
||||
00:07:51,952 --> 00:07:53,488
|
||||
大家觉得耳熟啊
|
||||
|
||||
184
|
||||
00:07:53,957 --> 00:07:55,149
|
||||
帕斯卡是谁呢
|
||||
|
||||
185
|
||||
00:07:55,525 --> 00:07:57,437
|
||||
没错 就是你想的那个压强的单位
|
||||
|
||||
186
|
||||
00:07:57,789 --> 00:07:58,269
|
||||
帕斯卡
|
||||
|
||||
187
|
||||
00:07:58,989 --> 00:08:02,229
|
||||
他最早设计了这个齿轮式的计算器
|
||||
|
||||
188
|
||||
00:08:03,725 --> 00:08:05,837
|
||||
这个计算器的实物是这样的
|
||||
|
||||
189
|
||||
00:08:06,637 --> 00:08:07,405
|
||||
它最后生产没有呢
|
||||
|
||||
190
|
||||
00:08:07,797 --> 00:08:08,677
|
||||
生产了
|
||||
|
||||
191
|
||||
00:08:08,965 --> 00:08:09,693
|
||||
而且生产了很多
|
||||
|
||||
192
|
||||
00:08:10,928 --> 00:08:12,949
|
||||
生产了很多之后
|
||||
|
||||
193
|
||||
00:08:13,341 --> 00:08:16,293
|
||||
有几个样品当时还曾经送到了中国
|
||||
|
||||
194
|
||||
00:08:17,077 --> 00:08:18,221
|
||||
可惜中国当时也没有用
|
||||
|
||||
195
|
||||
00:08:19,653 --> 00:08:22,086
|
||||
这是打开后里面的装置
|
||||
|
||||
196
|
||||
00:08:22,621 --> 00:08:26,725
|
||||
通过齿轮的咬合来进行十进制的计算
|
||||
|
||||
197
|
||||
00:08:27,565 --> 00:08:28,597
|
||||
这是帕斯卡
|
||||
|
||||
198
|
||||
00:08:29,131 --> 00:08:32,051
|
||||
那么帕斯卡他所做的工作
|
||||
|
||||
199
|
||||
00:08:32,763 --> 00:08:35,202
|
||||
应该说在那个年代超前的
|
||||
|
||||
200
|
||||
00:08:35,538 --> 00:08:36,538
|
||||
也是一种非凡的
|
||||
|
||||
201
|
||||
00:08:37,571 --> 00:08:39,702
|
||||
那么 在帕斯卡生命的最后几年
|
||||
|
||||
202
|
||||
00:08:40,131 --> 00:08:44,779
|
||||
他专心地在写 总结自己的思想
|
||||
|
||||
203
|
||||
00:08:45,251 --> 00:08:46,899
|
||||
他在他的书中写道
|
||||
|
||||
204
|
||||
00:08:47,251 --> 00:08:51,493
|
||||
这种计算器所进行的工作比动物的行为
|
||||
|
||||
205
|
||||
00:08:51,931 --> 00:08:54,179
|
||||
更接近于人类的思维
|
||||
|
||||
206
|
||||
00:08:55,747 --> 00:08:57,611
|
||||
也就是说 他实际上
|
||||
|
||||
207
|
||||
00:08:57,971 --> 00:09:00,499
|
||||
提出了一种非凡的想法
|
||||
|
||||
208
|
||||
00:09:01,099 --> 00:09:01,995
|
||||
为了实现这样一个目的
|
||||
|
||||
209
|
||||
00:09:02,371 --> 00:09:02,707
|
||||
是什么
|
||||
|
||||
210
|
||||
00:09:03,075 --> 00:09:08,475
|
||||
就是利用纯粹的机械的装置
|
||||
|
||||
211
|
||||
00:09:09,691 --> 00:09:14,771
|
||||
来代替我们人类的思考和记忆
|
||||
|
||||
212
|
||||
00:09:15,779 --> 00:09:17,867
|
||||
那么这种想法在当时可以说
|
||||
|
||||
213
|
||||
00:09:18,210 --> 00:09:21,194
|
||||
是一种非凡的创新 非凡的创举
|
||||
|
||||
214
|
||||
00:09:22,369 --> 00:09:25,449
|
||||
但是非常可惜 他有这样非凡的创举
|
||||
|
||||
215
|
||||
00:09:25,894 --> 00:09:30,705
|
||||
但是帕斯卡呢 在39岁的时候就去世了
|
||||
|
||||
216
|
||||
00:09:31,519 --> 00:09:33,082
|
||||
英年早逝了 特别可惜
|
||||
|
||||
217
|
||||
00:09:33,673 --> 00:09:34,929
|
||||
那么帕斯卡其实自己呢
|
||||
|
||||
218
|
||||
00:09:35,361 --> 00:09:36,538
|
||||
也对自己有一个评价
|
||||
|
||||
219
|
||||
00:09:36,945 --> 00:09:40,722
|
||||
他说 人好比是脆弱的芦苇
|
||||
|
||||
220
|
||||
00:09:41,218 --> 00:09:44,210
|
||||
但是 他又是有思想的芦苇
|
||||
|
||||
221
|
||||
00:09:45,227 --> 00:09:46,593
|
||||
其实这句话很有意思
|
||||
|
||||
222
|
||||
00:09:47,002 --> 00:09:47,793
|
||||
我们现在回想
|
||||
|
||||
223
|
||||
00:09:48,146 --> 00:09:51,490
|
||||
我们现在很多人比一个强壮的芦苇
|
||||
|
||||
224
|
||||
00:09:51,969 --> 00:09:53,529
|
||||
不知道要强壮多少倍
|
||||
|
||||
225
|
||||
00:09:54,073 --> 00:09:58,362
|
||||
但是最终也是像芦苇一样悄无声息
|
||||
|
||||
226
|
||||
00:09:59,209 --> 00:09:59,833
|
||||
为什么呢
|
||||
|
||||
227
|
||||
00:10:00,514 --> 00:10:02,905
|
||||
大家可以想一想帕斯卡的这句话
|
||||
|
||||
228
|
||||
00:10:05,661 --> 00:10:07,242
|
||||
好 那么帕斯卡之后
|
||||
|
||||
229
|
||||
00:10:07,977 --> 00:10:11,257
|
||||
有一位比帕斯卡小二十多岁的年轻人
|
||||
|
||||
230
|
||||
00:10:11,906 --> 00:10:15,682
|
||||
那么 他被帕斯卡的想法深深的迷住了
|
||||
|
||||
231
|
||||
00:10:16,130 --> 00:10:17,490
|
||||
他后来设计了一个
|
||||
|
||||
232
|
||||
00:10:17,842 --> 00:10:19,826
|
||||
大约在帕斯卡计算机之后
|
||||
|
||||
233
|
||||
00:10:20,137 --> 00:10:21,937
|
||||
我们推算在大约在半个世纪之后
|
||||
|
||||
234
|
||||
00:10:22,441 --> 00:10:24,881
|
||||
他设计了一个乘法器
|
||||
|
||||
235
|
||||
00:10:25,681 --> 00:10:26,905
|
||||
大家看这个乘法器
|
||||
|
||||
236
|
||||
00:10:27,450 --> 00:10:29,505
|
||||
那么这个乘法器看这个样子就比较
|
||||
|
||||
237
|
||||
00:10:29,834 --> 00:10:31,697
|
||||
高端 大气 上档次
|
||||
|
||||
238
|
||||
00:10:32,393 --> 00:10:33,337
|
||||
比之前的要好看多了
|
||||
|
||||
239
|
||||
00:10:33,889 --> 00:10:35,298
|
||||
那么这个乘法器谁设计的呢
|
||||
|
||||
240
|
||||
00:10:35,713 --> 00:10:37,225
|
||||
我们认识一下这位年轻人
|
||||
|
||||
241
|
||||
00:10:38,338 --> 00:10:40,441
|
||||
他的名字叫莱布尼茨
|
||||
|
||||
242
|
||||
00:10:42,233 --> 00:10:44,058
|
||||
那么这个莱布尼茨 他这个乘法器
|
||||
|
||||
243
|
||||
00:10:44,681 --> 00:10:47,297
|
||||
和之前帕斯卡的加法器的区别在什么地方
|
||||
|
||||
244
|
||||
00:10:47,834 --> 00:10:51,505
|
||||
它是二进制的 所以这是它最大的特点
|
||||
|
||||
245
|
||||
00:10:51,833 --> 00:10:52,593
|
||||
二进制 乘法器
|
||||
|
||||
246
|
||||
00:10:52,928 --> 00:10:53,673
|
||||
机械式的
|
||||
|
||||
247
|
||||
00:10:54,073 --> 00:10:55,258
|
||||
那么说到这个二进制呢
|
||||
|
||||
248
|
||||
00:10:55,505 --> 00:10:59,654
|
||||
应该说还是和中国的文化还是相当有些渊源
|
||||
|
||||
249
|
||||
00:11:00,050 --> 00:11:02,825
|
||||
那么据说莱布尼茨的二进制想法
|
||||
|
||||
250
|
||||
00:11:03,356 --> 00:11:05,418
|
||||
来自于我们的伏羲八卦图
|
||||
|
||||
251
|
||||
00:11:06,114 --> 00:11:07,241
|
||||
那么怎么对应呢
|
||||
|
||||
252
|
||||
00:11:07,697 --> 00:11:08,329
|
||||
就是伏羲八卦图当中的
|
||||
|
||||
253
|
||||
00:11:08,737 --> 00:11:11,657
|
||||
乾 坤 坎 离 巽 艮 震 兑
|
||||
|
||||
254
|
||||
00:11:11,938 --> 00:11:13,778
|
||||
这八个卦呢 分别可以用
|
||||
|
||||
255
|
||||
00:11:14,057 --> 00:11:18,113
|
||||
二进制的000 001等等 这样表示出来
|
||||
|
||||
256
|
||||
00:11:18,961 --> 00:11:20,540
|
||||
所以呢 有一种说法呢 说
|
||||
|
||||
257
|
||||
00:11:20,915 --> 00:11:23,543
|
||||
莱布尼茨的二进制思想来源于中国的八卦
|
||||
|
||||
258
|
||||
00:11:23,969 --> 00:11:25,393
|
||||
当然 莱布尼茨本人是否认的
|
||||
|
||||
259
|
||||
00:11:25,905 --> 00:11:27,730
|
||||
但是又有中国学者又考证过
|
||||
|
||||
260
|
||||
00:11:28,034 --> 00:11:29,090
|
||||
说他否认不了这一点
|
||||
|
||||
261
|
||||
00:11:29,298 --> 00:11:30,761
|
||||
他肯定是之前见过这个八卦的
|
||||
|
||||
262
|
||||
00:11:31,465 --> 00:11:33,906
|
||||
当然这个事情 我们不去再考证
|
||||
|
||||
263
|
||||
00:11:34,385 --> 00:11:38,217
|
||||
它到底是起源于什么地方 二进制
|
||||
|
||||
264
|
||||
00:11:38,489 --> 00:11:40,818
|
||||
我觉得我们现在自强是更重要的
|
||||
|
||||
265
|
||||
00:11:42,161 --> 00:11:43,673
|
||||
好 那么我们这个小节呢
|
||||
|
||||
266
|
||||
00:11:44,105 --> 00:11:47,161
|
||||
主要就给大家介绍了最远古的手动计算工具
|
||||
|
||||
267
|
||||
00:11:47,521 --> 00:11:48,969
|
||||
和机械式的一些计算工具
|
||||
|
||||
268
|
||||
00:11:49,489 --> 00:11:51,193
|
||||
那么也体现了先辈的智慧
|
||||
|
||||
269
|
||||
00:11:52,465 --> 00:11:57,954
|
||||
但是真正对未来的 也就是现在的计算机科学
|
||||
|
||||
270
|
||||
00:11:58,275 --> 00:11:59,841
|
||||
产生重大影响
|
||||
|
||||
271
|
||||
00:12:00,241 --> 00:12:01,773
|
||||
我们有两位伟大的先驱
|
||||
|
||||
272
|
||||
00:12:02,777 --> 00:12:03,657
|
||||
那么 他们的故事
|
||||
|
||||
273
|
||||
00:12:03,929 --> 00:12:05,988
|
||||
我们在下一节将要给大家讲述
|
||||
|
||||
11
common/test/data/uploads/subs_t__eq_exist.srt.sjson
Normal file
11
common/test/data/uploads/subs_t__eq_exist.srt.sjson
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"start": [
|
||||
1000
|
||||
],
|
||||
"end": [
|
||||
2000
|
||||
],
|
||||
"text": [
|
||||
"Equal transcripts"
|
||||
]
|
||||
}
|
||||
143
common/test/data/uploads/subs_t_neq_exist.srt.sjson
Normal file
143
common/test/data/uploads/subs_t_neq_exist.srt.sjson
Normal file
@@ -0,0 +1,143 @@
|
||||
{
|
||||
"start": [
|
||||
270,
|
||||
2720,
|
||||
5430,
|
||||
7160,
|
||||
10830,
|
||||
12880,
|
||||
15890,
|
||||
19000,
|
||||
22070,
|
||||
25170,
|
||||
27890,
|
||||
30590,
|
||||
32920,
|
||||
36360,
|
||||
39630,
|
||||
41170,
|
||||
42790,
|
||||
44590,
|
||||
47320,
|
||||
50250,
|
||||
51880,
|
||||
54320,
|
||||
57410,
|
||||
59160,
|
||||
62320,
|
||||
65099,
|
||||
68430,
|
||||
71360,
|
||||
73640,
|
||||
76580,
|
||||
78660,
|
||||
81480,
|
||||
83940,
|
||||
86230,
|
||||
88570,
|
||||
90520,
|
||||
93430,
|
||||
95940,
|
||||
99090,
|
||||
100910,
|
||||
103740,
|
||||
105610,
|
||||
108310,
|
||||
111100,
|
||||
112360
|
||||
],
|
||||
"end": [
|
||||
2720,
|
||||
5430,
|
||||
7160,
|
||||
10830,
|
||||
12880,
|
||||
15890,
|
||||
19000,
|
||||
22070,
|
||||
25170,
|
||||
27890,
|
||||
30590,
|
||||
32920,
|
||||
36360,
|
||||
39630,
|
||||
41170,
|
||||
42790,
|
||||
44590,
|
||||
47320,
|
||||
50250,
|
||||
51880,
|
||||
54320,
|
||||
57410,
|
||||
59160,
|
||||
62320,
|
||||
65099,
|
||||
68430,
|
||||
71360,
|
||||
73640,
|
||||
76580,
|
||||
78660,
|
||||
81480,
|
||||
83940,
|
||||
86230,
|
||||
88570,
|
||||
90520,
|
||||
93430,
|
||||
95940,
|
||||
99090,
|
||||
100910,
|
||||
103740,
|
||||
105610,
|
||||
108310,
|
||||
111100,
|
||||
112360,
|
||||
114220
|
||||
],
|
||||
"text": [
|
||||
"LILA FISHER: 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.",
|
||||
"So before we start learning about the subjects that",
|
||||
"brought you here, let's learn about the tools that you will",
|
||||
"use to navigate through the course material.",
|
||||
"Let's start with what is on your screen right now.",
|
||||
"You are watching a video of me talking.",
|
||||
"You have several tools associated with these videos.",
|
||||
"Some of them are standard video buttons, like the play",
|
||||
"Pause Button on the bottom left.",
|
||||
"Like most video players, you can see how far you are into",
|
||||
"this particular video segment and how long the entire video",
|
||||
"segment is.",
|
||||
"Something that you might not be used to",
|
||||
"is the speed option.",
|
||||
"While you are going through the videos, you can speed up",
|
||||
"or slow down the video player with these buttons.",
|
||||
"Go ahead and try that now.",
|
||||
"Make me talk faster and slower.",
|
||||
"If you ever get frustrated by the pace of speech, you can",
|
||||
"adjust it this way.",
|
||||
"Another great feature is the transcript on the side.",
|
||||
"This will follow along with everything that I am saying as",
|
||||
"I am saying it, so you can read along if you like.",
|
||||
"You can also click on any of the words, and you will notice",
|
||||
"that the video jumps to that word.",
|
||||
"The video slider at the bottom of the video will let you",
|
||||
"navigate through the video quickly.",
|
||||
"If you ever find the transcript distracting, you",
|
||||
"can toggle the captioning button in order to make it go",
|
||||
"away or reappear.",
|
||||
"Now that you know about the video player, I want to point",
|
||||
"out the sequence navigator.",
|
||||
"Right now you're in a lecture sequence, which interweaves",
|
||||
"many videos and practice exercises.",
|
||||
"You can see how far you are in a particular sequence by",
|
||||
"observing which tab you're on.",
|
||||
"You can navigate directly to any video or exercise by",
|
||||
"clicking on the appropriate tab.",
|
||||
"You can also progress to the next element by pressing the",
|
||||
"Arrow button, or by clicking on the next tab.",
|
||||
"Try that now.",
|
||||
"The tutorial will continue in the next video."
|
||||
]
|
||||
}
|
||||
143
common/test/data/uploads/subs_t_not_exist.srt.sjson
Normal file
143
common/test/data/uploads/subs_t_not_exist.srt.sjson
Normal file
@@ -0,0 +1,143 @@
|
||||
{
|
||||
"start": [
|
||||
270,
|
||||
2720,
|
||||
5430,
|
||||
7160,
|
||||
10830,
|
||||
12880,
|
||||
15890,
|
||||
19000,
|
||||
22070,
|
||||
25170,
|
||||
27890,
|
||||
30590,
|
||||
32920,
|
||||
36360,
|
||||
39630,
|
||||
41170,
|
||||
42790,
|
||||
44590,
|
||||
47320,
|
||||
50250,
|
||||
51880,
|
||||
54320,
|
||||
57410,
|
||||
59160,
|
||||
62320,
|
||||
65099,
|
||||
68430,
|
||||
71360,
|
||||
73640,
|
||||
76580,
|
||||
78660,
|
||||
81480,
|
||||
83940,
|
||||
86230,
|
||||
88570,
|
||||
90520,
|
||||
93430,
|
||||
95940,
|
||||
99090,
|
||||
100910,
|
||||
103740,
|
||||
105610,
|
||||
108310,
|
||||
111100,
|
||||
112360
|
||||
],
|
||||
"end": [
|
||||
2720,
|
||||
5430,
|
||||
7160,
|
||||
10830,
|
||||
12880,
|
||||
15890,
|
||||
19000,
|
||||
22070,
|
||||
25170,
|
||||
27890,
|
||||
30590,
|
||||
32920,
|
||||
36360,
|
||||
39630,
|
||||
41170,
|
||||
42790,
|
||||
44590,
|
||||
47320,
|
||||
50250,
|
||||
51880,
|
||||
54320,
|
||||
57410,
|
||||
59160,
|
||||
62320,
|
||||
65099,
|
||||
68430,
|
||||
71360,
|
||||
73640,
|
||||
76580,
|
||||
78660,
|
||||
81480,
|
||||
83940,
|
||||
86230,
|
||||
88570,
|
||||
90520,
|
||||
93430,
|
||||
95940,
|
||||
99090,
|
||||
100910,
|
||||
103740,
|
||||
105610,
|
||||
108310,
|
||||
111100,
|
||||
112360,
|
||||
114220
|
||||
],
|
||||
"text": [
|
||||
"LILA FISHER: 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.",
|
||||
"So before we start learning about the subjects that",
|
||||
"brought you here, let's learn about the tools that you will",
|
||||
"use to navigate through the course material.",
|
||||
"Let's start with what is on your screen right now.",
|
||||
"You are watching a video of me talking.",
|
||||
"You have several tools associated with these videos.",
|
||||
"Some of them are standard video buttons, like the play",
|
||||
"Pause Button on the bottom left.",
|
||||
"Like most video players, you can see how far you are into",
|
||||
"this particular video segment and how long the entire video",
|
||||
"segment is.",
|
||||
"Something that you might not be used to",
|
||||
"is the speed option.",
|
||||
"While you are going through the videos, you can speed up",
|
||||
"or slow down the video player with these buttons.",
|
||||
"Go ahead and try that now.",
|
||||
"Make me talk faster and slower.",
|
||||
"If you ever get frustrated by the pace of speech, you can",
|
||||
"adjust it this way.",
|
||||
"Another great feature is the transcript on the side.",
|
||||
"This will follow along with everything that I am saying as",
|
||||
"I am saying it, so you can read along if you like.",
|
||||
"You can also click on any of the words, and you will notice",
|
||||
"that the video jumps to that word.",
|
||||
"The video slider at the bottom of the video will let you",
|
||||
"navigate through the video quickly.",
|
||||
"If you ever find the transcript distracting, you",
|
||||
"can toggle the captioning button in order to make it go",
|
||||
"away or reappear.",
|
||||
"Now that you know about the video player, I want to point",
|
||||
"out the sequence navigator.",
|
||||
"Right now you're in a lecture sequence, which interweaves",
|
||||
"many videos and practice exercises.",
|
||||
"You can see how far you are in a particular sequence by",
|
||||
"observing which tab you're on.",
|
||||
"You can navigate directly to any video or exercise by",
|
||||
"clicking on the appropriate tab.",
|
||||
"You can also progress to the next element by pressing the",
|
||||
"Arrow button, or by clicking on the next tab.",
|
||||
"Try that now.",
|
||||
"The tutorial will continue in the next video."
|
||||
]
|
||||
}
|
||||
43
common/test/data/uploads/test_transcripts.srt
Normal file
43
common/test/data/uploads/test_transcripts.srt
Normal file
@@ -0,0 +1,43 @@
|
||||
0
|
||||
00:00:00,270 --> 00:00:02,720
|
||||
LILA FISHER: Hi, welcome to Edx.
|
||||
|
||||
1
|
||||
00:00:02,720 --> 00:00:05,430
|
||||
I'm Lila Fisher, an Edx fellow helping to put
|
||||
|
||||
2
|
||||
00:00:05,430 --> 00:00:07,160
|
||||
together these courses.
|
||||
|
||||
3
|
||||
00:00:07,160 --> 00:00:10,830
|
||||
As you know, our courses are entirely online.
|
||||
|
||||
4
|
||||
00:00:10,830 --> 00:00:12,880
|
||||
So before we start learning about the subjects that
|
||||
|
||||
5
|
||||
00:00:12,880 --> 00:00:15,890
|
||||
brought you here, let's learn about the tools that you will
|
||||
|
||||
6
|
||||
00:00:15,890 --> 00:00:19,000
|
||||
use to navigate through the course material.
|
||||
|
||||
7
|
||||
00:00:19,000 --> 00:00:22,070
|
||||
Let's start with what is on your screen right now.
|
||||
|
||||
8
|
||||
00:00:22,070 --> 00:00:25,170
|
||||
You are watching a video of me talking.
|
||||
|
||||
9
|
||||
00:00:25,170 --> 00:00:27,890
|
||||
You have several tools associated with these videos.
|
||||
|
||||
10
|
||||
00:00:27,890 --> 00:00:30,590
|
||||
Some of them are standard video buttons, like the play
|
||||
@@ -2,5 +2,9 @@
|
||||
CMS module
|
||||
*******************************************
|
||||
|
||||
.. module:: cms
|
||||
|
||||
.. module:: cms
|
||||
.. toctree::
|
||||
|
||||
transcripts.rst
|
||||
|
||||
|
||||
247
docs/developers/source/transcripts.rst
Normal file
247
docs/developers/source/transcripts.rst
Normal file
@@ -0,0 +1,247 @@
|
||||
.. module:: transcripts
|
||||
|
||||
======================================================
|
||||
Developer’s workflow for the timed transcripts in CMS.
|
||||
======================================================
|
||||
|
||||
:download:`Multipage pdf version of Timed Transcripts workflow. <transcripts_workflow.pdf>`
|
||||
|
||||
:download:`Open office graph version (source for pdf). <transcripts_workflow.odg>`
|
||||
|
||||
:download:`List of implemented acceptance tests. <transcripts_acceptance_tests.odt>`
|
||||
|
||||
|
||||
Description
|
||||
===========
|
||||
|
||||
Timed Transcripts functionality is added in separate tab of Video module Editor, that is active by default. This tab is called `Basic`, another tab is called `Advanced` and contains default metadata fields.
|
||||
|
||||
`Basic` tab is a simple representation of `Advanced` tab that provides functionality to speed up adding Video module with transcripts to the course.
|
||||
|
||||
To make more accurate adjustments `Advanced` tab should be used.
|
||||
|
||||
Front-end part of `Basic` tab has 4 editors/views:
|
||||
* Display name
|
||||
* 3 editors for inserting Video URLs.
|
||||
|
||||
Video URL fields might contain 3 kinds of URLs:
|
||||
* **YouTube** link. There are supported formats:
|
||||
* http://www.youtube.com/watch?v=OEoXaMPEzfM&feature=feedrec_grec_index ;
|
||||
* http://www.youtube.com/user/IngridMichaelsonVEVO#p/a/u/1/OEoXaMPEzfM ;
|
||||
* http://www.youtube.com/v/OEoXaMPEzfM?fs=1&hl=en_US&rel=0 ;
|
||||
* http://www.youtube.com/watch?v=OEoXaMPEzfM#t=0m10s ;
|
||||
* http://www.youtube.com/embed/OEoXaMPEzfM?rel=0 ;
|
||||
* http://www.youtube.com/watch?v=OEoXaMPEzfM ;
|
||||
* http://youtu.be/OEoXaMPEzfM ;
|
||||
|
||||
* **MP4** video source;
|
||||
* **WEBM** video source.
|
||||
|
||||
Each of these kind of URLs can be specified just **ONCE**. Otherwise, error message occurs on front-end.
|
||||
|
||||
After filling editor **transcripts/check** method will be invoked with the parameters described below (see `API`_). Depending on conditions, that are also described below (see `Commands`_), this method responds with a *command* and front-end renders the appropriate View.
|
||||
Each View can have specific actions. There is a list of supported actions:
|
||||
* Download Timed Transcripts;
|
||||
* Upload Timed Transcripts;
|
||||
* Import Timed Transcripts from YouTube;
|
||||
* Replace edX Timed Transcripts by Timed Transcripts from YouTube;
|
||||
* Choose Timed Transcripts;
|
||||
* Use existing Timed Transcripts.
|
||||
|
||||
All of these actions are handled by 7 API methods described below (see `API`_).
|
||||
|
||||
Because rollback functionality isn't implemented now, after invoking some of the actions user cannot revert changes by clicking button `Cancel`.
|
||||
|
||||
To remove timed transcripts file from the video just go to `Advanced` tab and clear field `sub` then Save changes.
|
||||
|
||||
|
||||
Commands
|
||||
========
|
||||
|
||||
Command from front-end point of view is just a reference to the needed View with possible actions that user can do depending on conditions described below (See edx-platform/cms/static/js/views/transcripts/message_manager.js:21-29).
|
||||
|
||||
So,
|
||||
* **IF** YouTube transcripts present locally **AND** on YouTube server **AND** both of these transcripts files are **DIFFERENT**, we respond with `replace` command. Ask user to replace local transcripts file by YouTube's ones.
|
||||
* **IF** YouTube transcripts present **ONLY** locally, we respond with `found` command.
|
||||
* **IF** YouTube transcripts present **ONLY** on YouTube server, we respond with `import` command. Ask user to import transcripts file from YouTube server.
|
||||
* **IF** player is in HTML5 video mode. It means that **ONLY** html5 sources are added:
|
||||
* **IF** just 1 html5 source was added or both html5 sources have **EQUAL** transcripts files, then we respond with `found` command.
|
||||
* **OTHERWISE**, when 2 html5 sources were added and founded transcripts files are **DIFFERENT**, we respond with `choose` command. In this case, user should choose which one transcripts file he wants to use.
|
||||
* **IF** we are working with just 1 field **AND** item.sub field **HAS** a value **AND** user fills editor/view by the new value/video source without transcripts file, we respond with `use_existing` command. In this case, user will have possibility to use transcripts file from previous video.
|
||||
* **OTHERWISE**, we will respond with `not_found` command.
|
||||
|
||||
|
||||
Synchronization and Saving workflow
|
||||
====================================
|
||||
|
||||
|
||||
For now saving mechanism works as follows:
|
||||
|
||||
On click `Save` button **ModuleEdit** class (See edx-platform/cms/static/coffee/src/views/module_edit.coffee:83-101) grabs values from all modified metadata fields and sends all this data to the server.
|
||||
|
||||
Because of the fact that Timed Transcripts is module specific functionality, ModuleEdit class is not extended. Instead, to apply all changes that user did in the `Basic` tab, we use synchronization mechanism of TabsEditingDescriptor class. That mechanism provides us possibility to do needed actions on Tab switching and on Save (See edx-platform/cms/templates/widgets/video/transcripts.html).
|
||||
|
||||
On tab switching and when save action is invoked, JavaScript code synchronize collections (Metadata Collection and Transcripts Collection). You can see synchronization logic in the edx-platform/cms/static/js/views/transcripts/editor.js:72-219. In this case, Metadata fields always have the actual data.
|
||||
|
||||
|
||||
API
|
||||
===
|
||||
|
||||
We provide 7 API methods to work with timed transcripts
|
||||
(edx-platform/cms/urls.py:23-29):
|
||||
* transcripts/upload
|
||||
* transcripts/download
|
||||
* transcripts/check
|
||||
* transcripts/choose
|
||||
* transcripts/replace
|
||||
* transcripts/rename
|
||||
* transcripts/save
|
||||
|
||||
**"transcripts/upload"** method is used for uploading SRT transcripts for the
|
||||
HTML5 and YouTube video modules.
|
||||
|
||||
*Method:*
|
||||
POST
|
||||
*Parameters:*
|
||||
- id - location ID of the Xmodule
|
||||
- video_list - list with information about the links currently passed in the editor/view.
|
||||
- file - BLOB file
|
||||
*Response:*
|
||||
HTTP 400
|
||||
or
|
||||
HTTP 200 + JSON:
|
||||
.. code::
|
||||
{
|
||||
status: 'Success' or 'Error',
|
||||
subs: value of uploaded and saved sub field in the video item.
|
||||
}
|
||||
|
||||
|
||||
**"transcripts/download"** method is used for downloading SRT transcripts for the
|
||||
HTML5 and YouTube video modules.
|
||||
|
||||
*Method:*
|
||||
GET
|
||||
*Parameters:*
|
||||
- id - location ID of the Xmodule
|
||||
- subs_id - file name that is used to find transcripts file in the storage.
|
||||
*Response:*
|
||||
HTTP 404
|
||||
or
|
||||
HTTP 200 + BLOB of SRT file
|
||||
|
||||
|
||||
**"transcripts/check"** method is used for checking availability of timed transcripts
|
||||
for the video module.
|
||||
|
||||
*Method:*
|
||||
GET
|
||||
*Parameters:*
|
||||
- id - location ID of the Xmodule
|
||||
*Response:*
|
||||
HTTP 400
|
||||
or
|
||||
HTTP 200 + JSON:
|
||||
.. code::
|
||||
{
|
||||
command: string with action to front-end what to do and what to show to user,
|
||||
subs: file name of transcripts file that was found in the storage,
|
||||
html5_local: [] or [True] or [True, True],
|
||||
is_youtube_mode: True/False,
|
||||
youtube_local: True/False,
|
||||
youtube_server: True/False,
|
||||
youtube_diff: True/False,
|
||||
current_item_subs: string with value of item.sub field,
|
||||
status: 'Error' or 'Success'
|
||||
}
|
||||
|
||||
|
||||
**"transcripts/choose"** method is used for choosing which transcripts file should be used.
|
||||
|
||||
*Method:*
|
||||
GET
|
||||
*Parameters:*
|
||||
- id - location ID of the Xmodule
|
||||
- video_list - list with information about the links currently passed in the editor/view.
|
||||
- html5_id - file name of chosen transcripts file.
|
||||
|
||||
*Response:*
|
||||
HTTP 200 + JSON:
|
||||
.. code::
|
||||
{
|
||||
status: 'Success' or 'Error',
|
||||
subs: value of uploaded and saved sub field in the video item.
|
||||
}
|
||||
|
||||
|
||||
**"transcripts/replace"** method is used for handling `import` and `replace` commands.
|
||||
Invoking this method starts downloading new transcripts file from YouTube server.
|
||||
|
||||
*Method:*
|
||||
GET
|
||||
*Parameters:*
|
||||
- id - location ID of the Xmodule
|
||||
- video_list - list with information about the links currently passed in the editor/view.
|
||||
|
||||
*Response:*
|
||||
HTTP 400
|
||||
or
|
||||
HTTP 200 + JSON:
|
||||
.. code::
|
||||
{
|
||||
status: 'Success' or 'Error',
|
||||
subs: value of uploaded and saved sub field in the video item.
|
||||
}
|
||||
|
||||
|
||||
**"transcripts/rename"** method is used for handling `use_existing` command.
|
||||
After invoking this method current transcripts file will be copied and renamed to another one with name of current video passed in the editor/view.
|
||||
|
||||
*Method:*
|
||||
GET
|
||||
*Parameters:*
|
||||
- id - location ID of the Xmodule
|
||||
- video_list - list with information about the links currently passed in the editor/view.
|
||||
|
||||
*Response:*
|
||||
HTTP 400
|
||||
or
|
||||
HTTP 200 + JSON:
|
||||
.. code::
|
||||
{
|
||||
status: 'Success' or 'Error',
|
||||
subs: value of uploaded and saved sub field in the video item.
|
||||
}
|
||||
|
||||
|
||||
**"transcripts/save"** method is used for handling `save` command.
|
||||
After invoking this method all changes will be saved that were done before this moment.
|
||||
|
||||
*Method:*
|
||||
GET
|
||||
*Parameters:*
|
||||
- id - location ID of the Xmodule
|
||||
- metadata - new values for the metadata fields.
|
||||
- currents_subs - list with the file names of videos passed in the editor/view.
|
||||
|
||||
*Response:*
|
||||
HTTP 400
|
||||
or
|
||||
HTTP 200 + JSON:
|
||||
.. code::
|
||||
{
|
||||
status: 'Success' or 'Error'
|
||||
}
|
||||
|
||||
|
||||
Transcripts modules:
|
||||
====================
|
||||
|
||||
.. automodule:: contentstore.views.transcripts_ajax
|
||||
:members:
|
||||
:show-inheritance:
|
||||
|
||||
.. automodule:: contentstore.transcripts_utils
|
||||
:members:
|
||||
:show-inheritance:
|
||||
|
||||
BIN
docs/developers/source/transcripts_acceptance_tests.odt
Normal file
BIN
docs/developers/source/transcripts_acceptance_tests.odt
Normal file
Binary file not shown.
BIN
docs/developers/source/transcripts_workflow.odg
Normal file
BIN
docs/developers/source/transcripts_workflow.odg
Normal file
Binary file not shown.
BIN
docs/developers/source/transcripts_workflow.pdf
Normal file
BIN
docs/developers/source/transcripts_workflow.pdf
Normal file
Binary file not shown.
@@ -1,7 +1,6 @@
|
||||
#pylint: disable=C0111
|
||||
#pylint: disable=W0621
|
||||
|
||||
from courseware.mock_youtube_server.mock_youtube_server import MockYoutubeServer
|
||||
from xmodule.util.mock_youtube_server.mock_youtube_server import MockYoutubeServer
|
||||
from lettuce import before, after, world
|
||||
from django.conf import settings
|
||||
import threading
|
||||
@@ -25,6 +24,8 @@ def setup_mock_youtube_server():
|
||||
|
||||
server.time_to_response = 1 # seconds
|
||||
|
||||
server.address = address
|
||||
|
||||
# Start the server running in a separate daemon thread
|
||||
# Because the thread is a daemon, it will terminate
|
||||
# when the main thread terminates.
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
from BaseHTTPServer import HTTPServer, BaseHTTPRequestHandler
|
||||
import urlparse
|
||||
import mock
|
||||
import threading
|
||||
import json
|
||||
from logging import getLogger
|
||||
logger = getLogger(__name__)
|
||||
import time
|
||||
|
||||
class MockYoutubeRequestHandler(BaseHTTPRequestHandler):
|
||||
'''
|
||||
A handler for Youtube GET requests.
|
||||
'''
|
||||
|
||||
protocol = "HTTP/1.0"
|
||||
|
||||
def do_HEAD(self):
|
||||
self._send_head()
|
||||
|
||||
def do_GET(self):
|
||||
'''
|
||||
Handle a GET request from the client and sends response back.
|
||||
'''
|
||||
self._send_head()
|
||||
|
||||
logger.debug("Youtube provider received GET request to path {}".format(
|
||||
self.path)
|
||||
) # Log the request
|
||||
|
||||
status_message = "I'm youtube."
|
||||
response_timeout = float(self.server.time_to_response)
|
||||
|
||||
# threading timer produces TypeError: 'NoneType' object is not callable here
|
||||
# so we use time.sleep, as we already in separate thread.
|
||||
time.sleep(response_timeout)
|
||||
self._send_response(status_message)
|
||||
|
||||
def _send_head(self):
|
||||
'''
|
||||
Send the response code and MIME headers
|
||||
'''
|
||||
self.send_response(200)
|
||||
self.send_header('Content-type', 'text/html')
|
||||
self.end_headers()
|
||||
|
||||
def _send_response(self, message):
|
||||
'''
|
||||
Send message back to the client
|
||||
'''
|
||||
callback = urlparse.parse_qs(self.path)['callback'][0]
|
||||
response = callback + '({})'.format(json.dumps({'message': message}))
|
||||
# Log the response
|
||||
logger.debug("Youtube: sent response {}".format(message))
|
||||
|
||||
self.wfile.write(response)
|
||||
|
||||
|
||||
class MockYoutubeServer(HTTPServer):
|
||||
'''
|
||||
A mock Youtube provider server that responds
|
||||
to GET requests to localhost.
|
||||
'''
|
||||
|
||||
def __init__(self, address):
|
||||
'''
|
||||
Initialize the mock XQueue server instance.
|
||||
|
||||
*address* is the (host, host's port to listen to) tuple.
|
||||
'''
|
||||
handler = MockYoutubeRequestHandler
|
||||
HTTPServer.__init__(self, address, handler)
|
||||
|
||||
def shutdown(self):
|
||||
'''
|
||||
Stop the server and free up the port
|
||||
'''
|
||||
# First call superclass shutdown()
|
||||
HTTPServer.shutdown(self)
|
||||
# We also need to manually close the socket
|
||||
self.socket.close()
|
||||
@@ -1,53 +0,0 @@
|
||||
"""
|
||||
Test for Mock_Youtube_Server
|
||||
"""
|
||||
import unittest
|
||||
import threading
|
||||
import urllib
|
||||
from mock_youtube_server import MockYoutubeServer
|
||||
|
||||
from nose.plugins.skip import SkipTest
|
||||
|
||||
|
||||
class MockYoutubeServerTest(unittest.TestCase):
|
||||
'''
|
||||
A mock version of the Youtube provider server that listens on a local
|
||||
port and responds with jsonp.
|
||||
|
||||
Used for lettuce BDD tests in lms/courseware/features/video.feature
|
||||
'''
|
||||
|
||||
def setUp(self):
|
||||
|
||||
# This is a test of the test setup,
|
||||
# so it does not need to run as part of the unit test suite
|
||||
# You can re-enable it by commenting out the line below
|
||||
raise SkipTest
|
||||
|
||||
# Create the server
|
||||
server_port = 8034
|
||||
server_host = '127.0.0.1'
|
||||
address = (server_host, server_port)
|
||||
self.server = MockYoutubeServer(address, )
|
||||
self.server.time_to_response = 0.5
|
||||
# Start the server in a separate daemon thread
|
||||
server_thread = threading.Thread(target=self.server.serve_forever)
|
||||
server_thread.daemon = True
|
||||
server_thread.start()
|
||||
|
||||
def tearDown(self):
|
||||
|
||||
# Stop the server, freeing up the port
|
||||
self.server.shutdown()
|
||||
|
||||
def test_request(self):
|
||||
"""
|
||||
Tests that Youtube server processes request with right program
|
||||
path, and responses with incorrect signature.
|
||||
"""
|
||||
# GET request
|
||||
response_handle = urllib.urlopen(
|
||||
'http://127.0.0.1:8034/feeds/api/videos/OEoXaMPEzfM?v=2&alt=jsonc&callback=callback_func',
|
||||
)
|
||||
response = response_handle.read()
|
||||
self.assertEqual("""callback_func({"message": "I\'m youtube."})""", response)
|
||||
@@ -5,7 +5,7 @@ desc "Invoke sphinx 'make build' to generate docs."
|
||||
task :builddocs, [:type, :quiet] do |t, args|
|
||||
args.with_defaults(:quiet => "quiet")
|
||||
if args.type == 'dev'
|
||||
path = "docs/developer"
|
||||
path = "docs/developers"
|
||||
elsif args.type == 'author'
|
||||
path = "docs/course_authors"
|
||||
elsif args.type == 'data'
|
||||
@@ -26,7 +26,7 @@ end
|
||||
desc "Show docs in browser (mac and ubuntu)."
|
||||
task :showdocs, [:options] do |t, args|
|
||||
if args.options == 'dev'
|
||||
path = "docs/developer"
|
||||
path = "docs/developers"
|
||||
elsif args.options == 'author'
|
||||
path = "docs/course_authors"
|
||||
elsif args.options == 'data'
|
||||
|
||||
@@ -56,6 +56,7 @@ pyparsing==1.5.6
|
||||
python-memcached==1.48
|
||||
python-openid==2.2.5
|
||||
pytz==2012h
|
||||
pysrt==0.4.7
|
||||
PyYAML==3.10
|
||||
requests==1.2.3
|
||||
scipy==0.11.0
|
||||
|
||||
Reference in New Issue
Block a user