Skip to content

Commit

Permalink
refactor: [AXM-583] refactor offline content generation
Browse files Browse the repository at this point in the history
  • Loading branch information
NiedielnitsevIvan authored and GlugovGrGlib committed Jun 11, 2024
1 parent 3613e7d commit a50ce23
Show file tree
Hide file tree
Showing 7 changed files with 153 additions and 117 deletions.
42 changes: 42 additions & 0 deletions lms/templates/courseware/courseware-chromeless.html
Original file line number Diff line number Diff line change
Expand Up @@ -216,3 +216,45 @@
}());
</script>
% endif

% if is_offline_content:
<script type="text/javascript">
(function() {
function sendMessageToiOS(message) {
window?.webkit?.messageHandlers?.iOSBridge?.postMessage(message);
}

function sendMessageToAndroid(message) {
window?.AndroidBridge?.postMessage(message);
}

function receiveMessageFromiOS(message) {
console.log("Message received from iOS:", message);
}

function receiveMessageFromAndroid(message) {
console.log("Message received from Android:", message);
}

if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.iOSBridge) {
window.addEventListener("messageFromiOS", function (event) {
receiveMessageFromiOS(event.data);
});
}
if (window.AndroidBridge) {
window.addEventListener("messageFromAndroid", function (event) {
receiveMessageFromAndroid(event.data);
});
}
const originalAjax = $.ajax;
$.ajax = function (options) {
sendMessageToiOS(options);
sendMessageToiOS(JSON.stringify(options));
sendMessageToAndroid(options);
sendMessageToAndroid(JSON.stringify(options));
console.log(options, JSON.stringify(options));
return originalAjax.call(this, options);
};
}());
</script>
% endif
32 changes: 22 additions & 10 deletions openedx/features/offline_mode/assets_management.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
import requests

from django.conf import settings
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage

from xmodule.assetstore.assetmgr import AssetManager
Expand Down Expand Up @@ -37,13 +36,14 @@ def read_static_file(path):
return file.read()


def save_asset_file(xblock, path, filename):
def save_asset_file(temp_dir, xblock, path, filename):
"""
Saves an asset file to the default storage.
If the filename contains a '/', it reads the static file directly from the file system.
Otherwise, it fetches the asset from the AssetManager.
Args:
temp_dir (str): The temporary directory where the assets are stored.
xblock (XBlock): The XBlock instance
path (str): The path where the asset is located.
filename (str): The name of the file to be saved.
Expand All @@ -62,9 +62,19 @@ def save_asset_file(xblock, path, filename):
log.info(f"Asset not found: {filename}")

else:
base_path = block_storage_path(xblock)
file_path = os.path.join(base_path, 'assets', filename)
default_storage.save(file_path, ContentFile(content))
assets_path = os.path.join(temp_dir, 'assets')
file_path = os.path.join(assets_path, filename)
create_subdirectories_for_asset(file_path)
with open(file_path, 'wb') as file:
file.write(content)


def create_subdirectories_for_asset(file_path):
out_dir_name = '/'
for dir_name in file_path.split('/')[:-1]:
out_dir_name = os.path.join(out_dir_name, dir_name)
if out_dir_name and not os.path.exists(out_dir_name):
os.mkdir(out_dir_name)


def remove_old_files(xblock):
Expand Down Expand Up @@ -128,7 +138,7 @@ def block_storage_path(xblock=None, usage_key=None):
str: The constructed base storage path.
"""
loc = usage_key or getattr(xblock, 'location', None)
return f'{str(loc.course_key)}/{loc.block_id}/' if loc else ''
return f'{str(loc.course_key)}/' if loc else ''


def is_modified(xblock):
Expand All @@ -148,15 +158,17 @@ def is_modified(xblock):
return xblock.published_on > last_modified


def save_mathjax_to_xblock_assets(xblock):
def save_mathjax_to_xblock_assets(temp_dir):
"""
Saves MathJax to the local static directory.
If MathJax is not already saved, it fetches MathJax from
the CDN and saves it to the local static directory.
"""
file_path = os.path.join(block_storage_path(xblock), MATHJAX_STATIC_PATH)
if not default_storage.exists(file_path):
file_path = os.path.join(temp_dir, MATHJAX_STATIC_PATH)
if not os.path.exists(file_path):
response = requests.get(MATHJAX_CDN_URL)
default_storage.save(file_path, ContentFile(response.content))
with open(file_path, 'wb') as file:
file.write(response.content)

