From 146e68f384cde542186d060ab13f4e2ae8dbef7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Vogel?= <104342347+cedricvogel-leo@users.noreply.github.com> Date: Fri, 30 Oct 2026 09:57:55 +0100 Subject: [PATCH] Libraries by URL in share links; notes for broken and long links (P25) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Share links of version 2 name a library by its URL instead of carrying its code ({path, url, readOnly: true}), so an Etappe 4 link with parser.py has about 1,200 characters instead of 5,157. The IDE fetches a library once when the link opens (https or http on localhost, at most 1 MB, without cookies) and keeps its text with the tab's project, so reload and offline use the copy. The file drawer lists it under «Bibliotheken» with its host and a lock; a library that can't be loaded (HTTP status, network error, timeout, too large) is named in the drawer and a notice with «Erneut versuchen», and importing it raises Python's own ModuleNotFoundError, as in the LiaScript macro. «Teilen» names it by URL again; the ZIP holds its code and lists its URL in glpython.json. Links without libraries stay version 1, and links of version 1 open as before. A link that can't be read (cut off, e.g. by OneNote at 2,083 characters, or damaged) opens an empty project with «Dieser Link ist unvollständig oder beschädigt. …» - never another project. «Teilen» still copies a link longer than 2,083 characters, with a note to download the project for a hand-in and a «Herunterladen» button. The unsaved-changes fingerprint ignores read-only files: a library arriving after its fetch is no change of the learner's. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- README.md | 23 ++++++++++++- src/App.tsx | 80 +++++++++++++++++++++++++++++++++++-------- src/i18n.ts | 42 +++++++++++++++++++++++ src/index.css | 8 +++++ src/libraries.ts | 47 +++++++++++++++++++++++++ src/projectShare.ts | 65 +++++++++++++++++++++++++---------- src/projectStorage.ts | 27 +++++++++++---- src/tabProjects.ts | 39 +++++++++++++-------- 9 files changed, 277 insertions(+), 56 deletions(-) create mode 100644 src/libraries.ts diff --git a/CLAUDE.md b/CLAUDE.md index 3be876d..ff67b1e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,7 +24,7 @@ There are no tests. `.github/workflows/ci.yml` runs `npm ci`, the build (tsc cat - `src/index.css` — the shell's styles (`ide-*` classes), from glpython-editor's `--glp-*` tokens. glpython-editor's `style.css` styles only the workspace (plus `icon-button`, `hidden-input` and `auth-notice`, which the shell uses too). - `src/i18n.ts` — only the shell's own strings (`ShellStrings`, de/en). The workspace's strings come from glpython-editor's `coreStrings`, so don't copy them here. A new workspace string goes into glpython-editor's `src/i18n.ts`. - `src/tabProjects.ts`: per-tab projects (P25). The tab's id is in sessionStorage and `history.state` (never in the address); projects are in localStorage, one entry per id. Nothing is deleted when a tab goes away: a page can't tell a close from iPad Safari unloading it (only `history.state` survives that). Open tabs answer over a BroadcastChannel, which finds duplicated tabs and the projects for «Zuletzt geschlossen». -- `src/projectStorage.ts` (folder/ZIP) and `src/projectShare.ts` (`?project=` share links, lz-string, versioned payload). +- `src/projectStorage.ts` (folder/ZIP) and `src/projectShare.ts` (`?project=` share links, lz-string, versioned payload: v1 every file's code, v2 also libraries by URL, fetched by `src/libraries.ts`; a link that can't be read opens an empty project with a notice). - `src/auth.ts` — MSAL. Only active with `VITE_ENTRA_CLIENT_ID` in `.env.local` (README «Microsoft sign-in»). - `vite.config.ts` — `optimizeDeps.exclude: ['glpython-editor']` keeps the editor's worker next to its `dist/index.js`. Don't remove it. diff --git a/README.md b/README.md index 7998d9c..19d6004 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,28 @@ The ⋮ menu and the bar support: and metadata) into a compressed link (`?project=`, or `#project=` beyond GitHub Pages' URL limit) so a project can be shared or reopened without a server, similar to - [webtigerpython.ethz.ch](https://webtigerpython.ethz.ch/) + [webtigerpython.ethz.ch](https://webtigerpython.ethz.ch/). A link longer + than 2,083 characters (OneNote cuts links there) is still copied, with a + note to download the project for a hand-in and a **Herunterladen** button. + +**Libraries by URL.** A share link (version 2) can name a library by its URL +instead of carrying its code, e.g. a course's `parser.py`: +`{"path": "parser.py", "url": "https://…/parser.py", "readOnly": true}`. The +IDE fetches it once when the link opens (https only, or http on localhost; at +most 1 MB; without cookies; the server must send CORS headers, as GitHub +Pages and raw.githubusercontent.com do) and keeps the text with the tab's +project, so a reload or working offline uses that copy. The file drawer lists +it under **Bibliotheken** with its host and a lock. If it can't be loaded +(e.g. HTTP 404, no network, no CORS), the drawer and a notice say so +(«Die Bibliothek parser.py konnte nicht geladen werden (HTTP 404).») with +**Erneut versuchen**; importing it then raises Python's own +`ModuleNotFoundError`, as in the LiaScript macro. **Teilen** names it by URL +again; the ZIP holds its code and lists its URL in `glpython.json` +(`"libraries"`). Links of version 1, with every file's code, open as before. + +**A link that can't be read** (cut off when copied, or damaged) opens an empty +project with «Dieser Link ist unvollständig oder beschädigt. Möglicherweise +wurde er beim Kopieren gekürzt.» - never another project. ZIP projects preserve relative paths, the project name, the configured Python entry point, and which files are read-only (`readOnly` in diff --git a/src/App.tsx b/src/App.tsx index 8a9e3a6..0f1d8bb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,15 +5,20 @@ import type { Project, ProjectFileKind, WorkspaceCommand } from 'glpython-editor import 'glpython-editor/style.css' import { initializeAuth } from './auth' import { exportProjectZip, importProjectFolder, importProjectZip } from './projectStorage' -import { buildShareUrl, readSharedProjectFromLocation } from './projectShare' +import { buildShareUrl, readSharedLinkFromLocation } from './projectShare' +import { fetchLibrary, libraryHost, type LibraryState } from './libraries' import { answerTabQueries, claimTabId, newTabEntry, otherLiveTabIds, projectFingerprint, pruneEntries, recentlyClosed, removeEntry, saveEntry, startTab, type TabEntry } from './tabProjects' import { detectLocale, localeNames, saveLocale, translations, type Locale } from './i18n' // This tab's project to start with (see tabProjects.ts): a share link's -// (`?project=`), else the one this tab held before, else the starter. Runs -// once at module load, since every useState initializer below needs the -// same result. -const startup = startTab(readSharedProjectFromLocation(), initialProject) +// (`?project=`), else the one this tab held before, else the starter; for a +// link that can't be read, an empty project and a notice. Runs once at +// module load, since every useState initializer below needs the same result. +const EMPTY_PROJECT: Project = { id: 'empty', name: translations[detectLocale()].emptyProjectName, mainFile: 'main.py', files: { 'main.py': { path: 'main.py', label: 'main', kind: 'python', code: '' } } } +const startup = startTab(readSharedLinkFromLocation(), initialProject, EMPTY_PROJECT) +// OneNote cuts links at 2,083 characters (Internet Explorer's old limit, +// which other programs kept too): «Teilen» copies longer ones with a note. +const LONG_LINK = 2083 // A fresh tab offers the last project closed within a day: after Safari or // the iPad restarted, a tab comes back empty. const OFFER_CLOSED_MS = 24 * 60 * 60 * 1000 @@ -73,6 +78,12 @@ function App() { const [closedOffer, setClosedOffer] = useState([]) const [closedList, setClosedList] = useState(null) const [updateAvailable, setUpdateAvailable] = useState(false) + // The project's libraries by URL (libraries.ts); a link that couldn't be + // read; the length of a long link just copied. + const [libraries, setLibraries] = useState>(() => startup.entry.libraries ?? {}) + const [brokenLink, setBrokenLink] = useState(startup.broken) + const [longLinkLength, setLongLinkLength] = useState(null) + const fetchingRef = useRef(new Set()) const [authErrorKey, setAuthErrorKey] = useState<'' | 'init' | 'config' | 'cancelled'>('') // The workspace decides itself when to show graphics (only while the // program draws); these props are for hosts with their own toggle. @@ -98,8 +109,26 @@ function App() { if (fingerprint !== lastFingerprintRef.current) changedAtRef.current = Date.now() lastFingerprintRef.current = fingerprint unsavedRef.current = fingerprint !== savedFingerprint - saveEntry({ id: tabId, project, savedFingerprint, changedAt: changedAtRef.current }) - }, [project, savedFingerprint, tabId]) + saveEntry({ id: tabId, project, savedFingerprint, changedAt: changedAtRef.current, libraries }) + }, [project, savedFingerprint, tabId, libraries]) + + // Libraries still to fetch: once, when the link opened (and again after + // «Erneut versuchen»); fetched, a library is a read-only file of the project. + useEffect(() => { + for (const [path, library] of Object.entries(libraries)) { + if (library.status !== 'loading' || fetchingRef.current.has(path)) continue + fetchingRef.current.add(path) + const forTab = tabIdRef.current + void fetchLibrary(library.url, t).then((result) => { + fetchingRef.current.delete(path) + if (tabIdRef.current !== forTab) return + if ('code' in result) setProject((current) => ({ ...current, files: { ...current.files, [path]: { path, label: path.replace(/\.[^/.]+$/, ''), kind: /\.py$/i.test(path) ? 'python' : 'text', code: result.code, readOnly: true } } })) + setLibraries((current) => current[path]?.url !== library.url ? current : { ...current, [path]: 'code' in result ? { url: library.url, status: 'loaded' } : { url: library.url, status: 'failed', failure: result.failure } }) + }) + } + }, [libraries, t]) + const retryLibrary = (path: string) => setLibraries((current) => current[path] ? { ...current, [path]: { url: current[path].url, status: 'loading' } } : current) + const libraryUrls = Object.fromEntries(Object.entries(libraries).map(([path, library]) => [path, library.url])) // Other IDE tabs ask which projects are open; a duplicated tab (same id as // an open one) takes its own copy; a fresh tab offers the last closed @@ -226,7 +255,7 @@ function App() { } const downloadProject = async () => { - const blob = await exportProjectZip(project) + const blob = await exportProjectZip(project, libraryUrls) const url = URL.createObjectURL(blob) const link = document.createElement('a') link.href = url @@ -239,7 +268,8 @@ function App() { // Teilen: the link to a copy of this project, copied with a short // confirmation beside the button, or shown to copy by hand. const shareProject = async () => { - const shareUrl = buildShareUrl(project) + const shareUrl = buildShareUrl(project, libraryUrls) + setLongLinkLength(shareUrl.length > LONG_LINK ? shareUrl.length : null) try { await navigator.clipboard.writeText(shareUrl) setShareCopied(true) @@ -253,9 +283,9 @@ function App() { // Another project in this tab (opened, imported, or «Neues Projekt»): a new // tab project, so the one before stays among «Zuletzt geschlossen» - unless // it is the untouched starter, which there is nothing to come back to. - const switchTabProject = (nextProject: Project, nextSavedFingerprint: string, entry?: TabEntry) => { + const switchTabProject = (nextProject: Project, nextSavedFingerprint: string, entry?: TabEntry, nextLibraries: Record = {}) => { if (projectFingerprint(project) === STARTER_FINGERPRINT) removeEntry(tabId) - const next = entry ?? newTabEntry(nextProject, nextSavedFingerprint) + const next = entry ?? newTabEntry(nextProject, nextSavedFingerprint, Date.now(), Object.fromEntries(Object.entries(nextLibraries).map(([path, url]) => [path, { url, status: 'loaded' as const }]))) if (entry) claimTabId(entry.id) changedAtRef.current = next.changedAt lastFingerprintRef.current = projectFingerprint(next.project) @@ -263,7 +293,10 @@ function App() { setSavedFingerprint(next.savedFingerprint) setProject(next.project) setActiveFile(next.project.mainFile) + setLibraries(next.libraries ?? {}) setClosedOffer([]) + setBrokenLink(false) + setLongLinkLength(null) } // A closed project: in this tab if it holds only the untouched starter, @@ -292,7 +325,7 @@ function App() { zipInputRef.current?.click() return } - switchTabProject(importedProject, projectFingerprint(importedProject)) + switchTabProject(importedProject.project, projectFingerprint(importedProject.project), undefined, importedProject.libraries) } catch { // Folder picker not supported or cancelled - importProjectFolder already // falls back to the hidden zip above when appropriate. @@ -301,11 +334,13 @@ function App() { const openZip = async (file: File) => { const importedProject = await importProjectZip(file, project) - switchTabProject(importedProject, projectFingerprint(importedProject)) + switchTabProject(importedProject.project, projectFingerprint(importedProject.project), undefined, importedProject.libraries) } const authErrorMessage = authErrorKey === 'init' ? t.authInitError : authErrorKey === 'config' ? t.authConfigError : authErrorKey === 'cancelled' ? t.authCancelledError : '' const filePaths = Object.keys(project.files) + // The learner's own files; libraries are listed apart in the drawer. + const ownPaths = filePaths.filter((path) => !libraries[path]) return (
@@ -350,19 +385,34 @@ function App() {
{project.name}
    - {filePaths.map((path) => { const file = project.files[path]; return
  • + {ownPaths.map((path) => { const file = project.files[path]; return
  • {/* A read-only file (e.g. a course's library) can't be renamed or deleted either. */} {file.readOnly ? : <> - + }
  • })}
+ {/* Libraries by URL (libraries.ts): read-only, with where they come from. */} + {Object.keys(libraries).length > 0 &&
+ {t.libraries} +
    + {Object.entries(libraries).map(([path, library]) =>
  • + + {library.status === 'failed' + ? + : } +
  • )} +
+
} }
{authErrorMessage &&
{authErrorMessage}
} + {brokenLink &&
{t.brokenLink}
} + {Object.entries(libraries).filter(([, library]) => library.status === 'failed').map(([path, library]) =>
{t.libraryFailed(path, library.failure ?? '')}
)} + {longLinkLength !== null &&
{t.longLink(longLinkLength)}
} {updateAvailable &&
{t.updateAvailable}
} {closedOffer.length > 0 &&
{t.lastEdited(closedOffer[0].project.mainFile, timeAgo(closedOffer[0].changedAt, locale, t.justNow))}{closedOffer.length > 1 && }
} string justNow: string + emptyProjectName: string + brokenLink: string + longLink: (length: number) => string + downloadForHandIn: string + libraries: string + librarySource: (host: string) => string + libraryLoading: string + libraryFailed: (name: string, reason: string) => string + networkError: string + timeout: string + libraryTooLarge: string + libraryNotHttps: string + retry: string + retryLibrary: (name: string) => string dismiss: string updateAvailable: string reloadNow: string @@ -84,6 +98,20 @@ export const translations: Record = { showAllClosed: 'Show all', lastEdited: (file, when) => `Last edited: ${file} (${when}).`, justNow: 'just now', + emptyProjectName: 'Empty project', + brokenLink: 'This link is incomplete or damaged. It may have been shortened when it was copied.', + longLink: (length) => `This link is very long (${length} characters). Some programs, e.g. OneNote, shorten such links. For a hand-in, download the project instead.`, + downloadForHandIn: 'Download', + libraries: 'Libraries', + librarySource: (host) => `from ${host}`, + libraryLoading: 'loading …', + libraryFailed: (name, reason) => `Could not load library ${name} (${reason}).`, + networkError: 'network error', + timeout: 'timeout', + libraryTooLarge: 'larger than 1 MB', + libraryNotHttps: 'not an https address', + retry: 'Try again', + retryLibrary: (name) => `Load ${name} again`, dismiss: 'Close', updateAvailable: 'A new version is available.', reloadNow: 'Reload', @@ -126,6 +154,20 @@ export const translations: Record = { showAllClosed: 'Alle anzeigen', lastEdited: (file, when) => `Zuletzt bearbeitet: ${file} (${when}).`, justNow: 'gerade eben', + emptyProjectName: 'Leeres Projekt', + brokenLink: 'Dieser Link ist unvollständig oder beschädigt. Möglicherweise wurde er beim Kopieren gekürzt.', + longLink: (length) => `Dieser Link ist sehr lang (${length} Zeichen). Manche Programme, z. B. OneNote, kürzen solche Links. Für eine Abgabe das Projekt besser herunterladen.`, + downloadForHandIn: 'Herunterladen', + libraries: 'Bibliotheken', + librarySource: (host) => `von ${host}`, + libraryLoading: 'wird geladen …', + libraryFailed: (name, reason) => `Die Bibliothek ${name} konnte nicht geladen werden (${reason}).`, + networkError: 'Netzwerkfehler', + timeout: 'Zeitüberschreitung', + libraryTooLarge: 'grösser als 1 MB', + libraryNotHttps: 'keine https-Adresse', + retry: 'Erneut versuchen', + retryLibrary: (name) => `${name} erneut laden`, dismiss: 'Schliessen', updateAvailable: 'Neue Version verfügbar.', reloadNow: 'Neu laden', diff --git a/src/index.css b/src/index.css index 796ed57..5078469 100644 --- a/src/index.css +++ b/src/index.css @@ -55,6 +55,14 @@ button { font: inherit; } .ide-notice { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 0 0 10px; padding: 6px 6px 6px 12px; color: var(--glp-text); background: var(--glp-info-bg); border-left: 3px solid var(--glp-info); border-radius: var(--glp-radius); font-size: 14px; } .ide-notice > span { flex: 1; min-width: 12em; } .ide-notice .secondary-button { padding: 5px 10px; font-size: 13px; } +/* A link that can't be read, a library that couldn't be loaded. */ +.ide-notice--warning { background: var(--glp-warning-bg); border-left-color: var(--glp-warning); } +/* Libraries by URL in the drawer: name, and where they come from. */ +.ide-libraries { display: flex; flex-direction: column; gap: 4px; } +.ide-library-name { min-width: 0; display: flex; flex-direction: column; } +.ide-library-name small { color: var(--glp-text-muted); font: 12px/1.3 var(--glp-font-ui); white-space: normal; } +.ide-library-name small.ide-library-failed { color: var(--glp-warning); } +.ide-file-open:disabled { cursor: default; } /* «Zuletzt geschlossen». */ .ide-dialog-backdrop { position: fixed; inset: 0; z-index: 30; display: grid; place-items: center; padding: 16px; background: rgba(0, 0, 0, .25); } .ide-dialog { width: min(520px, 100%); max-height: 80vh; overflow-y: auto; padding: 16px; background: var(--glp-bg); border: 1px solid var(--glp-border); border-radius: var(--glp-radius); box-shadow: 0 12px 32px rgba(0, 0, 0, .18); } diff --git a/src/libraries.ts b/src/libraries.ts new file mode 100644 index 0000000..21a8ce4 --- /dev/null +++ b/src/libraries.ts @@ -0,0 +1,47 @@ +// Libraries by URL (P25): a share link can name a library - e.g. a course's +// parser.py - by its URL instead of carrying its code. The IDE fetches it +// once, when the link opens, and keeps the text with the tab's project, so a +// reload and offline work use that copy. As in the LiaScript macro, a +// library that can't be loaded is reported and left out: importing it then +// raises Python's own ModuleNotFoundError. + +// A library's state in the tab's project: being fetched, fetched (its code is +// then a read-only file of the project), or failed (with the reason). +export type LibraryState = { url: string; status: 'loading' | 'loaded' | 'failed'; failure?: string } + +export type LibraryStrings = { networkError: string; timeout: string; libraryTooLarge: string; libraryNotHttps: string } + +const MAX_LIBRARY_LENGTH = 1_000_000 +const FETCH_TIMEOUT_MS = 15_000 + +// https, and http only on this computer (a course tested locally). +function allowedUrl(url: string): boolean { + try { + const parsed = new URL(url) + return parsed.protocol === 'https:' || (parsed.protocol === 'http:' && ['localhost', '127.0.0.1'].includes(parsed.hostname)) + } catch { + return false + } +} + +export function libraryHost(url: string): string { + try { return new URL(url).host } catch { return url } +} + +export async function fetchLibrary(url: string, strings: LibraryStrings): Promise<{ code: string } | { failure: string }> { + if (!allowedUrl(url)) return { failure: strings.libraryNotHttps } + const controller = new AbortController() + const timer = window.setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS) + try { + const response = await fetch(url, { signal: controller.signal, credentials: 'omit', cache: 'no-cache' }) + if (!response.ok) return { failure: `HTTP ${response.status}` } + const code = await response.text() + if (code.length > MAX_LIBRARY_LENGTH) return { failure: strings.libraryTooLarge } + return { code } + } catch (error) { + // No network and a server without CORS look the same to a page. + return { failure: (error as Error)?.name === 'AbortError' ? strings.timeout : strings.networkError } + } finally { + window.clearTimeout(timer) + } +} diff --git a/src/projectShare.ts b/src/projectShare.ts index 2f3b82c..6cd40e7 100644 --- a/src/projectShare.ts +++ b/src/projectShare.ts @@ -6,8 +6,12 @@ import type { Project, ProjectFile } from 'glpython-editor' // e.g. a course's library), the same idea as webTigerPython's // `?code=` parameter but extended to a full multi-file project instead of a // single source string. +// +// Version 2 (P25) adds libraries by URL: `{path, url, readOnly: true}` +// without the code, which the IDE fetches when the link opens - so a link +// with a course's parser stays short. A link without libraries stays +// version 1, which every IDE reads. const SHARE_PARAM = 'project' -const SCHEMA_VERSION = 1 // GitHub Pages answers "414 URI Too Long" when a request's path and query // exceed 8192 characters - a program of roughly 5,000 to 30,000 characters, // depending on how well it compresses. A longer link carries the project in @@ -16,13 +20,23 @@ const SCHEMA_VERSION = 1 // the same way. const MAX_PATH_AND_QUERY = 8192 +type LibraryReference = { path: string; url: string; readOnly?: boolean; label?: string; kind?: string } + type SharedProjectPayload = { v: number name: string mainFile: string - files: Record + files: Record } +// What the address brings: no link, one that can't be read (cut off, e.g. +// by OneNote at 2,083 characters, or damaged), or a project with the URLs +// of its libraries. +export type SharedLink = + | { status: 'none' } + | { status: 'broken' } + | { status: 'ok'; project: Project; libraries: Record } + function isProjectFile(value: unknown): value is ProjectFile { if (!value || typeof value !== 'object') return false const file = value as Partial @@ -30,39 +44,54 @@ function isProjectFile(value: unknown): value is ProjectFile { && (file.readOnly === undefined || typeof file.readOnly === 'boolean') } +function isLibraryReference(value: unknown): value is LibraryReference { + if (!value || typeof value !== 'object') return false + const library = value as Partial & { code?: unknown } + return typeof library.path === 'string' && typeof library.url === 'string' && library.code === undefined +} + function isSharedProjectPayload(value: unknown): value is SharedProjectPayload { if (!value || typeof value !== 'object') return false const payload = value as Partial if (typeof payload.name !== 'string' || typeof payload.mainFile !== 'string' || !payload.files || typeof payload.files !== 'object') return false - return Object.values(payload.files).every(isProjectFile) + return Object.values(payload.files).every((file) => isProjectFile(file) || isLibraryReference(file)) } -export function encodeProjectForUrl(project: Project): string { - const payload: SharedProjectPayload = { v: SCHEMA_VERSION, name: project.name, mainFile: project.mainFile, files: project.files } +// `libraries`: the project's libraries by URL (path → URL); they go into the +// link as URLs, not as their code. +export function encodeProjectForUrl(project: Project, libraries: Record = {}): string { + const files: Record = {} + for (const [path, file] of Object.entries(project.files)) if (!libraries[path]) files[path] = file + for (const [path, url] of Object.entries(libraries)) files[path] = { path, url, readOnly: true } + const payload: SharedProjectPayload = { v: Object.keys(libraries).length > 0 ? 2 : 1, name: project.name, mainFile: project.mainFile, files } return compressToEncodedURIComponent(JSON.stringify(payload)) } -export function decodeProjectFromUrl(encoded: string): Project | null { +export function decodeProjectFromUrl(encoded: string): SharedLink { try { const json = decompressFromEncodedURIComponent(encoded) - if (!json) return null + if (!json) return { status: 'broken' } const payload = JSON.parse(json) as unknown - if (!isSharedProjectPayload(payload) || !payload.files[payload.mainFile]) return null - + if (!isSharedProjectPayload(payload) || !isProjectFile(payload.files[payload.mainFile])) return { status: 'broken' } + const files: Record = {} + const libraries: Record = {} + for (const [path, file] of Object.entries(payload.files)) { + if (isProjectFile(file)) files[path] = file + else libraries[path] = file.url + } return { - id: `shared-${Date.now()}`, - name: payload.name.trim() || 'Shared project', - mainFile: payload.mainFile, - files: payload.files, + status: 'ok', + project: { id: `shared-${Date.now()}`, name: payload.name.trim() || 'Shared project', mainFile: payload.mainFile, files }, + libraries, } } catch { - return null + return { status: 'broken' } } } -export function buildShareUrl(project: Project): string { +export function buildShareUrl(project: Project, libraries: Record = {}): string { const url = new URL(import.meta.env.BASE_URL, window.location.origin) - const encoded = encodeProjectForUrl(project) + const encoded = encodeProjectForUrl(project, libraries) url.searchParams.set(SHARE_PARAM, encoded) if (url.pathname.length + url.search.length <= MAX_PATH_AND_QUERY) return url.toString() url.search = '' @@ -74,8 +103,8 @@ export function buildShareUrl(project: Project): string { // query or, for a long one, the fragment). The tab then holds it as its own // project and leaves the bare start page in the address (claimTabId), so // reloading or re-sharing doesn't keep re-importing the same snapshot. -export function readSharedProjectFromLocation(): Project | null { +export function readSharedLinkFromLocation(): SharedLink { const url = new URL(window.location.href) const encoded = url.searchParams.get(SHARE_PARAM) ?? new URLSearchParams(url.hash.slice(1)).get(SHARE_PARAM) - return encoded ? decodeProjectFromUrl(encoded) : null + return encoded ? decodeProjectFromUrl(encoded) : { status: 'none' } } diff --git a/src/projectStorage.ts b/src/projectStorage.ts index 70c62b9..3830e6f 100644 --- a/src/projectStorage.ts +++ b/src/projectStorage.ts @@ -5,7 +5,17 @@ type DirectoryPickerWindow = Window & { showDirectoryPicker?: () => Promise } -type ProjectMetadata = { name?: string; mainFile?: string; readOnly?: string[] } +type ProjectMetadata = { name?: string; mainFile?: string; readOnly?: string[]; libraries?: Record } + +// An imported project, with the URLs of its libraries (path → URL) that +// glpython.json lists: their code is in the ZIP, and a share link of it +// names them by URL again. +export type ImportedProject = { project: Project; libraries: Record } + +function librariesOf(files: Record, metadata: ProjectMetadata): Record { + const libraries = metadata.libraries && typeof metadata.libraries === 'object' ? metadata.libraries : {} + return Object.fromEntries(Object.entries(libraries).filter(([path, url]) => files[path] && typeof url === 'string')) +} // The files glpython.json lists as read-only, marked so. function withReadOnly(files: Record, metadata: ProjectMetadata): Record { @@ -13,17 +23,20 @@ function withReadOnly(files: Record, metadata: ProjectMetad return Object.fromEntries(Object.entries(files).map(([path, file]) => [path, readOnly.includes(path) ? { ...file, readOnly: true } : file])) } -export async function exportProjectZip(project: Project): Promise { +// `libraries`: the project's libraries by URL (path → URL). The ZIP holds +// their code, so a hand-in is complete; glpython.json keeps their URLs. +export async function exportProjectZip(project: Project, libraries: Record = {}): Promise { const zip = new JSZip() for (const file of Object.values(project.files)) zip.file(file.path, file.code) // Which files are read-only (e.g. a course's library) travels in the // metadata, so a ZIP round trip keeps it. const readOnly = Object.values(project.files).filter((file) => file.readOnly).map((file) => file.path) - zip.file('glpython.json', JSON.stringify({ name: project.name, mainFile: project.mainFile, ...(readOnly.length > 0 ? { readOnly } : {}) }, null, 2)) + const urls = Object.fromEntries(Object.entries(libraries).filter(([path]) => project.files[path])) + zip.file('glpython.json', JSON.stringify({ name: project.name, mainFile: project.mainFile, ...(readOnly.length > 0 ? { readOnly } : {}), ...(Object.keys(urls).length > 0 ? { libraries: urls } : {}) }, null, 2)) return zip.generateAsync({ type: 'blob' }) } -export async function importProjectZip(file: File, fallback: Project): Promise { +export async function importProjectZip(file: File, fallback: Project): Promise { const zip = await JSZip.loadAsync(file) const entries = Object.values(zip.files).filter((entry) => !entry.dir && entry.name !== 'glpython.json' && !entry.name.startsWith('__MACOSX/')) const metadataEntry = zip.file('glpython.json') @@ -39,10 +52,10 @@ export async function importProjectZip(file: File, fallback: Project): Promise

{ +export async function importProjectFolder(fallback: Project): Promise { const pickerWindow = window as DirectoryPickerWindow if (!pickerWindow.showDirectoryPicker) return null @@ -57,7 +70,7 @@ export async function importProjectFolder(fallback: Project): Promise { diff --git a/src/tabProjects.ts b/src/tabProjects.ts index 176c41d..e8f6d78 100644 --- a/src/tabProjects.ts +++ b/src/tabProjects.ts @@ -1,4 +1,6 @@ import type { Project } from 'glpython-editor' +import type { LibraryState } from './libraries' +import type { SharedLink } from './projectShare' // Per-tab projects (P25), as in WebTigerPython: each browser tab has its own // project. Opening a share link never replaces another tab's work, and @@ -29,11 +31,15 @@ export type TabEntry = { savedFingerprint: string // The last change to the project. changedAt: number + // Its libraries by URL (path → state), see libraries.ts. + libraries?: Record } -// Changes of a project's content (name, files, file to run), as a short hash. +// Changes of a project's content (name, the learner's files, file to run), as +// a short hash. Read-only files (a course's libraries) aren't the learner's +// work: a library arriving after its fetch is no change. export function projectFingerprint(project: Project): string { - const text = JSON.stringify([project.name, project.mainFile, Object.values(project.files).map((file) => [file.path, file.kind, file.code, file.readOnly === true])]) + const text = JSON.stringify([project.name, project.mainFile, Object.values(project.files).filter((file) => !file.readOnly).map((file) => [file.path, file.kind, file.code])]) let hash = 0x811c9dc5 for (let index = 0; index < text.length; index++) { hash ^= text.charCodeAt(index) @@ -117,40 +123,45 @@ export function claimTabId(id: string): void { } // The project this tab starts with: a share link's (always a new tab -// project), else the one this tab held before (reload, or Safari rebuilding -// the tab), else - in a tab without one - the IDE's former single project -// (once) or the starter. `fresh` is true for the starter: only then is -// another tab's project offered (see recentlyClosed). -export function startTab(sharedProject: Project | null, starter: Project): { entry: TabEntry; fresh: boolean } { +// project, its libraries still to fetch), else the one this tab held before +// (reload, or Safari rebuilding the tab), else - in a tab without one - the +// IDE's former single project (once) or the starter. `fresh` is true for the +// starter: only then is another tab's project offered (see recentlyClosed). +// A link that can't be read gives `empty` and `broken`: never another project. +export function startTab(link: SharedLink, starter: Project, empty: Project): { entry: TabEntry; fresh: boolean; broken: boolean } { const restoreId = new URLSearchParams(window.location.search).get('restore') - if (sharedProject) return { entry: newTabEntry(sharedProject, projectFingerprint(sharedProject)), fresh: false } + if (link.status === 'broken') return { entry: newTabEntry(empty, projectFingerprint(empty)), fresh: false, broken: true } + if (link.status === 'ok') { + const libraries = Object.fromEntries(Object.entries(link.libraries).map(([path, url]) => [path, { url, status: 'loading' as const }])) + return { entry: newTabEntry(link.project, projectFingerprint(link.project), Date.now(), libraries), fresh: false, broken: false } + } // «Öffnen» in «Zuletzt geschlossen» opens a closed project in a new tab. const restored = restoreId ? loadEntry(restoreId) : null if (restored) { claimTabId(restored.id) - return { entry: restored, fresh: false } + return { entry: restored, fresh: false, broken: false } } const ownId = readTabId() const own = ownId ? loadEntry(ownId) : null if (own) { claimTabId(own.id) - return { entry: own, fresh: false } + return { entry: own, fresh: false, broken: false } } const legacy = readJson(localStorage, LEGACY_KEY) if (legacy && legacy.files) { // Counts as unsaved: it was never shared from a tab. const entry = newTabEntry(legacy, '') try { localStorage.removeItem(LEGACY_KEY) } catch { /* storage unavailable */ } - return { entry, fresh: false } + return { entry, fresh: false, broken: false } } - return { entry: newStarterEntry(starter), fresh: true } + return { entry: newStarterEntry(starter), fresh: true, broken: false } } // A new tab project, held by this tab: the starter, an imported project, or // a copy (a duplicated tab gets its own). The one this tab held before stays // as it is, among «Zuletzt geschlossen». -export function newTabEntry(project: Project, savedFingerprint: string, changedAt = Date.now()): TabEntry { - const entry = { id: newTabId(), project, savedFingerprint, changedAt } +export function newTabEntry(project: Project, savedFingerprint: string, changedAt = Date.now(), libraries: Record = {}): TabEntry { + const entry = { id: newTabId(), project, savedFingerprint, changedAt, libraries } saveEntry(entry) claimTabId(entry.id) return entry