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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/web-workspaces.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@matdata/yasgui": minor
---

Add read-only **Web** workspaces (#256): queries published as plain `.rq` files on any web server (GitHub Pages, a static host), listed in a `workspace.json` with optional folder labels, endpoint aliases and per-query name, description and endpoint. Query files may start with grlc-style `#+ name:`, `#+ description:` and `#+ endpoint:` lines, which are removed from the query text. Opening a query switches the tab to its endpoint.

Add one in Settings → Workspaces with the type **Web (read-only)**, or share a link with `?workspace=<URL of workspace.json>`: whoever opens it is asked whether to add the workspace, and then sees it in the query browser. Embedding pages can call `yasgui.addWebWorkspace(url)`, or add `{ type: "http", manifestUrl }` through `persistentConfig.addOrUpdateWorkspace`.
56 changes: 55 additions & 1 deletion docs/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
- [Workspaces](#workspaces)
- [SPARQL workspaces](#sparql-workspaces)
- [Git based workspaces](#git-based-workspaces)
- [Web workspaces](#web-workspaces)
- [Managed queries](#managed-queries)
- [Query History and Persistence](#query-history-and-persistence)
- [Share Queries](#share-queries)
Expand Down Expand Up @@ -1362,6 +1363,7 @@ This feature is designed for **power users** who want to:

**Not for new user onboarding:** This is not primarily intended as a way to distribute sample queries to help new users get started with an endpoint. If you need to provide example queries for onboarding, consider:

- Publishing them as a read-only [web workspace](#web-workspaces): plain `.rq` files plus a `workspace.json` on any web server, shared with one link
- Using the developer configuration to embed queries in your Matgui instance
- Using the snippets functionality for quick templates
- Implementing a custom wrapper that loads example queries programmatically
Expand All @@ -1372,7 +1374,7 @@ This feature is designed for **power users** who want to:

1. Open **Settings** (gear icon in the top bar)
2. Go to the **Workspaces** tab
3. Add one or more workspaces (Git or SPARQL) and choose a **Default workspace**
3. Add one or more workspaces (SPARQL, Git or Web) and choose a **Default workspace**
4. (Optional) Click **Validate access** to test the configuration and show how many managed queries are already saved in that workspace

**Read-only workspaces:** tick **Read-only** in a workspace's settings (or set `readOnly: true` when adding it through `persistentConfig.addOrUpdateWorkspace`) to protect a shared or published set of queries. Queries in a read-only workspace can be browsed and opened, but:
Expand Down Expand Up @@ -1565,6 +1567,58 @@ The token must have access to the specific repository you're configuring as a wo
- The repository can contain other files; only `.rq` and `.sparql` files will be shown in the query browser.
- Authentication/permission errors may not always display clear error messages - check browser console if saves fail silently.

##### Web workspaces

A web workspace is a read-only set of queries published as plain files on a web server, such as GitHub Pages, a university web space or any static file host. Nothing has to be loaded into a SPARQL store and no Git token is needed. MatGUI reads one `workspace.json` file that lists the query files.

**Add one:** in **Settings → Workspaces**, click **Add workspace**, pick the type **Web (read-only)** and enter the URL of the `workspace.json`. The label and description are filled in from the file when you leave them empty. Web workspaces are always read-only (see [Read-only workspaces](#workspaces)), and have no version history.

**Share one with a link:** add `?workspace=<URL of workspace.json>` to the address of MatGUI, for example `https://matgui.matdata.eu/?workspace=https://example.org/queries/workspace.json`. Whoever opens the link is asked whether to add the workspace, and then sees its queries in the query browser. Nothing runs until they open a query and press Run. If the workspace is already configured, the link just shows it. The **Copy link** button next to a web workspace in the settings copies such a link for the current page. Embedding pages can do the same with `yasgui.addWebWorkspace(url)`.

**Publish one:** put the query files and a `workspace.json` in a folder on the web server:

```json
{
"label": "Example queries",
"description": "Optional description of the workspace",
"endpoints": {
"era": "https://graph.data.era.europa.eu/repositories/rinf-plus"
},
"folders": {
"era": { "label": "Railway infrastructure", "endpoint": "era" }
},
"queries": [
"era/operational-points.rq",
{
"url": "./other/example.rq",
"name": "Name shown in the query browser",
"description": "Shown in the query preview",
"endpoint": "era",
"folder": "era"
}
]
}
```

- `queries` (required) lists the query files, because a web server cannot list a folder. An entry is a URL, or an object with a `url` and optional `name`, `description`, `endpoint` and `folder`. URLs are relative to `workspace.json`; absolute URLs work too.
- The folder of a query in the query browser is the folder of its file, unless the entry sets `folder`. `folders` gives a folder a readable `label` and a default `endpoint` for the queries in it and its sub-folders.
- `endpoints` defines short aliases that `endpoint` values can use. An `endpoint` can also be a full URL. Opening a query switches the tab to its endpoint.
- `label` and `description` are used as the workspace's label and description when it is added.
- Other keys are ignored.

A query file can start with header lines in the `#+ key: value` form that [grlc](https://grlc.io) uses. MatGUI reads `name`, `description` and `endpoint` from them and removes them from the query text it shows:

```sparql
#+ name: Operational points per country
#+ description: Counts the operational points in RINF per member state.
#+ endpoint: era
SELECT ?country (COUNT(?op) AS ?ops) WHERE { ... }
```

A value in `workspace.json` wins over the file header; without either, the file name is used as the name (`operational-points.rq` becomes "operational points").

**Web server requirements:** MatGUI fetches the files from the browser, so the server must allow cross-origin requests (send `Access-Control-Allow-Origin: *`). GitHub Pages and `raw.githubusercontent.com` do this. On an `https` page the files must be served over `https` too. Files are fetched without cookies or credentials, so they must be public. Changes on the server show up after reloading MatGUI.

#### Managed queries

**Browse and open managed queries:**
Expand Down
26 changes: 10 additions & 16 deletions packages/yasgui/src/Tab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ import EndpointSelect from "./endpointSelect";
import "./tab.scss";
import { getRandomId, default as Yasgui, YasguiRequestConfig } from "./";
import * as OAuth2Utils from "./OAuth2Utils";
import type { ManagedTabMetadata } from "./queryManagement/types";
import type { BackendType, ManagedTabMetadata } from "./queryManagement/types";
import { queryIdFromMetadata, versionRefFromTag, versionTagFromRef } from "./queryManagement/queryRefs";
import { hashQueryText } from "./queryManagement/textHash";
import SaveManagedQueryModal from "./queryManagement/SaveManagedQueryModal";
import { saveManagedQuery } from "./queryManagement/saveManagedQuery";
Expand Down Expand Up @@ -170,14 +171,11 @@ export class Tab extends EventEmitter {
}

private getManagedQueryIdFromMetadata(meta: ManagedTabMetadata): string | undefined {
if (meta.backendType === "git") return (meta.queryRef as any)?.path as string | undefined;
return (meta.queryRef as any)?.managedQueryIri as string | undefined;
return queryIdFromMetadata(meta);
}

private versionRefFromVersionTag(backendType: "git" | "sparql", versionTag: string | undefined) {
if (!versionTag) return undefined;
if (backendType === "git") return { commitSha: versionTag };
return { managedQueryVersionIri: versionTag };
private versionRefFromVersionTag(backendType: BackendType, versionTag: string | undefined) {
return versionRefFromTag(backendType, versionTag);
}

private getQueryTextForSave(): string {
Expand Down Expand Up @@ -254,11 +252,7 @@ export class Tab extends EventEmitter {

const backend = getWorkspaceBackend(workspace, { persistentConfig: this.yasgui.persistentConfig });

const expectedVersionTag = (() => {
if (!meta?.lastSavedVersionRef) return undefined;
if (meta.backendType === "git") return (meta.lastSavedVersionRef as any)?.commitSha;
return (meta.lastSavedVersionRef as any)?.managedQueryVersionIri;
})();
const expectedVersionTag = versionTagFromRef(meta.backendType, meta.lastSavedVersionRef);

try {
await backend.writeQuery(queryId, this.getQueryTextForSave(), { expectedVersionTag });
Expand Down Expand Up @@ -363,9 +357,7 @@ export class Tab extends EventEmitter {
const expectedVersionTag = (() => {
// A version tag only applies when saving over the same query in the same workspace.
if (meta?.workspaceId !== workspace.id) return undefined;
if (!meta?.lastSavedVersionRef) return undefined;
if (meta.backendType === "git") return (meta.lastSavedVersionRef as any)?.commitSha;
return (meta.lastSavedVersionRef as any)?.managedQueryVersionIri;
return versionTagFromRef(meta.backendType, meta.lastSavedVersionRef);
})();

try {
Expand Down Expand Up @@ -675,7 +667,9 @@ export class Tab extends EventEmitter {
const workspace = this.yasgui.persistentConfig.getWorkspace(meta.workspaceId);
const crumbs = [workspace?.label || meta.workspaceId];
const path = (meta.queryRef as any)?.path as string | undefined;
if (meta.backendType === "git" && path) crumbs.push(...path.split("/").slice(0, -1).filter(Boolean));
if ((meta.backendType === "git" || meta.backendType === "http") && path && !/^[a-z][a-z0-9+.-]*:/i.test(path)) {
crumbs.push(...path.split("/").slice(0, -1).filter(Boolean));
}
return crumbs;
}

Expand Down
7 changes: 2 additions & 5 deletions packages/yasgui/src/TabContextMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import Tab from "./Tab";
import { TabListEl } from "./TabElements";
import { cloneDeep } from "lodash-es";
import { getWorkspaceBackend } from "./queryManagement/backends/getWorkspaceBackend";
import type { GitQueryRef, SparqlQueryRef } from "./queryManagement/types";
import { queryIdFromMetadata } from "./queryManagement/queryRefs";
import "./TabContextMenu.scss";
export interface TabContextConfig {
name: string;
Expand Down Expand Up @@ -137,10 +137,7 @@ export default class TabContextMenu {
if (meta) {
const workspaceConfig = this.yasgui.persistentConfig?.getWorkspaces().find((w) => w.id === meta.workspaceId);
if (workspaceConfig) {
const queryId =
meta.backendType === "git"
? (meta.queryRef as GitQueryRef).path
: (meta.queryRef as SparqlQueryRef).managedQueryIri;
const queryId = queryIdFromMetadata(meta);
if (queryId) {
const backend = getWorkspaceBackend(workspaceConfig, {
persistentConfig: this.yasgui.persistentConfig,
Expand Down
75 changes: 73 additions & 2 deletions packages/yasgui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,14 @@ import GraphPlugin from "@matdata/yasgui-graph-plugin";
import TablePlugin from "@matdata/yasgui-table-plugin";
import { moveTabIdToFront, removeTabId } from "./tabNavigationHistory";
import * as dialogs from "./dialogs";
import { setDialogHost, showToast } from "./dialogs";
import { setDialogHost, showAlert, showConfirm, showToast } from "./dialogs";
import HttpWorkspaceBackend from "./queryManagement/backends/HttpWorkspaceBackend";
import { asWorkspaceBackendError } from "./queryManagement/backends/errors";
import {
findWebWorkspace,
getWorkspaceUrlFromLink,
removeWorkspaceParamFromLink,
} from "./queryManagement/workspaceLink";
import "@matdata/yasgui-chart-plugin/dist/yasgui-chart-plugin.min.css";
import "@matdata/yasgui-explain-plugin/dist/yasgui-explain-plugin.min.css";
import "@matdata/yasgui-graph-plugin/dist/yasgui-graph-plugin.min.css";
Expand Down Expand Up @@ -319,6 +326,8 @@ export class Yasgui extends EventEmitter {
this.rootEl.appendChild(this.queryBrowser.getElement());
let executeIdAfterInit: string | undefined;
let optionsFromUrl: PersistedTabJson | undefined;
// Read before the share link below, which clears the hash
const workspaceFromLink = this.config.populateFromUrl ? this.takeWorkspaceUrlFromLink() : undefined;
if (this.config.populateFromUrl) {
optionsFromUrl = shareLink.getConfigFromUrl(Tab.getDefaults(this));
if (optionsFromUrl) {
Expand Down Expand Up @@ -379,7 +388,12 @@ export class Yasgui extends EventEmitter {
} else {
this.sidePanel.restore();
}
if (this.quickStart && this.config.quickStartOnFirstVisit && !QuickStart.hasBeenSeen()) {
if (workspaceFromLink) {
// After the first paint, so the dialog opens over the interface
window.setTimeout(() => {
if (this.rootEl.isConnected) void this.addWebWorkspace(workspaceFromLink);
}, 300);
} else if (this.quickStart && this.config.quickStartOnFirstVisit && !QuickStart.hasBeenSeen()) {
// After the first paint, so the tour opens over the interface it explains
const quickStart = this.quickStart;
QuickStart.markSeen();
Expand All @@ -388,6 +402,63 @@ export class Yasgui extends EventEmitter {
}, 300);
}
}
/** The `?workspace=` URL of the page, removed from the address bar so a reload does not ask again */
private takeWorkspaceUrlFromLink(): string | undefined {
if (typeof window === "undefined" || !window.location) return undefined;
const manifestUrl = getWorkspaceUrlFromLink(window.location.href);
if (!manifestUrl) return undefined;
try {
window.history.replaceState(window.history.state, "", removeWorkspaceParamFromLink(window.location.href));
} catch {
// The parameter stays in the address bar; the workspace is only added once anyway
}
return manifestUrl;
}

/**
* Add a read-only web workspace from the URL of its workspace.json, after the user confirms, and show it in
* the query browser. A workspace that is already configured is shown without asking.
* Used for links with `?workspace=<url>`; embedding pages can call it too.
*/
public async addWebWorkspace(manifestUrl: string): Promise<string | undefined> {
const existing = findWebWorkspace(this.persistentConfig.getWorkspaces(), manifestUrl);
if (existing) {
this.queryBrowser.showWorkspace(existing.id);
return existing.id;
}

let info: { label?: string; description?: string; queryCount: number };
try {
info = await new HttpWorkspaceBackend({ id: "preview", label: "", type: "http", manifestUrl }).getManifestInfo();
} catch (e) {
await showAlert(`The workspace from this link could not be loaded. ${asWorkspaceBackendError(e).message}`, {
host: this.rootEl,
title: "Workspace not added",
});
return undefined;
}

const label = info.label || new URL(manifestUrl).host;
const queries = `${info.queryCount} ${info.queryCount === 1 ? "query" : "queries"}`;
const ok = await showConfirm(
`Add the read-only workspace "${label}" (${queries}) published at ${manifestUrl}? You can browse and open its queries. A query only runs when you press Run.`,
{ host: this.rootEl, title: "Add workspace", confirmLabel: "Add workspace", cancelLabel: "Not now" },
);
if (!ok) return undefined;

const id = `ws_web_${Date.now().toString(36)}_${Math.random().toString(16).slice(2, 8)}`;
this.persistentConfig.addOrUpdateWorkspace({
id,
type: "http",
label,
description: info.description,
manifestUrl,
readOnly: true,
});
this.queryBrowser.showWorkspace(id);
return id;
}

/**
* Run the query of a tab opened from a share link, unless that would be unsafe without the user reviewing it
*/
Expand Down
Loading
Loading