log.info(f"Successfully saved MathJax to {file_path}")
2 changes: 1 addition & 1 deletion openedx/features/offline_mode/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,5 @@
MATHJAX_CDN_URL = f'https://cdn.jsdelivr.net/npm/mathjax@{MATHJAX_VERSION}/MathJax.js'
MATHJAX_STATIC_PATH = os.path.join('assets', 'js', f'MathJax-{MATHJAX_VERSION}.js')

DEFAULT_OFFLINE_SUPPORTED_XBLOCKS = ['problem']
DEFAULT_OFFLINE_SUPPORTED_XBLOCKS = ['html']
OFFLINE_SUPPORTED_XBLOCKS = getattr(settings, 'OFFLINE_SUPPORTED_XBLOCKS', DEFAULT_OFFLINE_SUPPORTED_XBLOCKS)
48 changes: 17 additions & 31 deletions openedx/features/offline_mode/html_manipulator.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,29 @@ class HtmlManipulator:
Changes links to static files to paths to pre-generated static files for offline use.
"""

def __init__(self, xblock, html_data):
def __init__(self, xblock, html_data, temp_dir):
self.html_data = html_data
self.xblock = xblock
self.temp_dir = temp_dir

def process_html(self):
"""
Prepares HTML content for local usage.
Changes links to static files to paths to pre-generated static files for offline use.
"""
self._replace_static_links()
self._replace_mathjax_link()

soup = BeautifulSoup(self.html_data, 'html.parser')
self._replace_iframe(soup)
return str(soup)

def _replace_mathjax_link(self):
"""
Replace MathJax CDN link with local path to MathJax.js file.
"""
save_mathjax_to_xblock_assets(self.xblock)
save_mathjax_to_xblock_assets(self.temp_dir)
mathjax_pattern = re.compile(fr'src="{MATHJAX_CDN_URL}[^"]*"')
self.html_data = mathjax_pattern.sub(f'src="{MATHJAX_STATIC_PATH}"', self.html_data)

Expand All @@ -45,7 +59,7 @@ def _replace_link(self, match):
"""
link = match.group()
filename = link.split(settings.STATIC_URL)[-1]
save_asset_file(self.xblock, link, filename)
save_asset_file(self.temp_dir, self.xblock, link, filename)
return f'assets/{filename}'

@staticmethod
Expand All @@ -60,31 +74,3 @@ def _replace_iframe(soup):
tag_a.string = node.get('title', node.get('src'))
replacement.append(tag_a)
node.replace_with(replacement)

@staticmethod
def _add_js_bridge(soup):
"""
Add JS bridge script to the HTML content.
"""
script_tag = soup.new_tag('script')
with open('openedx/features/offline_mode/static/offline_mode/js/bridge.js', 'r') as file:
script_tag.string = file.read()
if soup.body:
soup.body.append(script_tag)
else:
soup.append(script_tag)
return soup

def process_html(self):
"""
Prepares HTML content for local use.
Changes links to static files to paths to pre-generated static files for offline use.
"""
self._replace_static_links()
self._replace_mathjax_link()

soup = BeautifulSoup(self.html_data, 'html.parser')
self._replace_iframe(soup)
self._add_js_bridge(soup)
return str(soup)
1 change: 1 addition & 0 deletions openedx/features/offline_mode/renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ def render_xblock_from_lms(self):
'on_courseware_page': True,
'is_learning_mfe': False,
'is_mobile_app': True,
'is_offline_content': True,
'render_course_wide_assets': True,
'LANGUAGE_CODE': 'en',

Expand Down
35 changes: 0 additions & 35 deletions openedx/features/offline_mode/static/offline_mode/js/bridge.js

This file was deleted.

