Skip to content

Commit 4273821

Browse files
takaokoujiclaude
andcommitted
feat: add Load from URL menu item to File menu
Implement "Load from URL" functionality for Scratch projects: - Add URL parsing utility to extract project IDs from Scratch URLs - Create URL loader HOC following the same pattern as sb-file-uploader-hoc - Add new menu item "Load from URL" in File menu between existing load/save options - Add Japanese translation "URLから読み込む" for i18n support - Integrate with existing project loading infrastructure via ProjectFetcherHOC - Support URLs like https://scratch.mit.edu/projects/1209008277/ The implementation uses prompt() for URL input and validates Scratch project URLs, extracting project IDs to load projects through the existing Smalruby API proxy. Fixes #222 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent d2fb8e9 commit 4273821

File tree

5 files changed

+329
-0
lines changed

5 files changed

+329
-0
lines changed

src/components/menu-bar/menu-bar.jsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,15 @@ class MenuBar extends React.Component {
492492
>
493493
{this.props.intl.formatMessage(sharedMessages.loadFromComputerTitle)}
494494
</MenuItem>
495+
<MenuItem
496+
onClick={this.props.onStartSelectingUrlLoad}
497+
>
498+
<FormattedMessage
499+
defaultMessage="Load from URL"
500+
description="Menu bar item for loading from URL"
501+
id="gui.menuBar.loadFromUrl"
502+
/>
503+
</MenuItem>
495504
<SB3Downloader>{(className, downloadProjectCallback) => (
496505
<MenuItem
497506
className={className}
@@ -931,6 +940,7 @@ MenuBar.propTypes = {
931940
onSetTimeTravelMode: PropTypes.func,
932941
onShare: PropTypes.func,
933942
onStartSelectingFileUpload: PropTypes.func,
943+
onStartSelectingUrlLoad: PropTypes.func,
934944
onToggleLoginOpen: PropTypes.func,
935945
projectTitle: PropTypes.string,
936946
renderLogin: PropTypes.func,

src/containers/gui.jsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
import FontLoaderHOC from '../lib/font-loader-hoc.jsx';
3131
import LocalizationHOC from '../lib/localization-hoc.jsx';
3232
import SBFileUploaderHOC from '../lib/sb-file-uploader-hoc.jsx';
33+
import URLLoaderHOC from '../lib/url-loader-hoc.jsx';
3334
import ProjectFetcherHOC from '../lib/project-fetcher-hoc.jsx';
3435
import TitledHOC from '../lib/titled-hoc.jsx';
3536
import ProjectSaverHOC from '../lib/project-saver-hoc.jsx';
@@ -212,6 +213,7 @@ const WrappedGui = compose(
212213
vmListenerHOC,
213214
vmManagerHOC,
214215
SBFileUploaderHOC,
216+
URLLoaderHOC,
215217
cloudManagerHOC,
216218
systemPreferencesHOC
217219
)(ConnectedGUI);

src/lib/url-loader-hoc.jsx

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
import bindAll from 'lodash.bindall';
2+
import React from 'react';
3+
import PropTypes from 'prop-types';
4+
import {defineMessages, intlShape, injectIntl} from 'react-intl';
5+
import {connect} from 'react-redux';
6+
import log from '../lib/log';
7+
import sharedMessages from './shared-messages';
8+
9+
import {extractScratchProjectId} from './url-parser';
10+
11+
import {
12+
LoadingStates,
13+
getIsLoadingUpload,
14+
getIsShowingWithoutId,
15+
onLoadedProject,
16+
requestProjectUpload
17+
} from '../reducers/project-state';
18+
import {setProjectTitle} from '../reducers/project-title';
19+
import {
20+
openLoadingProject,
21+
closeLoadingProject
22+
} from '../reducers/modals';
23+
import {
24+
closeFileMenu
25+
} from '../reducers/menus';
26+
27+
const messages = defineMessages({
28+
loadError: {
29+
id: 'gui.urlLoader.loadError',
30+
defaultMessage: 'The project URL that was entered failed to load.',
31+
description: 'An error that displays when a project URL fails to load.'
32+
},
33+
invalidUrl: {
34+
id: 'gui.urlLoader.invalidUrl',
35+
defaultMessage: 'Please enter a valid Scratch project URL.',
36+
description: 'An error that displays when an invalid URL is entered.'
37+
},
38+
urlPrompt: {
39+
id: 'gui.urlLoader.urlPrompt',
40+
defaultMessage: 'Enter a Scratch project URL (e.g., https://scratch.mit.edu/projects/1209008277/):',
41+
description: 'Prompt for entering a project URL.'
42+
}
43+
});
44+
45+
/**
46+
* Higher Order Component to provide behavior for loading project from URL into editor.
47+
* @param {React.Component} WrappedComponent the component to add URL loading functionality to
48+
* @returns {React.Component} WrappedComponent with URL loading functionality added
49+
*
50+
* <URLLoaderHOC>
51+
* <WrappedComponent />
52+
* </URLLoaderHOC>
53+
*/
54+
const URLLoaderHOC = function (WrappedComponent) {
55+
class URLLoaderComponent extends React.Component {
56+
constructor (props) {
57+
super(props);
58+
bindAll(this, [
59+
'handleStartSelectingUrlLoad',
60+
'handleUrlInput',
61+
'loadProjectFromUrl',
62+
'handleFinishedLoadingUpload'
63+
]);
64+
}
65+
componentDidUpdate (prevProps) {
66+
if (this.props.isLoadingUpload && !prevProps.isLoadingUpload) {
67+
this.handleFinishedLoadingUpload();
68+
}
69+
}
70+
71+
// Step 1: Start the URL loading process
72+
handleStartSelectingUrlLoad () {
73+
this.handleUrlInput();
74+
}
75+
76+
// Step 2: Prompt user for URL input
77+
handleUrlInput () {
78+
const {
79+
intl,
80+
isShowingWithoutId,
81+
loadingState,
82+
projectChanged,
83+
userOwnsProject
84+
} = this.props;
85+
86+
const url = prompt(intl.formatMessage(messages.urlPrompt)); // eslint-disable-line no-alert
87+
88+
if (!url) {
89+
// User cancelled
90+
this.props.closeFileMenu();
91+
return;
92+
}
93+
94+
const projectId = extractScratchProjectId(url);
95+
if (!projectId) {
96+
alert(intl.formatMessage(messages.invalidUrl)); // eslint-disable-line no-alert
97+
this.props.closeFileMenu();
98+
return;
99+
}
100+
101+
this.projectIdToLoad = projectId;
102+
this.projectUrlToLoad = url;
103+
104+
// If user owns the project, or user has changed the project,
105+
// we must confirm with the user that they really intend to
106+
// replace it.
107+
let uploadAllowed = true;
108+
if (userOwnsProject || (projectChanged && isShowingWithoutId)) {
109+
uploadAllowed = confirm( // eslint-disable-line no-alert
110+
intl.formatMessage(sharedMessages.replaceProjectWarning)
111+
);
112+
}
113+
114+
if (uploadAllowed) {
115+
// Start the loading process
116+
this.props.requestProjectUpload(loadingState);
117+
}
118+
119+
this.props.closeFileMenu();
120+
}
121+
122+
// Step 3: Load project from URL (called from componentDidUpdate)
123+
handleFinishedLoadingUpload () {
124+
if (this.projectIdToLoad) {
125+
this.loadProjectFromUrl(this.projectIdToLoad);
126+
return;
127+
}
128+
this.props.cancelFileUpload(this.props.loadingState);
129+
}
130+
131+
// Step 4: Actually load the project data
132+
loadProjectFromUrl (projectId) {
133+
this.props.onLoadingStarted();
134+
let loadingSuccess = false;
135+
136+
// Use the same approach as project-fetcher-hoc.jsx
137+
// First get the project token via the proxy API
138+
const options = {
139+
method: 'GET',
140+
uri: `https://api.smalruby.app/scratch-api-proxy/projects/${projectId}`,
141+
json: true
142+
};
143+
144+
fetch(options.uri, {
145+
method: options.method,
146+
headers: {
147+
'Content-Type': 'application/json'
148+
}
149+
})
150+
.then(response => {
151+
if (!response.ok) {
152+
throw new Error(`HTTP ${response.status}`);
153+
}
154+
return response.json();
155+
})
156+
.then(data => {
157+
const projectToken = data.project_token;
158+
159+
// Now load the project using the VM's storage system
160+
const storage = this.props.vm.runtime.storage;
161+
storage.setProjectToken(projectToken);
162+
163+
return storage.load(storage.AssetType.Project, projectId, storage.DataFormat.JSON);
164+
})
165+
.then(projectAsset => {
166+
if (projectAsset) {
167+
return this.props.vm.loadProject(projectAsset.data);
168+
}
169+
throw new Error('Could not find project');
170+
})
171+
.then(() => {
172+
// Set project title based on the project data or URL
173+
const projectTitle = `Project ${this.projectIdToLoad}`;
174+
this.props.onSetProjectTitle(projectTitle);
175+
loadingSuccess = true;
176+
})
177+
.catch(error => {
178+
log.warn('URL loader error:', error);
179+
alert(this.props.intl.formatMessage(messages.loadError)); // eslint-disable-line no-alert
180+
})
181+
.then(() => {
182+
this.props.onLoadingFinished(this.props.loadingState, loadingSuccess);
183+
// Clear the project reference
184+
this.projectIdToLoad = null;
185+
this.projectUrlToLoad = null;
186+
});
187+
}
188+
189+
render () {
190+
const {
191+
/* eslint-disable no-unused-vars */
192+
cancelFileUpload,
193+
closeFileMenu: closeFileMenuProp,
194+
isLoadingUpload,
195+
isShowingWithoutId,
196+
loadingState,
197+
onLoadingFinished,
198+
onLoadingStarted,
199+
onSetProjectTitle,
200+
projectChanged,
201+
requestProjectUpload: requestProjectUploadProp,
202+
userOwnsProject,
203+
/* eslint-enable no-unused-vars */
204+
...componentProps
205+
} = this.props;
206+
return (
207+
<React.Fragment>
208+
<WrappedComponent
209+
onStartSelectingUrlLoad={this.handleStartSelectingUrlLoad}
210+
{...componentProps}
211+
/>
212+
</React.Fragment>
213+
);
214+
}
215+
}
216+
217+
URLLoaderComponent.propTypes = {
218+
canSave: PropTypes.bool,
219+
cancelFileUpload: PropTypes.func,
220+
closeFileMenu: PropTypes.func,
221+
intl: intlShape.isRequired,
222+
isLoadingUpload: PropTypes.bool,
223+
isShowingWithoutId: PropTypes.bool,
224+
loadingState: PropTypes.oneOf(LoadingStates),
225+
onLoadingFinished: PropTypes.func,
226+
onLoadingStarted: PropTypes.func,
227+
onSetProjectTitle: PropTypes.func,
228+
projectChanged: PropTypes.bool,
229+
requestProjectUpload: PropTypes.func,
230+
userOwnsProject: PropTypes.bool,
231+
vm: PropTypes.shape({
232+
loadProject: PropTypes.func,
233+
runtime: PropTypes.shape({
234+
storage: PropTypes.object
235+
})
236+
})
237+
};
238+
239+
const mapStateToProps = (state, ownProps) => {
240+
const loadingState = state.scratchGui.projectState.loadingState;
241+
const user = state.session && state.session.session && state.session.session.user;
242+
return {
243+
isLoadingUpload: getIsLoadingUpload(loadingState),
244+
isShowingWithoutId: getIsShowingWithoutId(loadingState),
245+
loadingState: loadingState,
246+
projectChanged: state.scratchGui.projectChanged,
247+
userOwnsProject: ownProps.authorUsername && user &&
248+
(ownProps.authorUsername === user.username),
249+
vm: state.scratchGui.vm
250+
};
251+
};
252+
253+
const mapDispatchToProps = (dispatch, ownProps) => ({
254+
cancelFileUpload: loadingState => dispatch(onLoadedProject(loadingState, false, false)),
255+
closeFileMenu: () => dispatch(closeFileMenu()),
256+
onLoadingFinished: (loadingState, success) => {
257+
dispatch(onLoadedProject(loadingState, ownProps.canSave, success));
258+
dispatch(closeLoadingProject());
259+
dispatch(closeFileMenu());
260+
},
261+
onLoadingStarted: () => dispatch(openLoadingProject()),
262+
onSetProjectTitle: title => dispatch(setProjectTitle(title)),
263+
requestProjectUpload: loadingState => dispatch(requestProjectUpload(loadingState))
264+
});
265+
266+
const mergeProps = (stateProps, dispatchProps, ownProps) => Object.assign(
267+
{}, stateProps, dispatchProps, ownProps
268+
);
269+
270+
return injectIntl(connect(
271+
mapStateToProps,
272+
mapDispatchToProps,
273+
mergeProps
274+
)(URLLoaderComponent));
275+
};
276+
277+
export {
278+
URLLoaderHOC as default
279+
};

src/lib/url-parser.js

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/**
2+
* Utility functions for parsing project URLs
3+
*/
4+
5+
/**
6+
* Extract Scratch project ID from Scratch project URL
7+
* @param {string} url - Scratch project URL
8+
* @returns {string|null} - Project ID or null if invalid
9+
*/
10+
export const extractScratchProjectId = url => {
11+
if (!url || typeof url !== 'string') {
12+
return null;
13+
}
14+
15+
const patterns = [
16+
// Standard project URL: https://scratch.mit.edu/projects/1209008277/
17+
/^https?:\/\/scratch\.mit\.edu\/projects\/(\d+)\/?$/,
18+
// Project URL with additional path: https://scratch.mit.edu/projects/1209008277/editor/
19+
/^https?:\/\/scratch\.mit\.edu\/projects\/(\d+)\/.*$/
20+
];
21+
22+
for (const pattern of patterns) {
23+
const match = url.trim().match(pattern);
24+
if (match && match[1]) {
25+
return match[1];
26+
}
27+
}
28+
29+
return null;
30+
};
31+
32+
/**
33+
* Validate if URL is a valid Scratch project URL
34+
* @param {string} url - URL to validate
35+
* @returns {boolean} - True if valid Scratch project URL
36+
*/
37+
export const isValidScratchProjectUrl = url => extractScratchProjectId(url) !== null;

src/locales/ja.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export default {
2+
'gui.menuBar.loadFromUrl': 'URLから読み込む',
23
'gui.menuBar.seeProjectPage': 'プロジェクトページを見る',
34
'gui.loader.creating': 'プロジェクトを作成中...',
45
'gui.smalruby3.crashMessage.description': '申し訳ありません。スモウルビーがクラッシュしたようです。このバグは自動的にスモウルビーチームに報告されました。ページを再読み込みしてください。',

0 commit comments

Comments
 (0)