Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions src/lib/hash-parser-hoc.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,18 @@ const HashParserHOC = function (WrappedComponent) {
window.removeEventListener('hashchange', this.handleHashChange);
}
handleHashChange () {
const hashMatch = window.location.hash.match(/#(\d+)/);
const hashProjectId = hashMatch === null ? defaultProjectId : hashMatch[1];
this.props.setProjectId(hashProjectId.toString());
const hash = window.location.hash.substring(1); // Remove #

// Check if hash is a URL (starts with http:// or https://)
if (hash.match(/^https?:\/\//)) {
// URL case: set URL as projectId
this.props.setProjectId(hash);
} else {
// Traditional numeric ID handling (maintain backward compatibility)
const hashMatch = hash.match(/^(\d+)$/);
const hashProjectId = hashMatch === null ? defaultProjectId : hashMatch[1];
this.props.setProjectId(hashProjectId.toString());
}
}
render () {
const {
Expand Down
85 changes: 83 additions & 2 deletions src/lib/project-fetcher-hoc.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,10 @@ const ProjectFetcherHOC = function (WrappedComponent) {
constructor (props) {
super(props);
bindAll(this, [
'fetchProject'
'fetchProject',
'fetchProjectFromUrl',
'getFilenameFromUrl',
'getProjectTitleFromFilename'
]);
storage.setProjectHost(props.projectHost);
storage.setProjectToken(props.projectToken);
Expand Down Expand Up @@ -73,6 +76,11 @@ const ProjectFetcherHOC = function (WrappedComponent) {
}
}
fetchProject (projectId, loadingState) {
// Check if projectId is a URL
if (projectId.match(/^https?:\/\//)) {
return this.fetchProjectFromUrl(projectId, loadingState);
}

if (projectId !== '0' && !this.props.projectToken) {
const errorHandler = err => {
this.props.onError(err);
Expand Down Expand Up @@ -124,6 +132,75 @@ const ProjectFetcherHOC = function (WrappedComponent) {
log.error(err);
});
}

async fetchProjectFromUrl (projectUrl, loadingState) {
try {
// Use Smalruby CORS proxy to avoid CORS restrictions
const corsProxyUrl = 'https://api.smalruby.app/cors-proxy';
const proxiedUrl = `${corsProxyUrl}?url=${encodeURIComponent(projectUrl)}`;

// Download SB3 file from URL via Smalruby CORS proxy
const response = await fetch(proxiedUrl);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}

// Get as ArrayBuffer
const arrayBuffer = await response.arrayBuffer();

// Load project using vm.loadProject
if (this.props.vm) {
await this.props.vm.loadProject(arrayBuffer);

// Set project title (inferred from URL)
const filename = this.getFilenameFromUrl(projectUrl);
if (filename && this.props.onSetProjectTitle) {
const projectTitle = this.getProjectTitleFromFilename(filename);
this.props.onSetProjectTitle(projectTitle);
}

// Notify load completion
this.props.onFetchedProjectData(null, loadingState);
} else {
throw new Error('VM instance not available');
}

} catch (error) {
this.props.onError(error);
log.error(error);
}
}

getFilenameFromUrl (url) {
try {
// Extract filename from URL path
const urlObj = new URL(url);
const path = urlObj.pathname;
const filename = path.substring(path.lastIndexOf('/') + 1);
// If no filename in path, try to extract from search params (e.g., Google Drive)
if (!filename || filename === '') {
const params = new URLSearchParams(urlObj.search);
// Google Drive specific: try to extract from id parameter
const id = params.get('id');
if (id) {
return `project_${id}.sb3`;
}
return 'project.sb3';
}
return filename;
} catch (e) {
return 'project.sb3';
}
}

getProjectTitleFromFilename (fileInputFilename) {
if (!fileInputFilename) return '';
// Only parse title with valid scratch project extensions (.sb, .sb2, and .sb3)
const matches = fileInputFilename.match(/^(.*)\\.sb[23]?$/);
if (!matches) return '';
return matches[1].substring(0, 100); // truncate project title to max 100 chars
}

render () {
const {
/* eslint-disable no-unused-vars */
Expand Down Expand Up @@ -164,11 +241,15 @@ const ProjectFetcherHOC = function (WrappedComponent) {
onError: PropTypes.func,
onFetchedProjectData: PropTypes.func,
onProjectUnchanged: PropTypes.func,
onSetProjectTitle: PropTypes.func,
projectHost: PropTypes.string,
projectToken: PropTypes.string,
projectId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
reduxProjectId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
setProjectId: PropTypes.func
setProjectId: PropTypes.func,
vm: PropTypes.shape({
loadProject: PropTypes.func
})
};
ProjectFetcherComponent.defaultProps = {
assetHost: 'https://assets.scratch.mit.edu',
Expand Down
Loading