110 changes: 70 additions & 40 deletions openedx/features/offline_mode/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
"""
import os
import logging
import shutil
from tempfile import mkdtemp

from django.contrib.auth import get_user_model
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage

from zipfile import ZipFile
Expand All @@ -17,56 +18,85 @@
log = logging.getLogger(__name__)


def create_zip_file(base_path, file_name):
"""
Creates a zip file with the content of the base_path directory.
"""
zf = ZipFile(default_storage.path(base_path + file_name), 'w')
zf.write(default_storage.path(base_path + 'index.html'), 'index.html')

def add_files_to_zip(zip_file, current_base_path, current_path_in_zip):
"""
Recursively adds files to the zip file.
"""
try:
directories, filenames = default_storage.listdir(current_base_path)
except OSError:
return

for filename in filenames:
full_path = os.path.join(current_base_path, filename)
zip_file.write(full_path, os.path.join(current_path_in_zip, filename))

for directory in directories:
add_files_to_zip(
zip_file,
os.path.join(current_base_path, directory),
os.path.join(current_path_in_zip, directory)
)

add_files_to_zip(zf, default_storage.path(base_path + 'assets/'), 'assets')
zf.close()
log.info(f'Offline content for {file_name} has been generated.')


def generate_offline_content(xblock, html_data):
"""
Generates archive with XBlock content for offline mode.
Args:
xblock (XBlock): The XBlock instance
html_data (str): The HTML data of the XBlock
html_data (str): The rendered HTML representation of the XBlock
"""
if not is_modified(xblock):
return

base_path = block_storage_path(xblock)
remove_old_files(xblock)
html_manipulator = HtmlManipulator(xblock, html_data)
tmp_dir = mkdtemp()

try:
save_xblock_html(tmp_dir, xblock, html_data)
create_zip_file(tmp_dir, base_path, f'{xblock.location.block_id}.zip')
finally:
shutil.rmtree(tmp_dir, ignore_errors=True)


def save_xblock_html(tmp_dir, xblock, html_data):
"""
Saves the XBlock HTML content to a file.
Generates the 'index.html' file with the HTML added to use it locally.
Args:
tmp_dir (str): The temporary directory path to save the xblock content
xblock (XBlock): The XBlock instance
html_data (str): The rendered HTML representation of the XBlock
"""
html_manipulator = HtmlManipulator(xblock, html_data, tmp_dir)
updated_html = html_manipulator.process_html()

default_storage.save(
os.path.join(base_path, 'index.html'),
ContentFile(updated_html),
)
create_zip_file(base_path, f'{xblock.location.block_id}.zip')
with open(os.path.join(tmp_dir, 'index.html'), 'w') as file:
file.write(updated_html)


def create_zip_file(temp_dir, base_path, file_name):
"""
Creates a zip file with the content of the base_path directory.
Args:
temp_dir (str): The temporary directory path where the content is stored
base_path (str): The base path directory to save the zip file
file_name (str): The name of the zip file
"""
if not os.path.exists(default_storage.path(base_path)):
os.makedirs(default_storage.path(base_path))

with ZipFile(default_storage.path(base_path + file_name), 'w') as zip_file:
zip_file.write(os.path.join(temp_dir, 'index.html'), 'index.html')
add_files_to_zip_recursively(
zip_file,
current_base_path=os.path.join(temp_dir, 'assets'),
current_path_in_zip='assets',
)
log.info(f'Offline content for {file_name} has been generated.')


def add_files_to_zip_recursively(zip_file, current_base_path, current_path_in_zip):
"""
Recursively adds files to the zip file.
Args:
zip_file (ZipFile): The zip file object
current_base_path (str): The current base path directory
current_path_in_zip (str): The current path in the zip file
"""
try:
for resource_path in os.listdir(current_base_path):
full_path = os.path.join(current_base_path, resource_path)
full_path_in_zip = os.path.join(current_path_in_zip, resource_path)
if os.path.isfile(full_path):
zip_file.write(full_path, full_path_in_zip)
else:
add_files_to_zip_recursively(zip_file, full_path, full_path_in_zip)
except OSError:
log.error(f'Error while reading the directory: {current_base_path}')
return

0 comments on commit a50ce23

Please sign in to comment.