diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ac1db5446..65f9e4f8c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,38 +1,107 @@ -name: Build and publish release +name: Build and publish release - private fork on: - push: - tags: - - 'v2.*' + push: + branches: + - 'experiment-private-fork' + # - 'master' + + # Allows you to run this workflow manually + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + actions: read # TBC: Was required for the `actions/checkout@v4` action, but not anymore jobs: - build: - runs-on: ubuntu-latest - - steps: - - name: Set env to beta - if: contains(github.ref, '-beta.') || contains(github.ref, '-rc.') - run: | - echo "REACT_APP_NAME=Pybricks Beta" >> $GITHUB_ENV - echo "REACT_APP_SUFFIX=-beta" >> $GITHUB_ENV - - uses: actions/setup-node@v3 - with: - node-version: '18.x' - - uses: actions/checkout@v3 - - run: yarn install - - run: yarn coverage - - uses: codecov/codecov-action@v3 - with: - directory: coverage - - name: Build - run: yarn build - - name: Install lftp - run: sudo apt-get update && sudo apt-get install --yes lftp - - name: Publish - shell: bash - env: - LFTP_USER: ${{ secrets.lftpUser }} - LFTP_PASSWORD: ${{ secrets.lftpPassword }} - LFTP_SITE: ${{ secrets.lftpSite }} - run: | - lftp -e "open --user $LFTP_USER --env-password $LFTP_SITE && mirror --verbose --reverse --delete --exclude=.htaccess --exclude=.well-known build beta; exit" + build: + environment: + name: github-pages + # url: ${{ steps.deployment.outputs.page_url }} + + runs-on: ubuntu-latest + + steps: + - name: Set env to beta + if: contains(github.ref, '-beta.') || contains(github.ref, '-rc.') + run: | + echo "REACT_APP_NAME=Pybricks Beta" >> $GITHUB_ENV + echo "REACT_APP_SUFFIX=-beta" >> $GITHUB_ENV + - name: Set env to private + if: contains(github.ref, '-private.') || contains(github.ref, '-private-fork.') + run: | + echo "REACT_APP_NAME=Pybricks Private" >> $GITHUB_ENV + echo "REACT_APP_SUFFIX=-private" >> $GITHUB_ENV + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: '18.x' + - name: Checkout repository + uses: actions/checkout@v4 + - run: yarn install + #- run: yarn coverage + #- uses: codecov/codecov-action@v3 + # with: + # directory: coverage + - name: Configure Pages + uses: actions/configure-pages@v5 + - name: Build + run: yarn build + #- name: mock build + # shell: bash + # run: | + # mkdir build && cp README.md build/ + + # - name: Install lftp + # run: sudo apt-get update && sudo apt-get install --yes lftp + # - name: Publish + # shell: bash + # env: + # LFTP_USER: ${{ secrets.lftpUser }} + # LFTP_PASSWORD: ${{ secrets.lftpPassword }} + # LFTP_SITE: ${{ secrets.lftpSite }} + # run: | + # lftp -e "open --user $LFTP_USER --env-password $LFTP_SITE && mirror --verbose --reverse --delete --exclude=.htaccess --exclude=.well-known build beta; exit" + #- name: Upload artifact + # uses: actions/upload-artifact@v4 + # with: + # path: 'build/' + # name: 'github-pages' + - name: Upload Artifacts + uses: actions/upload-pages-artifact@v3 + with: + path: 'build/' + #name: github-pages + + # deploy: + # needs: build + # runs-on: ubuntu-latest + # permissions: + # pages: write + # id-token: write + # environment: + # name: github-pages + # url: ${{ steps.deployment.outputs.page_url }} + # # url: 'https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/' + # steps: + + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + runs-on: ubuntu-latest + + needs: build + + steps: + # - name: Download artifact + # uses: actions/download-artifact@v4 + # #with: + # # name: 'github-pages' + + - name: Deploy + uses: actions/deploy-pages@v4 + id: deployment diff --git a/.vscode/settings.json b/.vscode/settings.json index 789111723..7346f0a3f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -57,5 +57,6 @@ "enforceHeader": true, "include": ["scss", "typescript", "typescriptreact"], "replace": ["Copyright"] - } + }, + "cSpell.words": ["blueprintjs", "Pybricks"] } diff --git a/CUSTOM.md b/CUSTOM.md new file mode 100644 index 000000000..9c10db7db --- /dev/null +++ b/CUSTOM.md @@ -0,0 +1,20 @@ +# Custom Changes for this Build + +This build is an unofficial preview version, consisting of features I wanted to testdrive early. They might cause instability, so be careful and backup everything. + +## Key Features: +- Remember the last connected hub name, so flashing of new firmware is easier +- Import from and export to Google drive, based on [@scatwang's work](https://github.com/scatwang/pybricks-code) +- Import and convert blockly files (.llsp3, .llsp, .lsm) +- HubCentral dialog (a.k.a portview) for custom firmware builds (!) +- Terminal window show/hide shortcut (Alt-T) +- Help window show/hide shortcut (Alt-F1) +- Upon error jump to compiler error automatically + +## Work in Planning or Progress: +- Simpler connect or autoconnect for Bluetooth (!) +- Github or gist sync +- Import of further blockly types maybe .lmsp (ev3 classroom), even later maybe .ev3, .ev3m... +- drag-and-drop import files + +(!) means that either upcoming or proposed firmware change is needed as well \ No newline at end of file diff --git a/config/webpackDevServer.config.js b/config/webpackDevServer.config.js index d14d820d7..7c66c1aed 100644 --- a/config/webpackDevServer.config.js +++ b/config/webpackDevServer.config.js @@ -40,8 +40,8 @@ module.exports = function (proxy, allowedHost) { 'Access-Control-Allow-Methods': '*', 'Access-Control-Allow-Headers': '*', // items below required by Pybricks Code app - 'Cross-Origin-Opener-Policy': 'same-origin', - 'Cross-Origin-Embedder-Policy': 'require-corp', + // 'Cross-Origin-Opener-Policy': 'same-origin', + // 'Cross-Origin-Embedder-Policy': 'require-corp', }, // Enable gzip compression of generated files. compress: true, diff --git a/hub-scripts/_builtin_port_view.py b/hub-scripts/_builtin_port_view.py new file mode 100644 index 000000000..a1221f19a --- /dev/null +++ b/hub-scripts/_builtin_port_view.py @@ -0,0 +1,327 @@ +from pybricks.pupdevices import ( + DCMotor, + Motor, + ColorSensor, + UltrasonicSensor, + ForceSensor, + ColorDistanceSensor, + TiltSensor, + InfraredSensor, +) +from pybricks.parameters import Port, Stop +from pybricks.tools import wait, AppData +try: + from pybricks.iodevices import PUPDevice +except: + pass + +# Figure out the available ports for the given hub. +ports = [Port.A, Port.B] +try: + ports += [Port.C, Port.D] + ports += [Port.E, Port.F] +except AttributeError: + pass +port_modes = [0 for _ in range(len(ports))] +port_commands = [[] for _ in range(len(ports))] + +from pybricks.hubs import ThisHub +hub = ThisHub() +try: + from pybricks.hubs import PrimeHub + from pybricks.parameters import Icon, Button + + hub = PrimeHub() + hub.light.off() + + # Create an animation of the heart icon with changing brightness. + brightness = list(range(0, 70, 4)) + list(range(70, 0, -4)) + hub.display.animate([Icon.HEART * i / 100 for i in brightness], 120) + # while hub.buttons.pressed(): + # wait(10) + hub.system.set_stop_button([Button.LEFT, Button.RIGHT]) +except ImportError: + pass + + +# Allocates small buffer so the IDE can send us commands, +# mode index values for each sensor. +# message format version:1, packet_counter:1, message_type:1, payload:3-max +# execute_action "a" # 'a' + action_name:1 +# shutdown "as" +# port_operations "p" # port_index:1, operation:1, values:1 +# set_port_mode "p\0x00m\0x00" +# rotate motor "p\0x00r\0x01" +app_data = AppData("1b1b1b3b") +def get_app_data_input(): + version, packet_counter, message_type, *payload = app_data.get_values() + if version != 1: + return 0, 0, 0 + else: + return message_type, packet_counter, payload + + +# This is sent when a device is plugged in if it has multiple modes. +# This populates a dropdown menu in the IDE to select the mode. +def make_mode_message(port, type_id, modes): + return f"{port}\t{type_id}\tmodes\t" + "\t".join(modes) + "\r\n" + + +# BOOST Color and Distance Sensor +def update_color_and_distance_sensor(port, port_index, type_id): + sensor = ColorDistanceSensor(port) + mode_info = make_mode_message( + port, + type_id, + ["Reflected light intensity and color", "Ambient light intensity", "Distance"], + ) + while True: + # mode = app_data.get_values()[ports.index(port)] + mode = port_modes[port_index] + if mode == 0: + hsv = sensor.hsv() + intensity = sensor.reflection() + color = str(sensor.color()).replace("Color.","") + data = f"c={color}\th={hsv.h}°\ts={hsv.s}%\tv={hsv.v}%\ti={intensity}%" + elif mode == 1: + data = f"i={sensor.ambient()}%" + else: + data = f"d={sensor.distance()}%" + yield mode_info + f"{port}\t{type_id}\t{data}" + mode_info = "" + + +# SPIKE Prime / MINDSTORMS Robot Inventor Color Sensor +def update_color_sensor(port, port_index, type_id): + sensor = ColorSensor(port) + mode_info = make_mode_message( + port, + type_id, + [ + "Reflected light intensity and color", + "Ambient light intensity and color", + ], + ) + while True: + mode = port_modes[port_index] + # mode = app_data.get_values()[ports.index(port)] + hsv = sensor.hsv(False if mode else True) + color = str(sensor.color(False if mode else True)).replace("Color.","") + intensity = sensor.ambient() if mode else sensor.reflection() + data = f"c={color}\th={hsv.h}°\ts={hsv.s}%\tv={hsv.v}%\ti={intensity}%" + yield mode_info + f"{port}\t{type_id}\t{data}" + mode_info = "" + + +# WeDo 2.0 Tilt Sensor +def update_tilt_sensor(port, port_index, type_id): + sensor = TiltSensor(port) + while True: + pitch, roll = sensor.tilt() + data = f"p={pitch}°\tr={roll}°" + yield f"{port}\t{type_id}\t{data}" + + +# WeDo 2.0 Infrared Sensor +def update_infrared_sensor(port, port_index, type_id): + sensor = InfraredSensor(port) + while True: + dist = sensor.distance() + ref = sensor.reflection() + data = f"d={dist}%\ti={ref}%" + yield f"{port}\t{type_id}\t{data}" + + +# SPIKE Prime / MINDSTORMS Robot Inventor Ultrasonic Sensor +def update_ultrasonic_sensor(port, port_index, type_id): + sensor = UltrasonicSensor(port) + while True: + data = f"d={sensor.distance()}mm" + yield f"{port}\t{type_id}\t{data}" + + +# SPIKE Prime Force Sensor +def update_force_sensor(port, port_index, type_id): + sensor = ForceSensor(port) + while True: + data = f"f={sensor.force():.2f}N\td={sensor.distance():.2f}mm" + yield f"{port}\t{type_id}\t{data}" + + +# Any motor with rotation sensors. +def update_motor(port, port_index, type_id): + motor = Motor(port) + try: + while True: + angle = motor.angle() + angle_mod = motor.angle() % 360 + if angle_mod > 180: + angle_mod -= 360 + rotations = round((angle - angle_mod) / 360) + data = f"a={motor.angle()}°" + if angle != angle_mod: + data += f"\tr={rotations}R\tra={angle_mod}°" + msg = f"{port}\t{type_id}\t{data}" + + # check commands + if len(port_commands[port_index]): + command = port_commands[port_index].pop(0) + if command[0] == ord("r"): + direction = command[1] + yield motor.run_time(100 * direction, 300, Stop.COAST, wait=False) + + yield msg + except: + if motor: motor.close() + raise + + +# Any motor without rotation sensors. +def update_dc_motor(port, port_index, type_id): + motor = DCMotor(port) + try: + while True: + yield f"{port}\t{type_id}" + except: + if motor: motor.close() + raise + +# Any unknown Powered Up device. +def unknown_pup_device(port, port_index, type_id): + PUPDevice(port) + while True: + yield f"{port}\t{type_id}\tunknown" + + +# Monitoring task for one port. +def device_task(port, port_index): + + while True: + try: + # Use generic class to find device type. + dev = PUPDevice(port) + type_id = dev.info()["id"] + + # Run device specific monitoring task until it is disconnected. + if type_id == 34: + yield from update_tilt_sensor(port, port_index, type_id) + if type_id == 35: + yield from update_infrared_sensor(port, port_index, type_id) + if type_id == 37: + yield from update_color_and_distance_sensor(port, port_index, type_id) + elif type_id == 61: + yield from update_color_sensor(port, port_index, type_id) + elif type_id == 62: + yield from update_ultrasonic_sensor(port, port_index, type_id) + elif type_id == 63: + yield from update_force_sensor(port, port_index, type_id) + elif type_id in (1, 2): + yield from update_dc_motor(port, port_index, type_id) + elif type_id in (38, 46, 47, 48, 49, 65, 75, 76, 86, 87): + # 86 (0x56) Technic Move hub built-in drive motor + # 87 (0x56) Technic Move hub built-in drive motor + yield from update_motor(port, port_index, type_id) + else: + yield from unknown_pup_device(port, port_index, type_id) + except OSError as e: + # No device or previous device was disconnected. + yield f"{port}\t--" + + +# Monitoring task for the hub core. +def hub_task(): + global last_packet_counter + last_packet_counter = -1 + while True: + message_type, packet_counter, payload = get_app_data_input() + + if packet_counter != last_packet_counter: + + # execute_action + last_packet_counter = packet_counter + if message_type == ord("a"): + if payload[0] == ord("s"): + # execute_action: shutdown + try: hub.speaker.beep() + except: pass + yield hub.system.shutdown() + # port_operations + elif message_type == ord("p"): + port_index = payload[0] + port_operation = payload[1] + + # set_port_mode + if port_operation == ord("m"): + port_modes[port_index] = payload[2] + # any other port commands + else: + port_commands[port_index].append(payload[1:]) + + yield None + + +def battery_task(): + if not hub.battery: return + + count = 0 + while True: + count += 1 + if count % 100: + yield None + else: + # skip cc 10 seconds before sending an update + percentage = round(min(100,(hub.battery.voltage()-6000)/(8300-6000)*100)) + voltage = hub.battery.voltage() + status = hub.charger.status() if hub.charger else '' + data = f"pct={percentage}%\tv={voltage}mV\ts={status}" + yield f"battery\t{data}" + + +# # Monitoring task for the hub buttons. +# def buttons_task(): +# while True: +# buttons = ",".join(sorted(str(b).replace("Button.","") for b in hub.buttons.pressed())) +# yield f'buttons\t{buttons}' + + +# Monitoring task for the hub imu. +def imu_task(): + if not hub.imu: return + while True: + heading = round(hub.imu.heading()) + # [pitch, roll] = hub.imu.tilt() + pitch = round(hub.imu.tilt()[0]) + roll = round(hub.imu.tilt()[1]) + stationary = 1 if hub.imu.stationary() else 0 + up = str(hub.imu.up()).replace("Side.","") + yield f"imu\tup={up}\ty={heading}°\tp={pitch}°\tr={roll}°\ts={stationary}" + + +# Assemble all monitoring tasks. +tasks = [device_task(port, port_index) for port_index, port in enumerate(ports)] + \ + [hub_task(), battery_task(), imu_task()] + + +# Main monitoring loop. +while True: + + # Get the messages for each sensor. + msg = "" + for task in tasks: + try: + line = next(task) + if line: msg += line + "\r\n" + except Exception as e: + print("exception", e) + pass + + # REVISIT: It would be better to send whole messages (or multiples), but we + # are currently limited to 19 bytes per message, so write in chunks. + if PUPDevice: + for i in range(0, len(msg), 19): + app_data.write_bytes(msg[i : i + 19]) + else: + print(msg) + + # Loop time. + wait(100) diff --git a/package.json b/package.json index 4b3e29f32..b116a26e2 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "babel-plugin-transform-import-meta": "^2.2.1", "babel-preset-react-app": "^10.0.1", "bfj": "^8.0.0", + "blocklypy": "^0.0.86", "browser-fs-access": "^0.35.0", "browserslist": "^4.22.2", "camelcase": "^8.0.0", @@ -64,6 +65,8 @@ "fake-indexeddb": "^5.0.2", "file-loader": "^6.2.0", "fs-extra": "^11.2.0", + "google-drive-picker": "^1.1.29", + "googleapi": "^1.0.2", "html-webpack-plugin": "^5.6.0", "identity-obj-proxy": "^3.0.0", "jest": "^29.7.0", diff --git a/scripts/serve.py b/scripts/serve.py index 79f49f725..fc5811b0f 100755 --- a/scripts/serve.py +++ b/scripts/serve.py @@ -24,8 +24,8 @@ def __init__( def end_headers(self) -> None: # custom headers needed for some web API features - self.send_header("Cross-Origin-Opener-Policy", "same-origin") - self.send_header("Cross-Origin-Embedder-Policy", "require-corp") + # self.send_header("Cross-Origin-Opener-Policy", "same-origin") + # self.send_header("Cross-Origin-Embedder-Policy", "require-corp") return super().end_headers() diff --git a/src/app/App.tsx b/src/app/App.tsx index ccf420f51..a2183aaab 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,17 +1,20 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020-2023 The Pybricks Authors +// Copyright (c) 2020-2024 The Pybricks Authors import 'react-splitter-layout/lib/index.css'; import './app.scss'; -import { Classes, Spinner } from '@blueprintjs/core'; -import React, { useEffect, useState } from 'react'; +import { Classes, HotkeyConfig, Spinner, useHotkeys } from '@blueprintjs/core'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import SplitterLayout from 'react-splitter-layout'; import { useLocalStorage, useTernaryDarkMode } from 'usehooks-ts'; import Activities from '../activities/Activities'; import DfuWindowsDriverInstallDialog from '../firmware/dfuWindowsDriverInstallDialog/DfuWindowsDriverInstallDialog'; import { InstallPybricksDialog } from '../firmware/installPybricksDialog/InstallPybricksDialog'; import RestoreOfficialDialog from '../firmware/restoreOfficialDialog/RestoreOfficialDialog'; -import { useSettingIsShowDocsEnabled } from '../settings/hooks'; +import { + useSettingIsShowDocsEnabled, + useSettingIsShowTerminalEnabled, +} from '../settings/hooks'; import SponsorDialog from '../sponsor/SponsorDialog'; import StatusBar from '../status-bar/StatusBar'; import Toolbar from '../toolbar/Toolbar'; @@ -20,6 +23,11 @@ import { isMacOS } from '../utils/os'; import { useAppLastDocsPageSetting } from './hooks'; import { useI18n } from './i18n'; +interface SplitterLayoutState { + secondaryPaneSize?: number; + resizing?: boolean; +} + const Editor = React.lazy(async () => { const [sagaModule, componentModule] = await Promise.all([ import('../editor/sagas'), @@ -46,6 +54,19 @@ const Terminal = React.lazy(async () => { return componentModule; }); +const HubCenter = React.lazy(async () => { + const [sagaModule, componentModule] = await Promise.all([ + import('../hubcenter/sagas'), + import('../hubcenter/HubCenterDialog'), + ]); + + window.dispatchEvent( + new CustomEvent('pb-lazy-saga', { detail: { saga: sagaModule.default } }), + ); + + return componentModule; +}); + const Docs: React.FunctionComponent = () => { const { setIsSettingShowDocsEnabled } = useSettingIsShowDocsEnabled(); const { initialDocsPage, setLastDocsPage } = useAppLastDocsPageSetting(); @@ -156,7 +177,10 @@ const Docs: React.FunctionComponent = () => { const App: React.FunctionComponent = () => { const i18n = useI18n(); const { isDarkMode } = useTernaryDarkMode(); - const { isSettingShowDocsEnabled } = useSettingIsShowDocsEnabled(); + const { isSettingShowDocsEnabled, toggleIsSettingShowDocsEnabled } = + useSettingIsShowDocsEnabled(); + const { isSettingShowTerminalEnabled, toggleIsSettingShowTerminalEnabled } = + useSettingIsShowTerminalEnabled(); const [isDragging, setIsDragging] = useState(false); const [docsSplit, setDocsSplit] = useLocalStorage('app-docs-split', 30); @@ -188,6 +212,45 @@ const App: React.FunctionComponent = () => { return () => removeEventListener('keydown', listener); }, []); + const hotkeys = useMemo( + () => [ + { + global: true, + allowInInput: true, + combo: 'alt+F1', + label: 'Show or Hide the Documentation', + preventDefault: true, + stopPropagation: true, + onKeyDown: () => toggleIsSettingShowDocsEnabled(), + }, + { + global: true, + allowInInput: true, + combo: 'alt+T', + label: 'Show or Hide the Terminal', + preventDefault: true, + stopPropagation: true, + onKeyDown: () => toggleIsSettingShowTerminalEnabled(), + }, + ], + [toggleIsSettingShowDocsEnabled, toggleIsSettingShowTerminalEnabled], + ); + useHotkeys(hotkeys); + + /* make sure when terminal is shown it has at leats 10% visibility not to confuse the user */ + const terminalSplitterRef = useRef(null); + useEffect(() => { + if (isSettingShowTerminalEnabled) { + if (terminalSplitterRef.current) { + const state = terminalSplitterRef.current + .state as unknown as SplitterLayoutState; + if (!state.secondaryPaneSize || state.secondaryPaneSize < 10) { + terminalSplitterRef.current.setState({ secondaryPaneSize: 10 }); + } + } + } + }, [isSettingShowTerminalEnabled, terminalSplitterRef]); + return (
e.preventDefault()}>
@@ -210,9 +273,15 @@ const App: React.FunctionComponent = () => { onSecondaryPaneSizeChange={setDocsSplit} >
{ + }> + +
); }; diff --git a/src/app/app.scss b/src/app/app.scss index 41689dc47..001c60208 100644 --- a/src/app/app.scss +++ b/src/app/app.scss @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020-2022 The Pybricks Authors +// Copyright (c) 2020-2024 The Pybricks Authors // Custom styling for the App control. @@ -185,6 +185,7 @@ $dark-splitter-color-hover: color.adjust( // hide the docs and resize separator +div.pb-hide-terminal > :not(.layout-pane-primary), div.pb-hide-docs > :not(.layout-pane-primary) { display: none; } diff --git a/src/app/constants.ts b/src/app/constants.ts index 90d4addcc..9cde64ab4 100644 --- a/src/app/constants.ts +++ b/src/app/constants.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2021-2023 The Pybricks Authors +// Copyright (c) 2021-2024 The Pybricks Authors import docsPackage from '@pybricks/ide-docs/package.json'; // Definitions for compile-time UI settings. @@ -87,3 +87,14 @@ export const zipFileExtension = '.zip'; /** The ZIP file MIME type ('application/zip') */ export const zipFileMimeType = 'application/zip'; + +export const legoBlocklyFileExtensions = ['.llsp3', '.llsp', '.lms']; + +/** Google Cloud API key */ +export const googleApiKey = 'AIzaSyDkJMU1OqsnEULcV6kCwja2OJef6Ge_NxQ'; // process.env.GOOGLE_API_KEY + +/** Google OAuth 2.0 Client ID */ +export const googleClientId = + '688983308610-sst0jc72ti5p196srnkst442a9iadkqs.apps.googleusercontent.com'; // process.env.GOOGLE_CLIENT_ID +/** maximum number of recent file displayed */ +export const recentFileCount = 3; diff --git a/src/ble-pybricks-service/protocol.ts b/src/ble-pybricks-service/protocol.ts index 773aa25a2..74e78cbb5 100644 --- a/src/ble-pybricks-service/protocol.ts +++ b/src/ble-pybricks-service/protocol.ts @@ -221,7 +221,11 @@ export enum EventType { * * Received when notifications are enabled and when status changes. * - * @since Pybricks Profile v1.0.0 + * The payload is one 32-bit little-endian unsigned integer containing + * ::pbio_pybricks_status_t flags and a one byte program identifier + * representing the currently active program if it is running. + * + * @since Pybricks Profile v1.0.0. Program identifier added in Pybricks Profile v1.4.0. */ StatusReport = 0, /** diff --git a/src/ble/reducers.ts b/src/ble/reducers.ts index 808a42fa6..13d03950f 100644 --- a/src/ble/reducers.ts +++ b/src/ble/reducers.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020-2022 The Pybricks Authors +// Copyright (c) 2020-2024 The Pybricks Authors // // Manages state for the Bluetooth Low Energy connection. // This assumes that there is only one global connection to a single device. diff --git a/src/ble/sagas.ts b/src/ble/sagas.ts index be3fb191d..8b7832d07 100644 --- a/src/ble/sagas.ts +++ b/src/ble/sagas.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020-2023 The Pybricks Authors +// Copyright (c) 2020-2024 The Pybricks Authors // // Manages connection to a Bluetooth Low Energy device running Pybricks firmware. @@ -63,7 +63,7 @@ import { ensureError } from '../utils'; import { isLinux } from '../utils/os'; import { pythonVersionToSemver } from '../utils/version'; import { - bleConnectPybricks as bleConnectPybricks, + bleConnectPybricks, bleDidConnectPybricks, bleDidDisconnectPybricks, bleDidFailToConnectPybricks, diff --git a/src/components/GoogleApi.ts b/src/components/GoogleApi.ts new file mode 100644 index 000000000..3f5deedb4 --- /dev/null +++ b/src/components/GoogleApi.ts @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2024 The Pybricks Authors + +/** A individual doc returned from Google Picker API. */ +export interface DriveDocument { + description: string; + downloadUrl?: string; + driveSuccess: boolean; + embedUrl: string; + iconUrl: string; + id: string; + isShared: boolean; + lastEditedUtc: number; + mimeType: string; + name: string; + rotation: number; + rotationDegree: number; + serviceId: string; + sizeBytes: number; + type: string; + uploadState?: string; + url: string; +} + +/** Response from Google Picker API. */ +export interface PickerResponse { + action: string; + docs: DriveDocument[]; +} diff --git a/src/editor/Editor.tsx b/src/editor/Editor.tsx index 3f6a438ad..a270c84a1 100644 --- a/src/editor/Editor.tsx +++ b/src/editor/Editor.tsx @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020-2023 The Pybricks Authors +// Copyright (c) 2020-2024 The Pybricks Authors import './editor.scss'; import { @@ -19,6 +19,7 @@ import { import { Blank, Clipboard, + Console, Cross, Duplicate, Manual, @@ -37,7 +38,10 @@ import { UUID } from '../fileStorage'; import { useFileStoragePath } from '../fileStorage/hooks'; import { compile } from '../mpy/actions'; import { useSelector } from '../reducers'; -import { useSettingIsShowDocsEnabled } from '../settings/hooks'; +import { + useSettingIsShowDocsEnabled, + useSettingIsShowTerminalEnabled, +} from '../settings/hooks'; import { isMacOS } from '../utils/os'; import Welcome from './Welcome'; import { editorActivateFile, editorCloseFile } from './actions'; @@ -389,6 +393,8 @@ const Editor: React.FunctionComponent = () => { const [editor, setEditor] = useState(); const { isSettingShowDocsEnabled, toggleIsSettingShowDocsEnabled } = useSettingIsShowDocsEnabled(); + const { isSettingShowTerminalEnabled, toggleIsSettingShowTerminalEnabled } = + useSettingIsShowTerminalEnabled(); const { isDarkMode } = useTernaryDarkMode(); const i18n = useI18n(); @@ -510,7 +516,7 @@ const Editor: React.FunctionComponent = () => { {
-
); }; diff --git a/src/editor/Welcome.tsx b/src/editor/Welcome.tsx index 7b7cbd9ae..fa687607e 100644 --- a/src/editor/Welcome.tsx +++ b/src/editor/Welcome.tsx @@ -1,13 +1,23 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2022-2023 The Pybricks Authors +// Copyright (c) 2022-2024 The Pybricks Authors // welcome screen that is shown when no editor is open. -import { Colors } from '@blueprintjs/core'; -import React, { useEffect, useRef } from 'react'; +import { Button, Colors } from '@blueprintjs/core'; +import { DocumentOpen, Plus } from '@blueprintjs/icons'; +import React, { useCallback, useEffect, useRef } from 'react'; +import { useDispatch } from 'react-redux'; import Two from 'two.js'; import { useTernaryDarkMode } from 'usehooks-ts'; +import { Activity, useActivitiesSelectedActivity } from '../activities/hooks'; +import { recentFileCount } from '../app/constants'; +import { explorerCreateNewFile } from '../explorer/actions'; +import { UUID } from '../fileStorage'; +import { useSelector } from '../reducers'; +import { editorActivateFile } from './actions'; +import { useI18n } from './i18n'; import logoSvg from './logo.svg'; +import { RecentFileMetadata } from '.'; const defaultRotation = -Math.PI / 9; // radians const rotationSpeedIncrement = 0.1; // radians per second @@ -61,6 +71,8 @@ type WelcomeProps = { }; const Welcome: React.FunctionComponent = ({ isVisible }) => { + const i18n = useI18n(); + const dispatch = useDispatch(); const stateRef = useRef({ rotation: defaultRotation, rotationSpeed: 0, @@ -93,6 +105,7 @@ const Welcome: React.FunctionComponent = ({ isVisible }) => { const logo = two.load(logoSvg, (g) => { g.center(); + two.add(logo); two.play(); }); @@ -156,15 +169,64 @@ const Welcome: React.FunctionComponent = ({ isVisible }) => { }; }, [isVisible]); + const [, setSelectedActivity] = useActivitiesSelectedActivity(); + const handleOpenNewProject = useCallback(() => { + setSelectedActivity(Activity.Explorer); + dispatch(explorerCreateNewFile()); + }, [dispatch, setSelectedActivity]); + + const handleOpenExplorer = useCallback( + (uuid: UUID) => { + setSelectedActivity(Activity.Explorer); + dispatch(editorActivateFile(uuid)); + }, + [dispatch, setSelectedActivity], + ); + + const recentFiles: readonly RecentFileMetadata[] = useSelector( + (s) => s.editor.recentFiles, + ); + // NOTE: could use UUID instead of storing - const uuid = useFileStorageUuid(file ?? ''); + + const getRecentFileShortCuts = () => ( + <> + {recentFiles.slice(0, recentFileCount).map((fitem: RecentFileMetadata) => ( +
handleOpenExplorer(fitem.uuid)}> +
+ {i18n.translate('welcome.openProject', { + fileName: fitem.path, + })} +
+
+
+
+ ))} + + ); + return (
{ e.stopPropagation(); e.preventDefault(); }} - /> + > +
+
+ {getRecentFileShortCuts()} +
+
{i18n.translate('welcome.newProject')}
+
+
+
+
+
); }; diff --git a/src/editor/actions.ts b/src/editor/actions.ts index 497eedf2c..8ce8c14b7 100644 --- a/src/editor/actions.ts +++ b/src/editor/actions.ts @@ -1,8 +1,9 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2022-2023 The Pybricks Authors +// Copyright (c) 2022-2024 The Pybricks Authors import { createAction } from '../actions'; import { UUID } from '../fileStorage'; +import { RecentFileMetadata } from '.'; export { didFailToInit as editorCompletionDidFailToInit, didInit as editorCompletionDidInit, @@ -132,3 +133,12 @@ export const editorReplaceFile = createAction((uuid: UUID, value: string) => ({ uuid, value, })); + +/** + * Requests to replace the value a file in the editor. + * @param files The recent files. + */ +export const editorRecentFiles = createAction((files: RecentFileMetadata[]) => ({ + type: 'editor.action.recentFiles', + files, +})); diff --git a/src/editor/editor.scss b/src/editor/editor.scss index 9142f662f..ec0a9e1ca 100644 --- a/src/editor/editor.scss +++ b/src/editor/editor.scss @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020-2023 The Pybricks Authors +// Copyright (c) 2020-2024 The Pybricks Authors // Custom styling for the Editor control. @@ -19,6 +19,17 @@ position: absolute; pointer-events: none; } +.pb-private .editor-scrollable::after { + content: ''; + background: url('./private.svg'); + opacity: 1; + top: 0; + left: 0; + bottom: 0; + right: 0; + position: absolute; + pointer-events: none; +} .pb-editor { display: flex; @@ -63,13 +74,59 @@ flex: 1 1 auto; } + .pb-editor-tabpanel.pb-empty { + display: flex; + justify-content: space-around; + } + &-welcome { display: none; - .pb-editor-tabpanel.pb-empty > & { - display: block; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + width: 100%; height: 100%; + + .logo { + flex: 1; + display: flex; + min-height: 0; + width: 100%; + + svg { + width: 100%; + height: 100%; + object-fit: contain; + min-height: 10; + flex: 1; + } + } + .shortcuts { + padding: 20px; + text-align: center; + + border-collapse: separate; + border-spacing: 11px 17px; + dl { + display: table-row; + opacity: 0.8; + cursor: pointer; + } + dt, + dd { + display: table-cell; + vertical-align: middle; + } + dd { + text-align: left; + } + dt { + text-align: right; + } + } } } @@ -94,7 +151,7 @@ } } - &-doc-button { + &-action-buttons { position: absolute; bottom: bp.$pt-grid-size; right: bp.$pt-grid-size; diff --git a/src/editor/index.ts b/src/editor/index.ts new file mode 100644 index 000000000..039ff5573 --- /dev/null +++ b/src/editor/index.ts @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2024 The Pybricks Authors + +import { UUID } from '../fileStorage'; + +/** + * LocalStorage recent files data type. + */ +export type RecentFileMetadata = Readonly<{ + /** A globally unique identifier that serves a a file handle. */ + uuid: UUID; + /** The path of the file in storage. */ + path: string; +}>; diff --git a/src/editor/private.svg b/src/editor/private.svg new file mode 100644 index 000000000..f8de47792 --- /dev/null +++ b/src/editor/private.svg @@ -0,0 +1,9 @@ + + + PRIVATE + + \ No newline at end of file diff --git a/src/editor/reducers.ts b/src/editor/reducers.ts index 6ae1acc46..fcf6ac445 100644 --- a/src/editor/reducers.ts +++ b/src/editor/reducers.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2022 The Pybricks Authors +// Copyright (c) 2022-2024 The Pybricks Authors import { Reducer, combineReducers } from 'redux'; import { UUID } from '../fileStorage'; @@ -8,8 +8,10 @@ import { editorDidCloseFile, editorDidCreate, editorDidOpenFile, + editorRecentFiles, } from './actions'; import codeCompletion from './redux/codeCompletion'; +import { RecentFileMetadata } from '.'; /** Indicates that the code editor is ready for use. */ const isReady: Reducer = (state = false, action) => { @@ -46,9 +48,26 @@ const openFileUuids: Reducer = (state = [], action) => { return state; }; +/** A list of recent files in the order they should be displayed to the user. */ +const initialStateRecentFiles = JSON.parse( + localStorage.getItem('editor.recentFiles') || '[]', +) as readonly RecentFileMetadata[]; +const recentFiles: Reducer = ( + state = initialStateRecentFiles, + action, +) => { + if (editorRecentFiles.matches(action)) { + return action.files; + //return { ...state, recentFiles: action.files }; + } + + return state; +}; + export default combineReducers({ codeCompletion, isReady, activeFileUuid, openFileUuids, + recentFiles, }); diff --git a/src/editor/sagas.ts b/src/editor/sagas.ts index b74c36769..acdbbdc34 100644 --- a/src/editor/sagas.ts +++ b/src/editor/sagas.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2022-2023 The Pybricks Authors +// Copyright (c) 2022-2024 The Pybricks Authors import type { DatabaseChangeType, IDatabaseChange } from 'dexie-observable/api'; import * as monaco from 'monaco-editor'; @@ -17,6 +17,7 @@ import { takeEvery, } from 'typed-redux-saga/macro'; import { alertsShowAlert } from '../alerts/actions'; +import { recentFileCount } from '../app/constants'; import { FileStorageDb, UUID } from '../fileStorage'; import { fileStorageDidFailToLoadTextFile, @@ -62,11 +63,13 @@ import { editorGetValueResponse, editorGoto, editorOpenFile, + editorRecentFiles, editorReplaceFile, } from './actions'; import { EditorError } from './error'; import { ActiveFileHistoryManager, OpenFileManager } from './lib'; import { pybricksMicroPythonId } from './pybricksMicroPython'; +import { RecentFileMetadata } from '.'; function* handleEditorGetValueRequest( editor: monaco.editor.ICodeEditor, @@ -273,6 +276,33 @@ function* handleEditorActivateFile( editor.focus(); + // store the activated uuid in the recent files queue + let recentFiles = (() => { + try { + return JSON.parse( + localStorage.getItem('editor.recentFiles') ?? '', + ) as RecentFileMetadata[]; + } catch { + return []; + } + })(); + + // Check if the file already exists + const fileIndex = recentFiles.findIndex((fitem: RecentFileMetadata) => { + return fitem.uuid === action.uuid; + }); + if (fileIndex !== -1) { + recentFiles.splice(fileIndex, 1); + } + + const db = yield* getContext('fileStorage'); + const metadata = yield* call(() => db.metadata.get(action.uuid)); + recentFiles.unshift({ uuid: action.uuid, path: metadata?.path ?? '' }); // Add new (or existing) file to the beginning + recentFiles = [...recentFiles.slice(0, recentFileCount)]; // Keep only the first 10 items + localStorage.setItem('editor.recentFiles', JSON.stringify(recentFiles)); + yield* put(editorRecentFiles(recentFiles)); + + // signal activation done yield* put(editorDidActivateFile(action.uuid)); } catch (err) { yield* put(editorDidFailToActivateFile(action.uuid, ensureError(err))); diff --git a/src/editor/translations/en.json b/src/editor/translations/en.json index 28d4fa967..ecec25a12 100644 --- a/src/editor/translations/en.json +++ b/src/editor/translations/en.json @@ -2,7 +2,6 @@ "tablist": { "label": "Editor" }, - "welcome": "Welcome", "placeholder": "Write your program here...", "check": "Check syntax", "toggleDocs": "Toggle documentation", @@ -14,7 +13,16 @@ "closeFile": { "tooltip": "Close {fileName}" }, "contextMenu": { "label": "Editor context menu" }, "docs": { - "show": "Show documentation", - "hide": "Hide documentation" + "show": "Show documentation (Alt-F1)", + "hide": "Hide documentation (Alt-F1)" + }, + "welcome": { + "label": "Welcome", + "openProject": "Open {fileName}", + "newProject": "Open a new project" + }, + "terminal": { + "show": "Show terminal (Alt-T)", + "hide": "Hide terminal (Alt-T)" } } diff --git a/src/explorer/Explorer.tsx b/src/explorer/Explorer.tsx index ae595e2fa..bd650ed9a 100644 --- a/src/explorer/Explorer.tsx +++ b/src/explorer/Explorer.tsx @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2022-2023 The Pybricks Authors +// Copyright (c) 2022-2024 The Pybricks Authors // A file explorer control. @@ -14,6 +14,8 @@ import { } from '@blueprintjs/core'; import { Archive, + CloudDownload, + CloudUpload, Document, Duplicate, Edit, @@ -45,6 +47,7 @@ import { Toolbar } from '../components/toolbar/Toolbar'; import { useToolbarItemFocus } from '../components/toolbar/aria'; import { UUID } from '../fileStorage'; import { useFileStorageMetadata } from '../fileStorage/hooks'; +import DownloadPicker from '../googleDrive/GoogleDrive'; import { isMacOS } from '../utils/os'; import { RenderProps, @@ -60,10 +63,12 @@ import { explorerExportFile, explorerImportFiles, explorerRenameFile, + explorerUploadFileToGoogleDrive, explorerUserActivateFile, } from './actions'; import DeleteFileAlert from './deleteFileAlert/DeleteFileAlert'; import DuplicateFileDialog from './duplicateFileDialog/DuplicateFileDialog'; +import GoogleDriveUploadDialog from './googleDriveUploadDialog/GoogleDriveUploadDialog'; import { useI18n } from './i18n'; import NewFileWizard from './newFileWizard/NewFileWizard'; import RenameFileDialog from './renameFileDialog/RenameFileDialog'; @@ -132,6 +137,7 @@ const FileActionButtonGroup: React.FunctionComponent = ( const duplicateButtonId = useId(); const exportButtonId = useId(); const deleteButtonId = useId(); + const uploadToGoogleDriveButtonId = useId(); return ( = ( tooltip={i18n.translate('treeItem.exportTooltip', { fileName })} onClick={() => dispatch(explorerExportFile(fileName))} /> + } + tooltip="Upload file to Google Cloud" + onClick={() => dispatch(explorerUploadFileToGoogleDrive(fileName))} + /> } @@ -183,11 +195,13 @@ const FileActionButtonGroup: React.FunctionComponent = ( // matches ID in tour component const archiveButtonId = 'pb-explorer-archive-button'; const newButtonId = 'pb-explorer-add-button'; +const importFromGoogleDriveButtonId = 'pb-download-from-google-drive-button'; const Header: React.FunctionComponent = () => { const exportButtonId = useId(); const dispatch = useDispatch(); const i18n = useI18n(); + const openDownloadPicker = DownloadPicker(); return ( { firstFocusableItemId={archiveButtonId} > + } + tooltip={i18n.translate('header.toolbar.importFromGoogleDrive')} + onClick={openDownloadPicker} + /> } @@ -469,6 +489,7 @@ const Explorer: React.FunctionComponent = () => { + ); }; diff --git a/src/explorer/actions.ts b/src/explorer/actions.ts index b4fc76dd4..58b8e1357 100644 --- a/src/explorer/actions.ts +++ b/src/explorer/actions.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2022 The Pybricks Authors +// Copyright (c) 2022-2024 The Pybricks Authors import { createAction } from '../actions'; import { UUID } from '../fileStorage'; @@ -26,6 +26,14 @@ export const explorerDidFailToArchiveAllFiles = createAction((error: Error) => ( error, })); +/** + * Action that requests to upload files to Google Drive. + */ +export const explorerUploadFileToGoogleDrive = createAction((fileName: string) => ({ + type: 'explorer.action.uploadFileToGoogleDrive', + fileName, +})); + /** * Action that requests to import (upload) files into the app. */ diff --git a/src/explorer/googleDriveUploadDialog/GoogleDriveUploadDialog.tsx b/src/explorer/googleDriveUploadDialog/GoogleDriveUploadDialog.tsx new file mode 100644 index 000000000..97e3936ec --- /dev/null +++ b/src/explorer/googleDriveUploadDialog/GoogleDriveUploadDialog.tsx @@ -0,0 +1,163 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2024 The Pybricks Authors + +import { + Button, + Classes, + Dialog, + FormGroup, + Icon, + InputGroup, + Spinner, +} from '@blueprintjs/core'; +import React, { useCallback, useRef, useState } from 'react'; +import { useDispatch } from 'react-redux'; +import { FolderPicker } from '../../googleDrive/GoogleDrive'; +import { googleDriveUploadFile } from '../../googleDrive/actions'; +import { useSelector } from '../../reducers'; +import { googleDriveUploadDialogDidCancel } from './actions'; +import { useI18n } from './i18n'; + +const GoogleDriveUploadDialog: React.FunctionComponent = () => { + const i18n = useI18n(); + const dispatch = useDispatch(); + + // TODO: somehow prefill descFolder + // const [lastGoogleDriveFolder] = useLocalStorage('googledrive.selectedFolder', ''); + + const isOpen = useSelector((s) => s.explorer.googleDriveUploadDialog.isOpen); + const fileName = useSelector((s) => s.explorer.googleDriveUploadDialog.fileName); + const destFolder = useSelector( + (s) => s.explorer.googleDriveUploadDialog.descFolder, + ); + const uploadedDocId = useSelector( + (s) => s.explorer.googleDriveUploadDialog.uploadedDocId, + ); + const isUploadFailed = useSelector( + (s) => s.explorer.googleDriveUploadDialog.isUploadFailed, + ); + + const inputRef = useRef(null); + + const [uploadStarted, setUploadStarted] = useState(false); + const openFolderPicker = FolderPicker(); + + const handleOpenPicker = () => { + openFolderPicker(); + }; + + const handleUpload = () => { + setUploadStarted(true); + if (!destFolder.folder) { + return; + } + dispatch(googleDriveUploadFile(fileName, destFolder.folder.id)); + }; + + const handleClose = useCallback(() => { + setUploadStarted(false); + dispatch(googleDriveUploadDialogDidCancel()); + }, [dispatch]); + + return ( + { + inputRef.current?.select(); + inputRef.current?.focus(); + }} + onClose={handleClose} + > +
+
+
+ + + ) + } + rightElement={ +
+ {destFolder.folder && ( + + + + )} + +
+ } + onMouseDown={(e) => e.stopPropagation()} + /> +
+
+ {uploadedDocId && destFolder.folder && ( + + )} + {isUploadFailed &&
Upload failed.
} +
+
+ {uploadStarted && + uploadedDocId === '' && + !isUploadFailed && } + + +
+
+
+
+
+ ); +}; + +export default GoogleDriveUploadDialog; diff --git a/src/explorer/googleDriveUploadDialog/actions.ts b/src/explorer/googleDriveUploadDialog/actions.ts new file mode 100644 index 000000000..d0546275e --- /dev/null +++ b/src/explorer/googleDriveUploadDialog/actions.ts @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2024 The Pybricks Authors + +import { createAction } from '../../actions'; + +/** + * Action that requests to show the Google Drive upload dialog. + * @param fileName The name of the local file to be uploaded. + */ +export const googleDriveUploadDialogShow = createAction((fileName: string) => ({ + type: 'explorer.googleDriveUploadDialog.action.show', + fileName, +})); + +/** + * Action that indicates the Google Drive upload dialog was canceled. + */ +export const googleDriveUploadDialogDidCancel = createAction(() => ({ + type: 'explorer.googleDriveUploadDialog.action.didCancel', +})); diff --git a/src/explorer/googleDriveUploadDialog/i18n.ts b/src/explorer/googleDriveUploadDialog/i18n.ts new file mode 100644 index 000000000..f358aafba --- /dev/null +++ b/src/explorer/googleDriveUploadDialog/i18n.ts @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2024 The Pybricks Authors + +import { useI18n as useShopifyI18n } from '@shopify/react-i18n'; +import type { TypedI18n } from '../../i18n'; +import type translations from './translations/en.json'; + +export function useI18n(): TypedI18n { + // istanbul ignore next: babel-loader rewrites this line + const [i18n] = useShopifyI18n(); + return i18n; +} diff --git a/src/explorer/googleDriveUploadDialog/reducers.ts b/src/explorer/googleDriveUploadDialog/reducers.ts new file mode 100644 index 000000000..448d20b19 --- /dev/null +++ b/src/explorer/googleDriveUploadDialog/reducers.ts @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2024 The Pybricks Authors + +import { Reducer, combineReducers } from 'redux'; +import { + googleDriveDidSelectFolder, + googleDriveDidUploadFile, + googleDriveFailedToUploadFile, +} from '../../googleDrive/actions'; +import { DriveDocument } from '../../googleDrive/protocol'; +import { + googleDriveUploadDialogDidCancel, + googleDriveUploadDialogShow, +} from './actions'; + +const initialDialogFileName = ''; + +interface PickedFolder { + folder?: DriveDocument; +} + +/** Controls the Google Drive upload file dialog isOpen state. */ +const isOpen: Reducer = (state = false, action) => { + if (googleDriveUploadDialogShow.matches(action)) { + return true; + } + + if (googleDriveUploadDialogDidCancel.matches(action)) { + return false; + } + + return state; +}; + +const fileName: Reducer = (state = initialDialogFileName, action) => { + if (googleDriveUploadDialogShow.matches(action)) { + return action.fileName; + } + + return state; +}; + +const descFolder: Reducer = (state = {}, action) => { + if (googleDriveDidSelectFolder.matches(action)) { + return { folder: action.folder }; + } + return state; +}; + +const uploadedDocId: Reducer = (state = '', action) => { + if (googleDriveUploadDialogShow.matches(action)) { + return ''; + } + if (googleDriveDidUploadFile.matches(action)) { + return action.uploadedFileId; + } + return state; +}; + +const isUploadFailed: Reducer = (state = false, action) => { + if (googleDriveUploadDialogShow.matches(action)) { + return false; + } + if (googleDriveFailedToUploadFile.matches(action)) { + return false; + } + return state; +}; +export default combineReducers({ + isOpen, + fileName, + descFolder, + uploadedDocId, + isUploadFailed, +}); diff --git a/src/explorer/googleDriveUploadDialog/translations/en.json b/src/explorer/googleDriveUploadDialog/translations/en.json new file mode 100644 index 000000000..4125cdb39 --- /dev/null +++ b/src/explorer/googleDriveUploadDialog/translations/en.json @@ -0,0 +1,12 @@ +{ + "title": "Upload '{fileName}' to Google Drive", + "upload_to": "Upload To", + "upload_to_sub_label": "Choose a destination folder on your Google Drive", + + "action": { + "change_destination": "Change Destination", + "choose_destination": "Choose Destination", + "upload": "Upload", + "cancel": "Cancel" + } +} diff --git a/src/explorer/reducers.ts b/src/explorer/reducers.ts index ebfea2dc9..d9dd07e5a 100644 --- a/src/explorer/reducers.ts +++ b/src/explorer/reducers.ts @@ -1,10 +1,11 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2022-2023 The Pybricks Authors +// Copyright (c) 2022-2024 The Pybricks Authors import { combineReducers } from 'redux'; import deleteFileAlert from './deleteFileAlert/reducers'; import duplicateFileDialog from './duplicateFileDialog/reducers'; +import googleDriveUploadDialog from './googleDriveUploadDialog/reducers'; import newFileWizard from './newFileWizard/reducers'; import renameFileDialog from './renameFileDialog/reducers'; import renameImportDialog from './renameImportDialog/reducers'; @@ -17,4 +18,5 @@ export default combineReducers({ renameFileDialog, renameImportDialog, replaceImportDialog, + googleDriveUploadDialog, }); diff --git a/src/explorer/sagas.ts b/src/explorer/sagas.ts index 84ced80ea..b1e8d6835 100644 --- a/src/explorer/sagas.ts +++ b/src/explorer/sagas.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2022-2023 The Pybricks Authors +// Copyright (c) 2022-2025 The Pybricks Authors +import { convertProjectToPython, supportedExtensions } from 'blocklypy'; import { fileOpen, fileSave } from 'browser-fs-access'; import JSZip from 'jszip'; import { @@ -45,6 +46,12 @@ import { fileStorageRenameFile, fileStorageWriteFile, } from '../fileStorage/actions'; +import { + googleDriveDidDownloadFile, + googleDriveDidSelectDownloadFiles, + googleDriveDownloadFile, + googleDriveFailToDownloadFile, +} from '../googleDrive/actions'; import { FileNameValidationResult, pythonFileExtension, @@ -76,6 +83,7 @@ import { explorerExportFile, explorerImportFiles, explorerRenameFile, + explorerUploadFileToGoogleDrive, explorerUserActivateFile, explorerUserDidActivateFile, } from './actions'; @@ -90,6 +98,7 @@ import { duplicateFileDialogShow, } from './duplicateFileDialog/actions'; import { ExplorerError } from './error'; +import { googleDriveUploadDialogShow } from './googleDriveUploadDialog/actions'; import { newFileWizardDidAccept, newFileWizardDidCancel, @@ -171,6 +180,12 @@ function* handleExplorerArchiveAllFiles(): Generator { } } +function* handleUploadFileToGoogleDrive( + action: ReturnType, +): Generator { + yield* put(googleDriveUploadDialogShow(action.fileName)); +} + type ImportContext = { rememberedAction?: ReplaceImportDialogAction; }; @@ -254,7 +269,7 @@ function* importPythonFile( } else { yield* put(fileStorageWriteFile(fileName, sourceFileContents)); - const { didFailToWrite } = yield* race({ + const { didWrite, didFailToWrite } = yield* race({ didWrite: take(fileStorageDidWriteFile.when((a) => a.path === fileName)), didFailToWrite: take( fileStorageDidFailToWriteFile.when((a) => a.path === fileName), @@ -264,41 +279,113 @@ function* importPythonFile( if (didFailToWrite) { throw didFailToWrite.error; } + + defined(didWrite); + + yield* put(editorActivateFile(didWrite.uuid)); + } +} + +function* handleGoogleDriveDidSelectDownloadFiles( + action: ReturnType, +): Generator { + try { + const context: ImportContext = {}; + for (const file of action.files) { + console.log(file); + switch (file.mimeType) { + case '': // empty string means "could not be determined" + case pythonFileMimeType: + { + yield* put(googleDriveDownloadFile(file)); + + const { didDownload, didFailToDownload } = yield* race({ + didDownload: take( + googleDriveDidDownloadFile.when( + (a) => a.file.id === file.id, + ), + ), + didFailToDownload: take( + googleDriveFailToDownloadFile.when( + (a) => a.file.id === file.id, + ), + ), + }); + + if (didFailToDownload) { + console.log(didFailToDownload); + } + defined(didDownload); + + yield* importPythonFile( + file.name, + didDownload.content, + context, + ); + } + break; + default: + throw new Error( + `'${file.name}' has unsupported file type: ${file.type}`, + ); + } + } + yield* put(explorerDidImportFiles()); + } catch (err) { + yield* put(explorerDidFailToImportFiles(ensureError(err))); } } function* handleExplorerImportFiles(): Generator { try { const selectedFiles = yield* call(() => - fileOpen([ - { - id: 'pybricks-code-explorer-import', - mimeTypes: [pythonFileMimeType], - extensions: [pythonFileExtension], - // TODO: translate description - description: 'Python Files', - multiple: true, - excludeAcceptAllOption: true, - }, - { - mimeTypes: [zipFileMimeType], - extensions: [zipFileExtension], - // TODO: translate description - description: 'ZIP Files', - }, - ]), + fileOpen({ + id: 'pybricks-code-explorer-import', + mimeTypes: [pythonFileMimeType, zipFileMimeType], + extensions: [ + pythonFileExtension, + zipFileExtension, + ...supportedExtensions(), + ], + description: 'Supported Files (Python, ZIP, LEGO Blockly)', + multiple: true, + excludeAcceptAllOption: true, + }), ); const context: ImportContext = {}; for (const file of selectedFiles) { + // console.log(`file type "${file.type}"`); switch (file.type) { case '': // empty string means "could not be determined" case pythonFileMimeType: { - // getting the text now to catch possible error *before* user interaction - const text = yield* call(() => file.text()); - yield* importPythonFile(file.name, text, context); + const extension = file.name.includes('.') + ? '.' + file.name.split('.').pop() + : ''; + let text; + let filename = file.name; + if (extension === pythonFileExtension) { + // getting the text now to catch possible error *before* user interaction + text = yield* call(() => file.text()); + } else { + // if (supportedExtensions().includes(extension)) { + const arraybuffer = yield* call(() => file.arrayBuffer()); + const inputfiles = [ + { name: filename, buffer: arraybuffer }, + ]; + const result = yield* call(() => + convertProjectToPython(inputfiles, {}), + ); + if (Array.isArray(result.pycode)) { + text = result.pycode?.join('\n'); + } else { + text = result.pycode ?? ''; + } + filename = filename.replace('.', '_') + '.py'; + } + yield* importPythonFile(filename, text, context); } break; case zipFileMimeType: @@ -592,4 +679,9 @@ export default function* (): Generator { yield* takeEvery(explorerDuplicateFile, handleExplorerDuplicateFile); yield* takeEvery(explorerExportFile, handleExplorerExportFile); yield* takeEvery(explorerDeleteFile, handleExplorerDeleteFile); + yield* takeEvery(explorerUploadFileToGoogleDrive, handleUploadFileToGoogleDrive); + yield* takeEvery( + googleDriveDidSelectDownloadFiles, + handleGoogleDriveDidSelectDownloadFiles, + ); } diff --git a/src/explorer/translations/en.json b/src/explorer/translations/en.json index 694804d77..74e46e725 100644 --- a/src/explorer/translations/en.json +++ b/src/explorer/translations/en.json @@ -4,7 +4,8 @@ "title": "File Actions", "exportAll": "Backup all files", "import": "Import a file", - "addNew": "Create a new file" + "addNew": "Create a new file", + "importFromGoogleDrive": "Import file(s) from Google Drive" } }, "tree": { diff --git a/src/fileStorage/sagas.ts b/src/fileStorage/sagas.ts index da0f20564..14009fc77 100644 --- a/src/fileStorage/sagas.ts +++ b/src/fileStorage/sagas.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2022-2023 The Pybricks Authors +// Copyright (c) 2022-2024 The Pybricks Authors import Dexie from 'dexie'; import { diff --git a/src/firmware/installPybricksDialog/InstallPybricksDialog.tsx b/src/firmware/installPybricksDialog/InstallPybricksDialog.tsx index b5643ab6e..39cc3d5e6 100644 --- a/src/firmware/installPybricksDialog/InstallPybricksDialog.tsx +++ b/src/firmware/installPybricksDialog/InstallPybricksDialog.tsx @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2022-2023 The Pybricks Authors +// Copyright (c) 2022-2024 The Pybricks Authors import './installPybricksDialog.scss'; import { @@ -23,7 +23,7 @@ import { ChevronDown, ChevronRight, Error, Heart } from '@blueprintjs/icons'; import { FirmwareMetadata, HubType } from '@pybricks/firmware'; import { fileOpen } from 'browser-fs-access'; import classNames from 'classnames'; -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { VisuallyHidden } from 'react-aria'; import { useDropzone } from 'react-dropzone'; import { useDispatch } from 'react-redux'; @@ -383,12 +383,14 @@ const AcceptLicensePanel: React.FunctionComponent = ({ type SelectOptionsPanelProps = { hubName: string; + hubDefaultName: string; metadata: FirmwareMetadata | undefined; onChangeHubName(hubName: string): void; }; const ConfigureOptionsPanel: React.FunctionComponent = ({ hubName, + hubDefaultName, metadata, onChangeHubName, }) => { @@ -408,7 +410,7 @@ const ConfigureOptionsPanel: React.FunctionComponent = onMouseOver={(e) => e.preventDefault()} onMouseDown={(e) => e.stopPropagation()} intent={isHubNameValid ? Intent.NONE : Intent.DANGER} - placeholder="Pybricks Hub" + placeholder={hubDefaultName || 'Pybricks Hub'} rightElement={ isHubNameValid ? undefined : ( = ( export const InstallPybricksDialog: React.FunctionComponent = () => { const { isOpen } = useSelector((s) => s.firmware.installPybricksDialog); + const deviceName = useSelector((s) => s.ble.deviceName); const inProgress = useSelector( (s) => s.firmware.isFirmwareFlashUsbDfuInProgress || s.firmware.isFirmwareRestoreOfficialDfuInProgress, ); const dispatch = useDispatch(); + const [deviceNameLastConnected, setDeviceNameLastConnected] = useLocalStorage( + 'setting.lastConnectedDeviceName', + '', + ); const [hubName, setHubName] = useState(''); const [licenseAccepted, setLicenseAccepted] = useState(false); const [hubType] = useHubPickerSelectedHub(); @@ -472,6 +479,12 @@ export const InstallPybricksDialog: React.FunctionComponent = () => { ? getHubTypeFromMetadata(customFirmwareData?.metadata, hubType) : hubType; + useEffect(() => { + if (deviceName) { + setDeviceNameLastConnected(deviceName); + } + }, [deviceName, setDeviceNameLastConnected]); + return ( { firmwareInstallPybricksDialogAccept( hubBootloaderType(selectedHubType), selectedFirmwareData?.firmwareZip ?? new ArrayBuffer(0), - hubName, + hubName || deviceNameLastConnected, ), ), }} @@ -528,6 +541,7 @@ export const InstallPybricksDialog: React.FunctionComponent = () => { panel={ ([]); + const [openPicker, authResponse] = GoogleDrivePicker(); + const dispatch = useDispatch(); + const [lastGoogleDriveFolder] = useLocalStorage('googledrive.selectedFolder', ''); + + const openDownloadPicker = () => { + // TODO: remove after debugging. + console.log( + 'stored_token: ', + sessionStorage.getItem('google_oauth_token_expiration'), + ', ', + sessionStorage.getItem('google_oauth_token'), + ); + const authToken = getStoredOauthToken(); + const folderId = (() => { + try { + return JSON.parse(lastGoogleDriveFolder)?.id; + } catch { + return undefined; + } + })(); + openPicker({ + clientId: googleClientId, + developerKey: googleApiKey, + viewId: 'DOCS', + viewMimeTypes: pythonFileMimeType, + token: authToken, + customScopes: ['https://www.googleapis.com/auth/drive'], + setIncludeFolders: true, + setSelectFolderEnabled: false, + multiselect: true, + supportDrives: true, + setParentFolder: folderId, + callbackFunction: (data: PickerResponse) => { + console.log(data); + if (data.action === 'picked' && data.docs) { + // TODO get parent folder: GET https://www.googleapis.com/drive/v2/files/{fileId}/parents/{parentId} + // setLastGoogleDriveFolder(JSON.stringify(parentFolder)); + if (authToken) { + dispatch(googleDriveDidSelectDownloadFiles(data.docs)); + } else { + setPickedDocs(data.docs); + } + } else { + console.log('dialog cancelled, nothing happens.'); + } + }, + }); + }; + + // When auth token is not available, need to wait for the auth token to be available until dispatching DidSelectDownloadFiles + useEffect(() => { + if (authResponse) { + saveOauthToken(authResponse.access_token, authResponse.expires_in); + if (pickedDocs) { + dispatch(googleDriveDidSelectDownloadFiles(pickedDocs)); + } + } + }, [authResponse, pickedDocs, dispatch]); + return openDownloadPicker; +} + +export function FolderPicker() { + const [openPicker, authResponse] = GoogleDrivePicker(); + const dispatch = useDispatch(); + const [, setLastGoogleDriveFolder] = useLocalStorage( + 'googledrive.selectedFolder', + '', + ); + + const openFolderPicker = () => { + // const folderId = (() => { + // try { + // return JSON.parse(lastGoogleDriveFolder)?.id; + // } catch { + // return undefined; + // } + // })(); + openPicker({ + clientId: googleClientId, + developerKey: googleApiKey, + viewId: 'FOLDERS', + token: getStoredOauthToken(), + customScopes: ['https://www.googleapis.com/auth/drive'], + setSelectFolderEnabled: true, + supportDrives: true, + // setParentFolder: folderId, + callbackFunction: (data: PickerResponse) => { + if (data.action === 'picked' && data.docs) { + setLastGoogleDriveFolder(JSON.stringify(data.docs[0])); + dispatch(googleDriveDidSelectFolder(data.docs[0])); + } + }, + }); + }; + + useEffect(() => { + if (authResponse) { + saveOauthToken(authResponse.access_token, authResponse.expires_in); + } + }, [authResponse]); + + return openFolderPicker; +} diff --git a/src/googleDrive/actions.ts b/src/googleDrive/actions.ts new file mode 100644 index 000000000..9561314d2 --- /dev/null +++ b/src/googleDrive/actions.ts @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2024 The Pybricks Authors + +import { createAction } from '../actions'; +import { DriveDocument } from './protocol'; + +export const googleDriveDidSelectFolder = createAction((folder: DriveDocument) => ({ + type: 'googleDrive.action.didSelectFolder', + folder, +})); + +export const googleDriveUploadFile = createAction( + (fileName: string, targetFolderId: string) => ({ + type: 'googleDrive.action.uploadFile', + fileName, + targetFolderId, + }), +); + +export const googleDriveDidUploadFile = createAction((uploadedFileId: string) => ({ + type: 'googleDrive.action.didUploadFile', + uploadedFileId, +})); + +export const googleDriveFailedToUploadFile = createAction((err: Error) => ({ + type: 'googleDrive.action.failedToUploadFile', + err, +})); + +export const googleDriveSelectDownloadFiles = createAction(() => ({ + type: 'googleDrive.action.selectDownloadFiles', +})); + +export const googleDriveDidSelectDownloadFiles = createAction( + (files: DriveDocument[]) => ({ + type: 'googleDrive.action.didSelectDownloadFiles', + files, + }), +); + +export const googleDriveSelectDownloadFilesCancelled = createAction(() => ({ + type: 'googleDrive.action.selectDownloadFilesCancelled', +})); + +export const googleDriveDownloadFile = createAction((file: DriveDocument) => ({ + type: 'googleDrive.action.downloadFile', + file, +})); + +export const googleDriveDidDownloadFile = createAction( + (file: DriveDocument, content: string) => ({ + type: 'googleDrive.action.didDownloadFile', + file, + content, + }), +); + +export const googleDriveFailToDownloadFile = createAction((file: DriveDocument) => ({ + type: 'googleDrive.action.failToDownloadFile', + file, +})); diff --git a/src/googleDrive/protocol.ts b/src/googleDrive/protocol.ts new file mode 100644 index 000000000..3f5deedb4 --- /dev/null +++ b/src/googleDrive/protocol.ts @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2024 The Pybricks Authors + +/** A individual doc returned from Google Picker API. */ +export interface DriveDocument { + description: string; + downloadUrl?: string; + driveSuccess: boolean; + embedUrl: string; + iconUrl: string; + id: string; + isShared: boolean; + lastEditedUtc: number; + mimeType: string; + name: string; + rotation: number; + rotationDegree: number; + serviceId: string; + sizeBytes: number; + type: string; + uploadState?: string; + url: string; +} + +/** Response from Google Picker API. */ +export interface PickerResponse { + action: string; + docs: DriveDocument[]; +} diff --git a/src/googleDrive/sagas.ts b/src/googleDrive/sagas.ts new file mode 100644 index 000000000..d4c93cd27 --- /dev/null +++ b/src/googleDrive/sagas.ts @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2024 The Pybricks Authors +// Copyright (c) 2024 The Pybricks Authors + +import { call, put, race, take, takeEvery } from 'typed-redux-saga/macro'; +import { + fileStorageDidFailToReadFile, + fileStorageDidReadFile, + fileStorageReadFile, +} from '../fileStorage/actions'; +import { pythonFileMimeType } from '../pybricksMicropython/lib'; +import { defined, ensureError } from '../utils'; +import { + googleDriveDidDownloadFile, + googleDriveDidUploadFile, + googleDriveDownloadFile, + googleDriveFailToDownloadFile, + googleDriveFailedToUploadFile, + googleDriveUploadFile, +} from './actions'; +import { getStoredOauthToken } from './utils'; + +function* handleDownloadFile( + action: ReturnType, +): Generator { + try { + console.log('handleDownloadFile'); + const url = + 'https://www.googleapis.com/drive/v3/files/' + + action.file.id + + '?alt=media'; + const fetchFileContent = fetch(url, { + headers: { + Authorization: 'Bearer ' + getStoredOauthToken(), + }, + }).then((response) => { + if (response.ok) { + return response.text(); + } + throw new Error(`Fetch error: ${response.status}`); + }); + const fileContent = yield* call(() => fetchFileContent); + yield* put(googleDriveDidDownloadFile(action.file, fileContent)); + } catch (err) { + yield* put(googleDriveFailToDownloadFile(action.file)); + } +} + +function* handleUploadFile( + action: ReturnType, +): Generator { + try { + yield* put(fileStorageReadFile(action.fileName)); + console.log(action); + + const { didRead, didFailToRead } = yield* race({ + didRead: take( + fileStorageDidReadFile.when((a) => a.path === action.fileName), + ), + didFailToRead: take( + fileStorageDidFailToReadFile.when((a) => a.path === action.fileName), + ), + }); + + if (didFailToRead) { + throw didFailToRead.error; + } + + defined(didRead); + + //---- + + const authToken = getStoredOauthToken(); + + defined(authToken); + + const downloadFile = new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + const url = + 'https://www.googleapis.com/drive/v3/files?fields=files(id)&q=' + + encodeURIComponent( + `name='${action.fileName}' and '${action.targetFolderId}' in parents`, + ); + xhr.open('GET', url); + xhr.setRequestHeader('Authorization', 'Bearer ' + authToken); + xhr.responseType = 'json'; + xhr.onload = () => { + console.log(xhr.response); + resolve(xhr.response.files[0]?.id); + }; + xhr.onerror = (err) => { + console.info('error:', xhr.response); + reject(err); + }; + xhr.send(); + }); + const existing_file_id = yield* call(() => downloadFile); + console.log(`existing file: ${existing_file_id}`); + + //---- + + const form = new FormData(); + form.append( + 'metadata', + new Blob( + [ + JSON.stringify({ + name: action.fileName, + description: 'Pybricks Python file', + mimeType: pythonFileMimeType, + ...(!existing_file_id + ? { parents: [action.targetFolderId] } + : {}), + //starred: true, + }), + ], + { type: 'application/json' }, + ), + ); + form.append('file', new Blob([didRead.contents], { type: pythonFileMimeType })); + + // overwrite: PUT https://www.googleapis.com/upload/drive/v3/files/[FILE_ID] + + const uploadFile = new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + const method = existing_file_id ? 'PATCH' : 'POST'; + const url = + 'https://www.googleapis.com/upload/drive/v3/files' + + (existing_file_id ? `/${existing_file_id}` : '') + + '?uploadType=multipart&fields=id'; + console.info(url); + xhr.open(method, url); + xhr.setRequestHeader('Authorization', 'Bearer ' + authToken); + xhr.responseType = 'json'; + xhr.onload = () => { + console.log(xhr.response); + console.log('Google drive file id:', xhr.response.id); + resolve(xhr.response.id); + }; + xhr.onerror = (event) => { + console.log('Failed to upload file to Google Drive:', event); + reject(event); + }; + xhr.send(form); + }); + + const fileId = yield* call(() => uploadFile); + + yield* put(googleDriveDidUploadFile(fileId)); + } catch (err) { + console.log('Failed to upload file to Google Drive:', err); + yield* put(googleDriveFailedToUploadFile(ensureError(err))); + } +} + +export default function* (): Generator { + yield* takeEvery(googleDriveDownloadFile, handleDownloadFile); + yield* takeEvery(googleDriveUploadFile, handleUploadFile); +} diff --git a/src/googleDrive/utils.ts b/src/googleDrive/utils.ts new file mode 100644 index 000000000..eae2197e5 --- /dev/null +++ b/src/googleDrive/utils.ts @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2024 The Pybricks Authors + +const googleOauthTokenExpirationStorageKey = 'google_oauth_token_expiration'; +const googleOauthTokenStorageKey = 'google_oauth_token'; + +export function getStoredOauthToken(): string { + const tokenExpiration = sessionStorage.getItem( + googleOauthTokenExpirationStorageKey, + ); + if (!tokenExpiration || Date.now() > parseInt(tokenExpiration)) { + return ''; + } + + return sessionStorage.getItem(googleOauthTokenStorageKey) || ''; +} +export function saveOauthToken(authToken: string, expireIn: number) { + console.log('auth token updated'); + sessionStorage.setItem(googleOauthTokenStorageKey, authToken); + sessionStorage.setItem( + googleOauthTokenExpirationStorageKey, + (1000 * expireIn + Date.now()).toString(), + ); +} diff --git a/src/hub/reducers.ts b/src/hub/reducers.ts index 28a5c672b..cd49e3558 100644 --- a/src/hub/reducers.ts +++ b/src/hub/reducers.ts @@ -209,6 +209,17 @@ const hasRepl: Reducer = (state = false, action) => { return state; }; +/** + * Indicates if the connected hub supports a Port View. + */ +const hasPortView: Reducer = (state = false, action) => { + if (blePybricksServiceDidReceiveHubCapabilities.matches(action)) { + return Boolean(action.flags & HubCapabilityFlag.HasPortView); + } + + return state; +}; + /** * The preferred file format of the connected hub or null if the hub does not * support any file formats that Pybricks Code supports. @@ -281,6 +292,7 @@ export default combineReducers({ maxBleWriteSize, maxUserProgramSize, hasRepl, + hasPortView, preferredFileFormat, useLegacyDownload, useLegacyStdio, diff --git a/src/hubcenter/HubCenterContext.tsx b/src/hubcenter/HubCenterContext.tsx new file mode 100644 index 000000000..2311e4466 --- /dev/null +++ b/src/hubcenter/HubCenterContext.tsx @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2021-2024 The Pybricks Authors +// +// Shared terminal context. + +import { createContext } from 'react'; +import PushStream from 'zen-push'; + +/** The default hubcenter context. */ +export const defaultHubCenterContext = { + dataSource: new PushStream(), +}; + +/** Hubcenter context data type. */ +export type HubCenterContextValue = typeof defaultHubCenterContext; + +/** Hubcenter React context. */ +export const HubCenterContext = createContext(defaultHubCenterContext); diff --git a/src/hubcenter/HubCenterDialog.tsx b/src/hubcenter/HubCenterDialog.tsx new file mode 100644 index 000000000..5fe5b15ab --- /dev/null +++ b/src/hubcenter/HubCenterDialog.tsx @@ -0,0 +1,185 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022-2024 The Pybricks Authors + +import { Classes, Dialog, Icon } from '@blueprintjs/core'; +import { Lightning, Power } from '@blueprintjs/icons'; +import classNames from 'classnames'; +import React, { + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { useDispatch } from 'react-redux'; +import { useEventCallback } from 'usehooks-ts'; +import { Button } from '../components/Button'; +import { useSelector } from '../reducers'; +import { HubCenterContext } from './HubCenterContext'; +import PortComponent, { PortData } from './PortComponent'; +import { executeAppDataCommand, hubcenterHideDialog } from './actions'; +import './hub-center-dialog.scss'; +import { useI18n } from './i18n'; +import HubIconComponent, { getHubPortCount } from './icons/HubCenterIcon'; + +const HubcenterDialog: React.FunctionComponent = () => { + const { showDialog, deviceName, deviceType, deviceFirmwareVersion } = useSelector( + (s) => ({ + showDialog: s.hubcenter.showDialog, + deviceName: s.ble.deviceName, + deviceType: s.ble.deviceType, + deviceFirmwareVersion: s.ble.deviceFirmwareVersion, + }), + ); + + const hubcenterStream = useContext(HubCenterContext); + const [hubBattery, setHubBattery] = useState(''); + const [hubBatteryCharger, setHubBatteryCharger] = useState(false); + const [hubImuData, setHubImuData] = useState(''); + const portDataRef = useRef(new Map()); + const portModesRef = useRef(new Map()); + const [portData, setPortData] = useState(new Map()); + const dispatch = useDispatch(); + const i18n = useI18n(); + const subscriptionRef = useRef(null); + const partialMessageRef = useRef(''); + + // NOTE: port data reference contains the current value, subscription should be initied only on mount, + // and not be updated when it changes, while portData/setPortData will be updated on every message + // and triggers the component UI update. + + const parseArrayToMap = (input: string[]): Map => { + return input.reduce((map, pair) => { + const [key, value] = pair.split('='); + if (key && value) { + map.set(key.trim(), value.trim()); + } + return map; + }, new Map()); + }; + + const processMessage = useCallback((message: string) => { + const [key, ...dataraw] = message.split('\t'); + const dataMap = parseArrayToMap(dataraw); + + switch (key) { + case 'battery': + setHubBattery(dataMap.get('pct') ?? ''); + setHubBatteryCharger(parseInt(dataMap.get('s') ?? '') > 0); + break; + case 'imu': + setHubImuData(dataraw.join(', ')); + break; + default: + if (key.startsWith('Port.')) { + const port = key; + const puptype = parseInt(dataraw[0]) ?? 0; + const dataStr = dataraw.slice(1).join(', '); + + const portdata = + portDataRef.current.get(port) ?? + ({ + type: puptype, + dataMap: new Map(), + } as PortData); + portdata.type = puptype; + + if (!dataStr || puptype === 0) { + portDataRef.current.delete(port); + portModesRef.current.delete(port); + } else if (dataraw[1] === 'modes') { + portModesRef.current.set(port, dataraw.slice(2)); + } else { + portdata.dataMap = dataMap; + portdata.dataStr = dataStr; + portDataRef.current.set(port, portdata); + } + + setPortData(new Map(portDataRef.current)); + } + break; + } + }, []); + + useEffect(() => { + subscriptionRef.current = hubcenterStream.dataSource.observable.subscribe({ + next: (d) => { + const combinedMessage = partialMessageRef.current + d; + const parts = combinedMessage.split('\n'); + + // Process all complete messages + for (let i = 0; i < parts.length - 1; i++) { + const message = parts[i].trim(); + if (message) { + processMessage(message); + } + } + + // Remember any partial leftover + partialMessageRef.current = parts[parts.length - 1]; + }, + }); + + // Cleanup subscription on unmount + return () => subscriptionRef.current?.unsubscribe(); + }, [hubcenterStream.dataSource.observable, processMessage]); + + const portComponents = useMemo(() => { + return [...Array(getHubPortCount(deviceType)).keys()].map((idx: number) => { + const portLabel = String.fromCharCode(65 + idx); // A, B, C, D, E, F + const side = idx % 2 === 0 ? 'left' : 'right'; + return ( + + ); + }); + }, [deviceType, portData]); + + const handleShutdown = useEventCallback(() => { + const msg = new Uint8Array(['a'.charCodeAt(0), 's'.charCodeAt(0)]); + dispatch(executeAppDataCommand(msg)); + // TODO: workaround: should be a didExecutedAppCommand + setTimeout(() => dispatch(hubcenterHideDialog()), 300); + }); + + return ( + dispatch(hubcenterHideDialog())} + > +
+

+ {deviceName} + + + {deviceType}, {deviceFirmwareVersion}, {hubBattery} + {hubBatteryCharger && } />} + +

+ +
+ + {portComponents} +
+
+
+
+
+ ); +}; + +export default HubcenterDialog; diff --git a/src/hubcenter/PortComponent.tsx b/src/hubcenter/PortComponent.tsx new file mode 100644 index 000000000..591930b5c --- /dev/null +++ b/src/hubcenter/PortComponent.tsx @@ -0,0 +1,240 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2024 The Pybricks Authors + +import { AnchorButton, Button, ButtonGroup } from '@blueprintjs/core'; +import { CaretDown, Repeat, Reset } from '@blueprintjs/icons'; +import React, { useRef } from 'react'; +import { useDispatch } from 'react-redux'; +import { useEventCallback } from 'usehooks-ts'; +import { executeAppDataCommand } from './actions'; +import ColorSensorIconComponent from './icons/ColorSensorIcon'; +import DeviceIcon from './icons/DeviceIcon'; +import MotorIcon from './icons/MotorIcon'; +import boostColorDistanceSensor from './icons/boost_color_distance_sensor_37.png'; +// import wedoMediumMotor from './icons/wedo_2_0_medium_motor_1.png'; +// import poweredUpTrainMotor from './icons/powered_up_train_motor_2.png'; +// import poweredUpLight from './icons/8_powered_up_light.png'; +import boostInteractiveMotor from './icons/boost_interactive_motor_38.png'; +import boostInteractiveMotorShaft from './icons/boost_interactive_motor_38_shaft.png'; +import spikeColorSensor from './icons/spike_color_sensor_61.png'; +import spikeForceSensor from './icons/spike_force_sensor_63.png'; +// import spikeColorLightMatrix from './icons/spike_3x3_color_light_matrix_64.png'; +import spikeLargeAngularMotor from './icons/spike_large_angular_motor_49.png'; +import spikeLargeAngularMotorShaft from './icons/spike_large_angular_motor_49_shaft.png'; +import spikeMediumAngularMotor from './icons/spike_medium_angular_motor_48.png'; +import spikeMediumAngularMotorShaft from './icons/spike_medium_angular_motor_48_shaft.png'; +import spikeSmallAngularMotor from './icons/spike_small_angular_motor_65.png'; +import spikeUltrasonicSensor from './icons/spike_ultrasonic_sensor_62.png'; +import technicLargeAngularMotor from './icons/technic_large_angular_motor_76.png'; +import technicLargeAngularMotorShaft from './icons/technic_large_angular_motor_76_shaft.png'; +import technicMediumAngularMotor from './icons/technic_medium_angular_motor_75.png'; +import technicMediumAngularMotorShaft from './icons/technic_medium_angular_motor_75_shaft.png'; +import wedoInfraredMotionSensor from './icons/wedo_2_0_infrared_motion_sensor_35.png'; +import wedoTiltSensor from './icons/wedo_2_0_tilt_sensor_34.png'; +// import technicLargeMotor from './icons/technic_large_motor_46.png'; +// import technicExtraLargeMotor from './icons/technic_extra_large_motor_47.png'; + +interface PortComponentProps { + portCode: string; + portIndex: number; + side: 'left' | 'right'; + data: Map; + modes: Map; +} + +export interface PortData { + type: number | undefined; + lastUpdated?: Date; + dataMap: Map | undefined; + dataStr: string; +} + +export interface DeviceRegistryEntry { + name: string; + icon?: string; + iconShaft?: string; + classShaft?: string; + canRotate?: boolean; +} + +const DeviceRegistry = new Map([ + [1, { name: 'Wedo 2.0 Medium Motor' }], //, wedoMediumMotor], + [2, { name: 'Powered Up Train Motor' }], //, poweredUpTrainMotor], + [8, { name: 'Powered Up Light' }], //, poweredUpLight], + [34, { name: 'Wedo 2.0 Tilt Sensor', icon: wedoTiltSensor }], + [35, { name: 'Wedo 2.0 Infrared Motion Sensor', icon: wedoInfraredMotionSensor }], + [37, { name: 'BOOST Color Distance Sensor', icon: boostColorDistanceSensor }], + [ + 38, + { + name: 'BOOST Interactive Motor', + icon: boostInteractiveMotor, + iconShaft: boostInteractiveMotorShaft, + classShaft: 'motor-shaft-centered', + canRotate: false, + }, + ], + [46, { name: 'Technic Large Motor' }], //, technicLargeMotor], + [47, { name: 'Technic Extra Large Motor' }], //, technicExtraLargeMotor], + [ + 48, + { + name: 'SPIKE Medium Angular Motor', + icon: spikeMediumAngularMotor, + iconShaft: spikeMediumAngularMotorShaft, + classShaft: 'motor-shaft-start', + canRotate: true, + }, + ], + [ + 49, + { + name: 'SPIKE Large Angular Motor', + icon: spikeLargeAngularMotor, + iconShaft: spikeLargeAngularMotorShaft, + classShaft: 'motor-shaft-start', + canRotate: true, + }, + ], + [61, { name: 'SPIKE Color Sensor', icon: spikeColorSensor }], + [62, { name: 'SPIKE Ultrasonic Sensor', icon: spikeUltrasonicSensor }], + [63, { name: 'SPIKE Force Sensor', icon: spikeForceSensor }], + [64, { name: 'SPIKE 3x3 Color Light Matrix' }], //, spikeColorLightMatrix], + [ + 65, + { + name: 'SPIKE Small Angular Motor', + icon: spikeSmallAngularMotor, + classShaft: 'motor-shaft-start', + canRotate: true, + }, + ], + [ + 75, + { + name: 'Technic Medium Angular Motor', + icon: technicMediumAngularMotor, + iconShaft: technicMediumAngularMotorShaft, + classShaft: 'motor-shaft-start', + canRotate: true, + }, + ], + [ + 76, + { + name: 'Technic Large Angular Motor', + icon: technicLargeAngularMotor, + iconShaft: technicLargeAngularMotorShaft, + classShaft: 'motor-shaft-start', + canRotate: true, + }, + ], +]); + +const PortComponent: React.FunctionComponent = ({ + portCode, + portIndex, + side, + data, + modes, +}) => { + const portModeRef = useRef(0); + const dispatch = useDispatch(); + + const portId = 'Port.' + portCode; + const portData = data.get(portId); + const portModes = modes.get(portId); + const devEntry = DeviceRegistry.get(portData?.type ?? 0); + + // const iconComponent = useMemo(() => { + // if (devEntry?.iconShaft) { + // return ; + // } else if (portData?.type === 61 || portData?.type === 37) { + // return ; + // } else { + // return ; + // } + // }, [portData, devEntry, side]); + + const getIconComponent = () => { + if (devEntry?.iconShaft) { + return ( + <> + + + - - ); + ); + } else { + return ( + + + + + + {i18n.translate('hubInfo.connectedTo')} + + + {deviceName} + + + + {i18n.translate('hubInfo.hubType')} + + {deviceType} + + + + + {i18n.translate('hubInfo.firmware')} + + + v{deviceFirmwareVersion} + + + + } + > + + + ); + } }; const BatteryIndicator: React.FunctionComponent = () => { diff --git a/src/status-bar/translations/en.json b/src/status-bar/translations/en.json index 1fa3ea669..3061d3da0 100644 --- a/src/status-bar/translations/en.json +++ b/src/status-bar/translations/en.json @@ -14,6 +14,9 @@ "hubType": "Hub type:", "firmware": "Firmware:" }, + "hubCenter": { + "title": "Hub Center (F7)" + }, "battery": { "title": "Battery", "low": "Battery is low. Hub will turn off soon.", diff --git a/src/toolbar/Toolbar.tsx b/src/toolbar/Toolbar.tsx index efe8bb6ff..49c917cf7 100644 --- a/src/toolbar/Toolbar.tsx +++ b/src/toolbar/Toolbar.tsx @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020-2023 The Pybricks Authors +// Copyright (c) 2020-2024 The Pybricks Authors import { ButtonGroup } from '@blueprintjs/core'; import React from 'react'; diff --git a/yarn.lock b/yarn.lock index 411f80fc4..7b000d55a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2378,6 +2378,13 @@ __metadata: languageName: node linkType: hard +"@hpcc-js/wasm-graphviz@npm:^1.7.0": + version: 1.7.0 + resolution: "@hpcc-js/wasm-graphviz@npm:1.7.0" + checksum: 599eaa29b6d7fde107c1f54e0e7d1134f8a9f0ccedaafc720d544f6e477e070481599cf22daced1916a71ce012d7cce140eb84507ac9010db84825af59c35988 + languageName: node + linkType: hard + "@humanwhocodes/config-array@npm:^0.11.13": version: 0.11.13 resolution: "@humanwhocodes/config-array@npm:0.11.13" @@ -3012,6 +3019,7 @@ __metadata: babel-plugin-transform-import-meta: ^2.2.1 babel-preset-react-app: ^10.0.1 bfj: ^8.0.0 + blocklypy: ^0.0.86 browser-fs-access: ^0.35.0 browserslist: ^4.22.2 camelcase: ^8.0.0 @@ -3039,6 +3047,8 @@ __metadata: fake-indexeddb: ^5.0.2 file-loader: ^6.2.0 fs-extra: ^11.2.0 + google-drive-picker: ^1.1.29 + googleapi: ^1.0.2 html-webpack-plugin: ^5.6.0 identity-obj-proxy: ^3.0.0 jest: ^29.7.0 @@ -5317,6 +5327,13 @@ __metadata: languageName: node linkType: hard +"@types/prop-types@npm:^15.7.12": + version: 15.7.13 + resolution: "@types/prop-types@npm:15.7.13" + checksum: 8935cad87c683c665d09a055919d617fe951cb3b2d5c00544e3a913f861a2bd8d2145b51c9aa6d2457d19f3107ab40784c40205e757232f6a80cc8b1c815513c + languageName: node + linkType: hard + "@types/qs@npm:*": version: 6.9.7 resolution: "@types/qs@npm:6.9.7" @@ -5340,6 +5357,15 @@ __metadata: languageName: node linkType: hard +"@types/react-dom@npm:^18.3.0": + version: 18.3.0 + resolution: "@types/react-dom@npm:18.3.0" + dependencies: + "@types/react": "*" + checksum: a0cd9b1b815a6abd2a367a9eabdd8df8dd8f13f95897b2f9e1359ea3ac6619f957c1432ece004af7d95e2a7caddbba19faa045f831f32d6263483fc5404a7596 + languageName: node + linkType: hard + "@types/react-splitter-layout@npm:^3.0.5": version: 3.0.5 resolution: "@types/react-splitter-layout@npm:3.0.5" @@ -5369,6 +5395,16 @@ __metadata: languageName: node linkType: hard +"@types/react@npm:^18.3.4": + version: 18.3.11 + resolution: "@types/react@npm:18.3.11" + dependencies: + "@types/prop-types": "*" + csstype: ^3.0.2 + checksum: 6cbf36673b64e758dd61b16c24139d015f58530e0d476777de26ba83f24b55e142fbf64e3b8f6b3c7b05ed9ba548551b2a62d9ffb0f95743d0a368646a619163 + languageName: node + linkType: hard + "@types/redux-logger@npm:^3.0.12": version: 3.0.12 resolution: "@types/redux-logger@npm:3.0.12" @@ -6841,6 +6877,13 @@ __metadata: languageName: node linkType: hard +"base64-js@npm:^1.3.1": + version: 1.5.1 + resolution: "base64-js@npm:1.5.1" + checksum: 669632eb3745404c2f822a18fc3a0122d2f9a7a13f7fb8b5823ee19d1d2ff9ee5b52c53367176ea4ad093c332fd5ab4bd0ebae5a8e27917a4105a4cfc86b1005 + languageName: node + linkType: hard + "batch@npm:0.6.1": version: 0.6.1 resolution: "batch@npm:0.6.1" @@ -6882,6 +6925,36 @@ __metadata: languageName: node linkType: hard +"bl@npm:^4.0.3": + version: 4.1.0 + resolution: "bl@npm:4.1.0" + dependencies: + buffer: ^5.5.0 + inherits: ^2.0.4 + readable-stream: ^3.4.0 + checksum: 9e8521fa7e83aa9427c6f8ccdcba6e8167ef30cc9a22df26effcc5ab682ef91d2cbc23a239f945d099289e4bbcfae7a192e9c28c84c6202e710a0dfec3722662 + languageName: node + linkType: hard + +"blocklypy@npm:^0.0.86": + version: 0.0.86 + resolution: "blocklypy@npm:0.0.86" + dependencies: + "@hpcc-js/wasm-graphviz": ^1.7.0 + buffer: ^6.0.3 + canvas: ^3.1.0 + commander: ^13.1.0 + fast-xml-parser: ^4.5.0 + jszip: ^3.10.1 + dependenciesMeta: + "@hpcc-js/wasm-graphviz": + optional: true + bin: + blocklypy: bin/index.min.js + checksum: e63d8376b471788b76b40a86c5b2648a94742839f40e8638143eb4a9997f3c7d507122007900fa7049a4417296a24c10e8121bb33184cc74a868505f465cd7d9 + languageName: node + linkType: hard + "bluebird@npm:^3.7.2": version: 3.7.2 resolution: "bluebird@npm:3.7.2" @@ -7018,6 +7091,26 @@ __metadata: languageName: node linkType: hard +"buffer@npm:^5.5.0": + version: 5.7.1 + resolution: "buffer@npm:5.7.1" + dependencies: + base64-js: ^1.3.1 + ieee754: ^1.1.13 + checksum: e2cf8429e1c4c7b8cbd30834ac09bd61da46ce35f5c22a78e6c2f04497d6d25541b16881e30a019c6fd3154150650ccee27a308eff3e26229d788bbdeb08ab84 + languageName: node + linkType: hard + +"buffer@npm:^6.0.3": + version: 6.0.3 + resolution: "buffer@npm:6.0.3" + dependencies: + base64-js: ^1.3.1 + ieee754: ^1.2.1 + checksum: 5ad23293d9a731e4318e420025800b42bf0d264004c0286c8cc010af7a270c7a0f6522e84f54b9ad65cbd6db20b8badbfd8d2ebf4f80fa03dab093b89e68c3f9 + languageName: node + linkType: hard + "builtin-modules@npm:^3.1.0": version: 3.2.0 resolution: "builtin-modules@npm:3.2.0" @@ -7174,6 +7267,17 @@ __metadata: languageName: node linkType: hard +"canvas@npm:^3.1.0": + version: 3.1.0 + resolution: "canvas@npm:3.1.0" + dependencies: + node-addon-api: ^7.0.0 + node-gyp: latest + prebuild-install: ^7.1.1 + checksum: d965d13bb75342b83e77cdeffe587237e1291e01aa08e62c932f755a84bcdd4eeb0e77837ae0c110bed96d02c7cb36d15085d493f1710739c0a5aa8fd5e80922 + languageName: node + linkType: hard + "capital-case@npm:^1.0.4": version: 1.0.4 resolution: "capital-case@npm:1.0.4" @@ -7297,6 +7401,13 @@ __metadata: languageName: node linkType: hard +"chownr@npm:^1.1.1": + version: 1.1.4 + resolution: "chownr@npm:1.1.4" + checksum: 115648f8eb38bac5e41c3857f3e663f9c39ed6480d1349977c4d96c95a47266fcacc5a5aabf3cb6c481e22d72f41992827db47301851766c4fd77ac21a4f081d + languageName: node + linkType: hard + "chownr@npm:^2.0.0": version: 2.0.0 resolution: "chownr@npm:2.0.0" @@ -7444,6 +7555,13 @@ __metadata: languageName: node linkType: hard +"commander@npm:^13.1.0": + version: 13.1.0 + resolution: "commander@npm:13.1.0" + checksum: 8ca2fcb33caf2aa06fba3722d7a9440921331d54019dabf906f3603313e7bf334b009b862257b44083ff65d5a3ab19e83ad73af282bd5319f01dc228bdf87ef0 + languageName: node + linkType: hard + "commander@npm:^2.20.0": version: 2.20.3 resolution: "commander@npm:2.20.3" @@ -8094,6 +8212,15 @@ __metadata: languageName: node linkType: hard +"decompress-response@npm:^6.0.0": + version: 6.0.0 + resolution: "decompress-response@npm:6.0.0" + dependencies: + mimic-response: ^3.1.0 + checksum: d377cf47e02d805e283866c3f50d3d21578b779731e8c5072d6ce8c13cc31493db1c2f6784da9d1d5250822120cefa44f1deab112d5981015f2e17444b763812 + languageName: node + linkType: hard + "dedent@npm:^1.0.0": version: 1.3.0 resolution: "dedent@npm:1.3.0" @@ -8146,6 +8273,13 @@ __metadata: languageName: node linkType: hard +"deep-extend@npm:^0.6.0": + version: 0.6.0 + resolution: "deep-extend@npm:0.6.0" + checksum: 7be7e5a8d468d6b10e6a67c3de828f55001b6eb515d014f7aeb9066ce36bd5717161eb47d6a0f7bed8a9083935b465bc163ee2581c8b128d29bf61092fdf57a7 + languageName: node + linkType: hard + "deep-is@npm:^0.1.3, deep-is@npm:~0.1.3": version: 0.1.4 resolution: "deep-is@npm:0.1.4" @@ -8288,6 +8422,13 @@ __metadata: languageName: node linkType: hard +"detect-libc@npm:^2.0.0": + version: 2.0.3 + resolution: "detect-libc@npm:2.0.3" + checksum: 2ba6a939ae55f189aea996ac67afceb650413c7a34726ee92c40fb0deb2400d57ef94631a8a3f052055eea7efb0f99a9b5e6ce923415daa3e68221f963cfc27d + languageName: node + linkType: hard + "detect-newline@npm:^3.0.0": version: 3.1.0 resolution: "detect-newline@npm:3.1.0" @@ -8633,6 +8774,15 @@ __metadata: languageName: node linkType: hard +"end-of-stream@npm:^1.1.0, end-of-stream@npm:^1.4.1": + version: 1.4.4 + resolution: "end-of-stream@npm:1.4.4" + dependencies: + once: ^1.4.0 + checksum: 530a5a5a1e517e962854a31693dbb5c0b2fc40b46dad2a56a2deec656ca040631124f4795823acc68238147805f8b021abbe221f4afed5ef3c8e8efc2024908b + languageName: node + linkType: hard + "enhanced-resolve@npm:^5.15.0": version: 5.15.0 resolution: "enhanced-resolve@npm:5.15.0" @@ -9477,6 +9627,13 @@ __metadata: languageName: node linkType: hard +"expand-template@npm:^2.0.3": + version: 2.0.3 + resolution: "expand-template@npm:2.0.3" + checksum: 588c19847216421ed92befb521767b7018dc88f88b0576df98cb242f20961425e96a92cbece525ef28cc5becceae5d544ae0f5b9b5e2aa05acb13716ca5b3099 + languageName: node + linkType: hard + "expect@npm:^29.0.0, expect@npm:^29.7.0": version: 29.7.0 resolution: "expect@npm:29.7.0" @@ -9584,6 +9741,17 @@ __metadata: languageName: node linkType: hard +"fast-xml-parser@npm:^4.5.0": + version: 4.5.1 + resolution: "fast-xml-parser@npm:4.5.1" + dependencies: + strnum: ^1.0.5 + bin: + fxparser: src/cli/cli.js + checksum: aab32d7f08a95b20f9ecdc2d769531a9dc454faf12740873972f8169c04ab9335ac5df1029ebfe829a01ddbb0ec60572cb7769d6be2409e95a9be8fc6a86e92c + languageName: node + linkType: hard + "fastq@npm:^1.6.0": version: 1.13.0 resolution: "fastq@npm:1.13.0" @@ -9829,6 +9997,13 @@ __metadata: languageName: node linkType: hard +"fs-constants@npm:^1.0.0": + version: 1.0.0 + resolution: "fs-constants@npm:1.0.0" + checksum: 18f5b718371816155849475ac36c7d0b24d39a11d91348cfcb308b4494824413e03572c403c86d3a260e049465518c4f0d5bd00f0371cdfcad6d4f30a85b350d + languageName: node + linkType: hard + "fs-extra@npm:^11.2.0": version: 11.2.0 resolution: "fs-extra@npm:11.2.0" @@ -10030,6 +10205,13 @@ __metadata: languageName: node linkType: hard +"github-from-package@npm:0.0.0": + version: 0.0.0 + resolution: "github-from-package@npm:0.0.0" + checksum: 14e448192a35c1e42efee94c9d01a10f42fe790375891a24b25261246ce9336ab9df5d274585aedd4568f7922246c2a78b8a8cd2571bfe99c693a9718e7dd0e3 + languageName: node + linkType: hard + "glob-parent@npm:^5.1.2, glob-parent@npm:~5.1.2": version: 5.1.2 resolution: "glob-parent@npm:5.1.2" @@ -10169,6 +10351,27 @@ __metadata: languageName: node linkType: hard +"google-drive-picker@npm:^1.1.29": + version: 1.1.29 + resolution: "google-drive-picker@npm:1.1.29" + dependencies: + "@types/prop-types": ^15.7.12 + "@types/react": ^18.3.4 + "@types/react-dom": ^18.3.0 + prop-types: ^15.8.1 + react: ^18.3.1 + react-dom: ^18.3.1 + checksum: 37cd7c1ebda4ea1e5495bdeeae6b157c0a9183fd386eefe2282e1a8325fcd1cdb53bbc1513859437a299dfec1a3b29803d88bf51bc0c647466f24eafa12efe61 + languageName: node + linkType: hard + +"googleapi@npm:^1.0.2": + version: 1.0.2 + resolution: "googleapi@npm:1.0.2" + checksum: f3d7eb9e80e236a68c92bb58578d941ba260cea9cb54351c03fb41a249f04212f040d63a72b7c529466788d02e8a9dc70b355ca1b4dea3c035c035521d28a53c + languageName: node + linkType: hard + "gopd@npm:^1.0.1": version: 1.0.1 resolution: "gopd@npm:1.0.1" @@ -10584,6 +10787,13 @@ __metadata: languageName: node linkType: hard +"ieee754@npm:^1.1.13, ieee754@npm:^1.2.1": + version: 1.2.1 + resolution: "ieee754@npm:1.2.1" + checksum: 5144c0c9815e54ada181d80a0b810221a253562422e7c6c3a60b1901154184f49326ec239d618c416c1c5945a2e197107aee8d986a3dd836b53dffefd99b5e7e + languageName: node + linkType: hard + "ignore@npm:^5.2.0, ignore@npm:^5.2.4": version: 5.2.4 resolution: "ignore@npm:5.2.4" @@ -10665,7 +10875,7 @@ __metadata: languageName: node linkType: hard -"inherits@npm:2, inherits@npm:2.0.4, inherits@npm:^2.0.1, inherits@npm:^2.0.3, inherits@npm:~2.0.3": +"inherits@npm:2, inherits@npm:2.0.4, inherits@npm:^2.0.1, inherits@npm:^2.0.3, inherits@npm:^2.0.4, inherits@npm:~2.0.3": version: 2.0.4 resolution: "inherits@npm:2.0.4" checksum: 4a48a733847879d6cf6691860a6b1e3f0f4754176e4d71494c41f3475553768b10f84b5ce1d40fbd0e34e6bfbb864ee35858ad4dd2cf31e02fc4a154b724d7f1 @@ -10679,7 +10889,7 @@ __metadata: languageName: node linkType: hard -"ini@npm:^1.3.5": +"ini@npm:^1.3.5, ini@npm:~1.3.0": version: 1.3.8 resolution: "ini@npm:1.3.8" checksum: dfd98b0ca3a4fc1e323e38a6c8eb8936e31a97a918d3b377649ea15bdb15d481207a0dda1021efbd86b464cae29a0d33c1d7dcaf6c5672bee17fa849bc50a1b3 @@ -12427,6 +12637,13 @@ __metadata: languageName: node linkType: hard +"mimic-response@npm:^3.1.0": + version: 3.1.0 + resolution: "mimic-response@npm:3.1.0" + checksum: 25739fee32c17f433626bf19f016df9036b75b3d84a3046c7d156e72ec963dd29d7fc8a302f55a3d6c5a4ff24259676b15d915aad6480815a969ff2ec0836867 + languageName: node + linkType: hard + "min-indent@npm:^1.0.0": version: 1.0.1 resolution: "min-indent@npm:1.0.1" @@ -12495,6 +12712,13 @@ __metadata: languageName: node linkType: hard +"minimist@npm:^1.2.3": + version: 1.2.8 + resolution: "minimist@npm:1.2.8" + checksum: 75a6d645fb122dad29c06a7597bddea977258957ed88d7a6df59b5cd3fe4a527e253e9bbf2e783e4b73657f9098b96a5fe96ab8a113655d4109108577ecf85b0 + languageName: node + linkType: hard + "minipass-collect@npm:^1.0.2": version: 1.0.2 resolution: "minipass-collect@npm:1.0.2" @@ -12572,6 +12796,13 @@ __metadata: languageName: node linkType: hard +"mkdirp-classic@npm:^0.5.2, mkdirp-classic@npm:^0.5.3": + version: 0.5.3 + resolution: "mkdirp-classic@npm:0.5.3" + checksum: 3f4e088208270bbcc148d53b73e9a5bd9eef05ad2cbf3b3d0ff8795278d50dd1d11a8ef1875ff5aea3fa888931f95bfcb2ad5b7c1061cfefd6284d199e6776ac + languageName: node + linkType: hard + "mkdirp@npm:^0.5.1": version: 0.5.6 resolution: "mkdirp@npm:0.5.6" @@ -12703,6 +12934,13 @@ __metadata: languageName: node linkType: hard +"napi-build-utils@npm:^2.0.0": + version: 2.0.0 + resolution: "napi-build-utils@npm:2.0.0" + checksum: 532121efd2dd2272595580bca48859e404bdd4ed455a72a28432ba44868c38d0e64fac3026a8f82bf8563d2a18b32eb9a1d59e601a9da4e84ba4d45b922297f5 + languageName: node + linkType: hard + "natural-compare-lite@npm:^1.4.0": version: 1.4.0 resolution: "natural-compare-lite@npm:1.4.0" @@ -12748,6 +12986,24 @@ __metadata: languageName: node linkType: hard +"node-abi@npm:^3.3.0": + version: 3.74.0 + resolution: "node-abi@npm:3.74.0" + dependencies: + semver: ^7.3.5 + checksum: b33617fe1867a261379c5b4340a7f2018547ffa652b469d9459a0038d97c227d6d57f56b921007e6614552c323fdf67feff2eeb3baa85d6f45957983d61eccc7 + languageName: node + linkType: hard + +"node-addon-api@npm:^7.0.0": + version: 7.1.1 + resolution: "node-addon-api@npm:7.1.1" + dependencies: + node-gyp: latest + checksum: 46051999e3289f205799dfaf6bcb017055d7569090f0004811110312e2db94cb4f8654602c7eb77a60a1a05142cc2b96e1b5c56ca4622c41a5c6370787faaf30 + languageName: node + linkType: hard + "node-fetch@npm:^2.6.5": version: 2.6.7 resolution: "node-fetch@npm:2.6.7" @@ -13021,7 +13277,7 @@ __metadata: languageName: node linkType: hard -"once@npm:^1.3.0, once@npm:^1.3.1": +"once@npm:^1.3.0, once@npm:^1.3.1, once@npm:^1.4.0": version: 1.4.0 resolution: "once@npm:1.4.0" dependencies: @@ -14247,6 +14503,28 @@ __metadata: languageName: node linkType: hard +"prebuild-install@npm:^7.1.1": + version: 7.1.3 + resolution: "prebuild-install@npm:7.1.3" + dependencies: + detect-libc: ^2.0.0 + expand-template: ^2.0.3 + github-from-package: 0.0.0 + minimist: ^1.2.3 + mkdirp-classic: ^0.5.3 + napi-build-utils: ^2.0.0 + node-abi: ^3.3.0 + pump: ^3.0.0 + rc: ^1.2.7 + simple-get: ^4.0.0 + tar-fs: ^2.0.0 + tunnel-agent: ^0.6.0 + bin: + prebuild-install: bin.js + checksum: 300740ca415e9ddbf2bd363f1a6d2673cc11dd0665c5ec431bbb5bf024c2f13c56791fb939ce2b2a2c12f2d2a09c91316169e8063a80eb4482a44b8fe5b265e1 + languageName: node + linkType: hard + "prelude-ls@npm:^1.2.1": version: 1.2.1 resolution: "prelude-ls@npm:1.2.1" @@ -14389,6 +14667,16 @@ __metadata: languageName: node linkType: hard +"pump@npm:^3.0.0": + version: 3.0.2 + resolution: "pump@npm:3.0.2" + dependencies: + end-of-stream: ^1.1.0 + once: ^1.3.1 + checksum: e0c4216874b96bd25ddf31a0b61a5613e26cc7afa32379217cf39d3915b0509def3565f5f6968fafdad2894c8bbdbd67d340e84f3634b2a29b950cffb6442d9f + languageName: node + linkType: hard + "punycode@npm:^2.1.0, punycode@npm:^2.1.1": version: 2.1.1 resolution: "punycode@npm:2.1.1" @@ -14473,6 +14761,20 @@ __metadata: languageName: node linkType: hard +"rc@npm:^1.2.7": + version: 1.2.8 + resolution: "rc@npm:1.2.8" + dependencies: + deep-extend: ^0.6.0 + ini: ~1.3.0 + minimist: ^1.2.0 + strip-json-comments: ~2.0.1 + bin: + rc: ./cli.js + checksum: 2e26e052f8be2abd64e6d1dabfbd7be03f80ec18ccbc49562d31f617d0015fbdbcf0f9eed30346ea6ab789e0fdfe4337f033f8016efdbee0df5354751842080e + languageName: node + linkType: hard + "react-app-polyfill@npm:^3.0.0": version: 3.0.0 resolution: "react-app-polyfill@npm:3.0.0" @@ -14608,15 +14910,15 @@ __metadata: languageName: node linkType: hard -"react-dom@npm:^18.2.0": - version: 18.2.0 - resolution: "react-dom@npm:18.2.0" +"react-dom@npm:^18.2.0, react-dom@npm:^18.3.1": + version: 18.3.1 + resolution: "react-dom@npm:18.3.1" dependencies: loose-envify: ^1.1.0 - scheduler: ^0.23.0 + scheduler: ^0.23.2 peerDependencies: - react: ^18.2.0 - checksum: 7d323310bea3a91be2965f9468d552f201b1c27891e45ddc2d6b8f717680c95a75ae0bc1e3f5cf41472446a2589a75aed4483aee8169287909fcd59ad149e8cc + react: ^18.3.1 + checksum: 298954ecd8f78288dcaece05e88b570014d8f6dce5db6f66e6ee91448debeb59dcd31561dddb354eee47e6c1bb234669459060deb238ed0213497146e555a0b9 languageName: node linkType: hard @@ -14795,12 +15097,12 @@ __metadata: languageName: node linkType: hard -"react@npm:^18.2.0": - version: 18.2.0 - resolution: "react@npm:18.2.0" +"react@npm:^18.2.0, react@npm:^18.3.1": + version: 18.3.1 + resolution: "react@npm:18.3.1" dependencies: loose-envify: ^1.1.0 - checksum: 88e38092da8839b830cda6feef2e8505dec8ace60579e46aa5490fc3dc9bba0bd50336507dc166f43e3afc1c42939c09fe33b25fae889d6f402721dcd78fca1b + checksum: a27bcfa8ff7c15a1e50244ad0d0c1cb2ad4375eeffefd266a64889beea6f6b64c4966c9b37d14ee32d6c9fcd5aa6ba183b6988167ab4d127d13e7cb5b386a376 languageName: node linkType: hard @@ -14839,6 +15141,17 @@ __metadata: languageName: node linkType: hard +"readable-stream@npm:^3.1.1, readable-stream@npm:^3.4.0": + version: 3.6.2 + resolution: "readable-stream@npm:3.6.2" + dependencies: + inherits: ^2.0.3 + string_decoder: ^1.1.1 + util-deprecate: ^1.0.1 + checksum: bdcbe6c22e846b6af075e32cf8f4751c2576238c5043169a1c221c92ee2878458a816a4ea33f4c67623c0b6827c8a400409bfb3cf0bf3381392d0b1dfb52ac8d + languageName: node + linkType: hard + "readdirp@npm:~3.6.0": version: 3.6.0 resolution: "readdirp@npm:3.6.0" @@ -15250,7 +15563,7 @@ __metadata: languageName: node linkType: hard -"safe-buffer@npm:5.2.1, safe-buffer@npm:>=5.1.0, safe-buffer@npm:^5.1.0, safe-buffer@npm:~5.2.0": +"safe-buffer@npm:5.2.1, safe-buffer@npm:>=5.1.0, safe-buffer@npm:^5.0.1, safe-buffer@npm:^5.1.0, safe-buffer@npm:~5.2.0": version: 5.2.1 resolution: "safe-buffer@npm:5.2.1" checksum: b99c4b41fdd67a6aaf280fcd05e9ffb0813654894223afb78a31f14a19ad220bba8aba1cb14eddce1fcfb037155fe6de4e861784eb434f7d11ed58d1e70dd491 @@ -15330,12 +15643,12 @@ __metadata: languageName: node linkType: hard -"scheduler@npm:^0.23.0": - version: 0.23.0 - resolution: "scheduler@npm:0.23.0" +"scheduler@npm:^0.23.2": + version: 0.23.2 + resolution: "scheduler@npm:0.23.2" dependencies: loose-envify: ^1.1.0 - checksum: d79192eeaa12abef860c195ea45d37cbf2bbf5f66e3c4dcd16f54a7da53b17788a70d109ee3d3dde1a0fd50e6a8fc171f4300356c5aee4fc0171de526bf35f8a + checksum: 3e82d1f419e240ef6219d794ff29c7ee415fbdc19e038f680a10c067108e06284f1847450a210b29bbaf97b9d8a97ced5f624c31c681248ac84c80d56ad5a2c4 languageName: node linkType: hard @@ -15636,6 +15949,17 @@ __metadata: languageName: node linkType: hard +"simple-get@npm:^4.0.0": + version: 4.0.1 + resolution: "simple-get@npm:4.0.1" + dependencies: + decompress-response: ^6.0.0 + once: ^1.3.1 + simple-concat: ^1.0.0 + checksum: e4132fd27cf7af230d853fa45c1b8ce900cb430dd0a3c6d3829649fe4f2b26574c803698076c4006450efb0fad2ba8c5455fbb5755d4b0a5ec42d4f12b31d27e + languageName: node + linkType: hard + "sisteransi@npm:^1.0.5": version: 1.0.5 resolution: "sisteransi@npm:1.0.5" @@ -16148,6 +16472,20 @@ __metadata: languageName: node linkType: hard +"strip-json-comments@npm:~2.0.1": + version: 2.0.1 + resolution: "strip-json-comments@npm:2.0.1" + checksum: 1074ccb63270d32ca28edfb0a281c96b94dc679077828135141f27d52a5a398ef5e78bcf22809d23cadc2b81dfbe345eb5fd8699b385c8b1128907dec4a7d1e1 + languageName: node + linkType: hard + +"strnum@npm:^1.0.5": + version: 1.0.5 + resolution: "strnum@npm:1.0.5" + checksum: 651b2031db5da1bf4a77fdd2f116a8ac8055157c5420f5569f64879133825915ad461513e7202a16d7fec63c54fd822410d0962f8ca12385c4334891b9ae6dd2 + languageName: node + linkType: hard + "style-loader@npm:^3.3.4": version: 3.3.4 resolution: "style-loader@npm:3.3.4" @@ -16325,6 +16663,31 @@ __metadata: languageName: node linkType: hard +"tar-fs@npm:^2.0.0": + version: 2.1.2 + resolution: "tar-fs@npm:2.1.2" + dependencies: + chownr: ^1.1.1 + mkdirp-classic: ^0.5.2 + pump: ^3.0.0 + tar-stream: ^2.1.4 + checksum: 6b4fcd38a644b5cd3325f687b9f1f48cd19809b63cbc8376fe794f68361849a17120d036833b3a97de6acb1df588844476309b8c2d0bcaf53f19da2d56ac07de + languageName: node + linkType: hard + +"tar-stream@npm:^2.1.4": + version: 2.2.0 + resolution: "tar-stream@npm:2.2.0" + dependencies: + bl: ^4.0.3 + end-of-stream: ^1.4.1 + fs-constants: ^1.0.0 + inherits: ^2.0.3 + readable-stream: ^3.1.1 + checksum: 699831a8b97666ef50021c767f84924cfee21c142c2eb0e79c63254e140e6408d6d55a065a2992548e72b06de39237ef2b802b99e3ece93ca3904a37622a66f3 + languageName: node + linkType: hard + "tar@npm:^6.1.11, tar@npm:^6.1.2": version: 6.2.1 resolution: "tar@npm:6.2.1" @@ -16617,6 +16980,15 @@ __metadata: languageName: node linkType: hard +"tunnel-agent@npm:^0.6.0": + version: 0.6.0 + resolution: "tunnel-agent@npm:0.6.0" + dependencies: + safe-buffer: ^5.0.1 + checksum: 05f6510358f8afc62a057b8b692f05d70c1782b70db86d6a1e0d5e28a32389e52fa6e7707b6c5ecccacc031462e4bc35af85ecfe4bbc341767917b7cf6965711 + languageName: node + linkType: hard + "two.js@npm:^0.8.12": version: 0.8.12 resolution: "two.js@npm:0.8.12"