Yet another Solid Hello World

Great idea! Look forward to the results and more Hello-worlds in any language.

Here is my started demo about this: Solid Hello Worlds
I went down the path of creating a RDF to describe these Examples → which means for now it is just a table and not at all a click and filter answers.
I am also using some brand new libs and because there are some bugs some table columns are not filled yet.
Source code: GitHub - theRealImy/SolidHelloWorlds

3 Likes

I love how you use an ontology to organize the examples; I never thought about trying to connect different examples through stacks and use cases. Interesting ideas :+1:t4: will try to add some more info about the 0data one.

Thank you!
I am working to extend my app so that people can login (with Solid) and edit the data. Until then I closed the Excel shared before. However, if you (or anyone) want me to make urgent changes you can chat with me on Gitter (find me on solidos channel most of the time).

@rosano @NoelDeMartin thanks for writing this. I personally learn a lot more from reading code than from reading documentation and this is by far the simplest, but most robust approach i’ve come across.

I’m about to start diving deeper, but wanted to share a tweak I made while learning. When traversing the code for the Solid hello world example, it was difficult for me to follow exactly where different pieces were coming from. As a result, I ended up putting everything into a single file and loaded the dependencies as ESM.

This is a single copy pastable file that is identical to the Solid Hello World, the only modifications being to make it work as a the single file.

<!DOCTYPE html>
<html lang="en">

  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width">
    <title>Solid Hello World (solid-file-client)</title>
    <link rel="stylesheet" href="https://cdn.simplecss.org/simple.min.css">
  </head>

  <body>
    <h1>Solid Hello World<br><small>(solid-file-client)</small></h1>

    <div id="loading">
      <p>Loading...</p>
    </div>

    <div id="auth-guest" hidden>
      <p>Hi there!</p>
      <p>
      This page is a showcase of a simple <a href="https://solidproject.org/" target="_blank">Solid application</a>
      built using the <a href="https://github.com/jeff-zucker/solid-file-client" target="_blank">solid-file-client</a> library. You can look at the source code and learn how to use it in
      <a href="https://github.com/0dataapp/hello/tree/main/solid/solid-file-client" target="_blank">the repository</a>.
      </p>

      <p>
      This page is a derivative work of <a href="https://hello.0data.app/">0 data hello world</a>
      </p>

      <button id="login-button" type="button" onclick="login()">Log in with Solid</button>

      <p>
      <small>If you don't have one, you can <a href="https://solidproject.org/users/get-a-pod">get a Solid Pod</a>.</small>
      </p>
    </div>

    <div id="auth-user" hidden>
      <p>Hello, <span id="username"></span>!</p>
      <button id="logout-button" type="button" onclick="logout()">Log out</button>

      <h2>Your tasks</h2>
      <ul id="tasks"></ul>
      <button type="button" onclick="createTask()">Create new task</button>
    </div>

    <script type="module">
      import solidClientAuthentication from 'https://esm.sh/@inrupt/solid-client-authn-browser@1.11.2?bundle'
      import SolidFileClient from 'https://esm.sh/solid-file-client@1.2.5?bundle'

      // You can find the basic Solid concepts explained in the Glossary.md file, inline comments talk about
      // the specifics of how this application is implemented.

      let user, tasksContainerUrl;
      const solidFileClient = new SolidFileClient(solidClientAuthentication);

      solidFileClient.rdf.setPrefix('schemaorg', 'https://schema.org/');

      async function restoreSession() {
        // This function uses Inrupt's authentication library to restore a previous session. If you were
        // already logged into the application last time that you used it, this will trigger a redirect that
        // takes you back to the application. This usually happens without user interaction, but if you hadn't
        // logged in for a while, your identity provider may ask for your credentials again.
        //
        // After a successful login, this will also read the profile from your POD.
        //
        // @see https://docs.inrupt.com/developer-tools/javascript/client-libraries/tutorial/authenticate-browser/

        try {
          await solidClientAuthentication.handleIncomingRedirect({ restorePreviousSession: true });

          const session = solidClientAuthentication.getDefaultSession();

          if (!session.info.isLoggedIn)
            return false;

          user = await fetchUserProfile(session.info.webId);

          return user;
        } catch (error) {
          alert(error.message);

          return false;
        }
      }

      function getLoginUrl() {
        // Asking for a login url in Solid is kind of tricky. In a real application, you should be
        // asking for a user's webId, and reading the user's profile you would be able to obtain
        // the url of their identity provider. However, most users may not know what their webId is,
        // and they introduce the url of their issue provider directly. In order to simplify this
        // example, we just use the base domain of the url they introduced, and this should work
        // most of the time.
        const url = prompt('Introduce your Solid login url');

        if (!url)
            return null;

        const loginUrl = new URL(url);
        loginUrl.hash = '';
        loginUrl.pathname = '';

        return loginUrl.href;
      }

      function performLogin(loginUrl) {
        solidClientAuthentication.login({
          oidcIssuer: loginUrl,
          redirectUrl: window.location.href,
          clientName: 'Hello World',
        });
      }

      async function performLogout() {
        await solidClientAuthentication.logout();
      }

      async function performTaskCreation(description) {
        // Data discovery mechanisms are still being defined in Solid, but so far it is clear that
        // applications should not hard-code the url of their containers like we are doing in this
        // example.
        //
        // In a real application, you should use one of these two alternatives:
        //
        // - The Type index. This is the one that most applications are using in practice today:
        //   https://github.com/solid/solid/blob/main/proposals/data-discovery.md#type-index-registry
        //
        // - SAI, or Solid App Interoperability. This one is still being defined:
        //   https://solid.github.io/data-interoperability-panel/specification/

        if (!tasksContainerUrl)
          tasksContainerUrl = await createSolidContainer(`${user.storageUrl}tasks/`);

        const documentUrl = await createSolidDocument(tasksContainerUrl, `
          @prefix schema: <https://schema.org/> .

          <#it>
            a schema:Action ;
            schema:actionStatus schema:PotentialActionStatus ;
            schema:description "${escapeText(description)}" .
        `);
        const taskUrl = `${documentUrl}#it`;

        return { url: taskUrl, description };
      }

      async function performTaskUpdate(taskUrl, done) {
        const documentUrl = getSolidDocumentUrl(taskUrl);

        await updateSolidDocument(documentUrl, `
          DELETE DATA {
            <#it>
              <https://schema.org/actionStatus>
              <https://schema.org/${done ? 'PotentialActionStatus' : 'CompletedActionStatus'}> .
          } ;
          INSERT DATA {
            <#it>
              <https://schema.org/actionStatus>
              <https://schema.org/${done ? 'CompletedActionStatus' : 'PotentialActionStatus'}> .
          }
        `);
      }

      async function performTaskDeletion(taskUrl) {
        const documentUrl = getSolidDocumentUrl(taskUrl);

        await deleteSolidDocument(documentUrl);
      }

      async function loadTasks() {
        // In a real application, you shouldn't hard-code the path to the container like we're doing here.
        // Read more about this in the comments on the performTaskCreation function.

        const containerUrl = `${user.storageUrl}tasks/`;
        const containmentQuads = await readSolidDocument(containerUrl, null, { ldp: 'contains' });

        if (!containmentQuads)
          return [];

        tasksContainerUrl = containerUrl;

        const tasks = [];
        for (const containmentQuad of containmentQuads) {
          const [typeQuad] = await readSolidDocument(containmentQuad.object.value, null, { rdf: 'type' }, { schemaorg: 'Action' });

          if (!typeQuad) {
            // Not a Task, we can ignore this document.

            continue;
          }

          const taskUrl = typeQuad.subject.value;
          const [descriptionQuad] = await readSolidDocument(containmentQuad.object.value, `<${taskUrl}>`, { schemaorg: 'description' });
          const [statusQuad] = await readSolidDocument(containmentQuad.object.value, `<${taskUrl}>`, { schemaorg: 'actionStatus' });

          tasks.push({
            url: taskUrl,
            description: descriptionQuad?.object.value || '-',
            done: statusQuad?.object.value === 'https://schema.org/CompletedActionStatus',
          });
        }

        return tasks;
      }

      async function readSolidDocument(url, source, predicate, object, graph) {
        try {
            // solidFileClient.rdf.query returns an array of statements with matching terms.
            // (load and cache url content)
          return await solidFileClient.rdf.query(url, source, predicate, object, graph);
        } catch (error) {
          return null;
        }
      }

      async function createSolidDocument(url, contents) {
        const response = await solidFileClient.post(url, {
          headers: { 'Content-Type': 'text/turtle' },
          body: contents,
        });

        if (!isSuccessfulStatusCode(response.status))
          throw new Error(`Failed creating document at ${url}, returned status ${response.status}`);

        const location = response.headers.get('Location');

        return new URL(location, url).href;
      }

      async function updateSolidDocument(url, update) {
        const response = await solidFileClient.patchFile(url, update, 'application/sparql-update');

        if (!isSuccessfulStatusCode(response.status))
          throw new Error(`Failed updating document at ${url}, returned status ${response.status}`);
      }

      async function deleteSolidDocument(url) {
        const response = await solidFileClient.deleteFile(url);

        if (!isSuccessfulStatusCode(response.status))
          throw new Error(`Failed deleting document at ${url}, returned status ${response.status}`);
      }

      async function createSolidContainer(url) {
        const response = await solidFileClient.createFolder(url);

        if (!isSuccessfulStatusCode(response.status))
          throw new Error(`Failed creating container at ${url}, returned status ${response.status}`);

        return url;
      }

      function isSuccessfulStatusCode(statusCode) {
        return Math.floor(statusCode / 100) === 2;
      }

      function getSolidDocumentUrl(resourceUrl) {
        const url = new URL(resourceUrl);

        url.hash = '';

        return url.href;
      }

      async function fetchUserProfile(webId) {
        const [nameQuad] = await readSolidDocument(webId, null, { foaf: 'name' });
        const [storageQuad] = await readSolidDocument(webId, null, { space: 'storage' });

        return {
          url: webId,
          name: nameQuad?.object.value || 'Anonymous',

          // WebIds may declare more than one storage url, so in a real application you should
          // ask which one to use if that happens. In this app, in order to keep it simple, we'll
          // just use the first one. If none is declared in the profile, we'll search for it.
          storageUrl: storageQuad?.object.value || await findUserStorage(webId),
        };
      }

      // See https://solidproject.org/TR/protocol#storage.
      async function findUserStorage(url) {
        url = url.replace(/#.*$/, '');
        url = url.endsWith('/') ? url + '../' : url + '/../';
        url = new URL(url);

        const response = await solidFileClient.head(url.href);

        if (response.headers.get('Link')?.includes('<http://www.w3.org/ns/pim/space#Storage>; rel="type"'))
            return url.href;

        // Fallback for providers that don't advertise storage properly.
        if (url.pathname === '/')
            return url.href;

        return findUserStorage(url.href);
      }

      function escapeText(text) {
        return text.replace(/[\\"']/g, '\\$&').replace(/\u0000/g, '\\0');
      }

      // ------------------------------------------------------------------

      async function main() {
        const user = await restoreSession();

        document.getElementById('loading').setAttribute('hidden', '');

        if (!user) {
          document.getElementById('auth-guest').removeAttribute('hidden');

          return;
        }

        document.getElementById('username').innerHTML = `<a href="${user.url}" target="_blank">${user.name}</a>`;
        document.getElementById('auth-user').removeAttribute('hidden');

        const tasks = await loadTasks();

        for (const task of tasks) {
          appendTaskItem(task);
        }
      }

      window.login = ()=> {
        const loginUrl = getLoginUrl();

        if (!loginUrl)
          return;

        performLogin(loginUrl);
      }

      window.logout = async () => {
        document.getElementById('logout-button').setAttribute('disabled', '');

        await performLogout();

        document.getElementById('auth-guest').removeAttribute('hidden');
        document.getElementById('auth-user').setAttribute('hidden', '');
        document.getElementById('logout-button').removeAttribute('disabled');
      }

      window.createTask = async () => {
        const description = prompt('Task description');

        if (!description)
            return;

        const task = await performTaskCreation(description);

        appendTaskItem(task);
      }

      window.updateTask = async (taskUrl, button) => {
        const done = button.innerText === 'Complete';
        button.setAttribute('disabled', '');

        await performTaskUpdate(taskUrl, done);

        button.removeAttribute('disabled');
        button.innerText = done ? 'Undo' : 'Complete';
      }

      window.deleteTask = async (taskUrl, taskElement, button) => {
        button.setAttribute('disabled', '');

        await performTaskDeletion(taskUrl);

        taskElement.remove();
      }

      function appendTaskItem(task) {
        const taskItem = document.createElement('li');

        taskItem.innerHTML = `
          <button
            type="button"
            onclick="deleteTask('${task.url}', this.parentElement, this)"
            >
            Delete
          </button>
          <button
            type="button"
            onclick="updateTask('${task.url}', this)"
            style="width:100px"
            >
            ${task.done ? 'Undo' : 'Complete'}
          </button>
          <span>${task.description}</span>
        `;

        document.getElementById('tasks').appendChild(taskItem);
      }

      // ------------------------------------------------------------------

      main();

      window.onunhandledrejection = (error) => alert(`Error: ${error.reason?.message}`);
    </script>

    <small>Or, go back to <a href="../../">the homepage</a>.</small>
  </body>

</html>
2 Likes

Thanks for sharing @tychi,

One of the reasons why it’s split in files is that we share some parts with other examples, so we wanted to distinguish what’s different for each one.

Other than that, I think it should be possible to easily track where things come from by cloning the repository and removing other files or something, but I guess that also depends on your code editor.

I’m not sure what’s be the best way to share your approach with others, maybe you can make a fork and we can point to it from our repo or something. I don’t think everyone who comes across the repository ends up visiting this topic. But I guess it’s also fine if you just want to leave it here :).

@NoelDeMartin I totally agree this isn’t the best place for it-- and being able to share the code across the other examples makes sense too. I just wanted to drop that lean example since I was about to dissect it to integrate it into my Tag todo example.

The UX is rough right now, but all the functionality from the 0data todo example is ported in and the only thing missing from the TodoMVC example is the ability to persist data in Solid correctly after double-clicking and editing a todo. That’s only blocked on me just not knowing SPARQL or RDF.

Demo link
Demo source

I’m not going to advertise this functionality in tag, but I put all the solid related logic from the 0data example into a tag that I can re-use across components/applications. This is powering the currently unstyled “Log in with Solid” button at the top of the todo list app and all the CRUD operations for the todo app tunnel through this file.

Solid User Tag Source

TBH, I think that above approach could be a proof of concept solution to this post: https://forum.solidproject.org/t/has-any-work-been-done-to-standardise-ux-patterns-for-logging-into-solid-applications/

What do you need to know? It’s not implemented in the Hello World example, but you could do something very similar to the toggle but changing the description instead. For example, if you want to change a task called “Foo Bar” to say “Lorem Ipsum” you would do:

DELETE DATA {
  <#it>
    <https://schema.org/description>
    "Foo Bar" .
} ;
INSERT DATA {
  <#it>
    <https://schema.org/description>
    "Lorem Ipsum" .
}

Thanks, this is exactly what I needed to know :slight_smile:

Just to make sure I understand, I’ll always need to track two forms of state?

  1. What the remote value currently is to be able to properly delete it
  2. What the user is updating to for insertion

Well, maybe someone who knows more about RDF/SPARQL than me will correct me, but as far as I know it’s not possible to do something like DELETE DATA { <#it> schema:description * . } to delete the property without knowing its value. I tried to do it a while ago and it didn’t work.

However, using the value your application knows about may be an issue and that’s not how I implement it in my apps. What I do is, right before updating, I get the latest version of the document and use that value to build the query. There are a couple of drawbacks to this approach. The first one is that in the time between the first request and the second, the document could have changed (because another app changed it, or even your same app in another device or something). And the second one, that you do two network requests instead of one. But so far, this is the best way I’ve found (although to be fair, I haven’t revisited this issue for at least two years :sweat_smile: if anyone knows a better approach let me know).

I think the ideal solution would be to use the values your app knows about and adding an If-Match to the request. That way, if the document changed since the last time you fetched it, you can update it before doing any modifications. But I don’t think all POD providers currently support it, so it’s not an option.

In any case, these things are very unlikely to happen, and if you go ahead using the value your app already knows about it’s very likely that it works fine. So if you don’t want to overcomplicate the solution, that would be fine as well :).

1 Like

I’m over a year late on this (how’s that for latency?) as I got swept up in different things, but I finally uploaded the video from this swap where Noel explains the solid part of the Hello app (starting at one and a half minutes in)

2 Likes

A good tip from @NoelDeMartin to use a local solid server when during development so you don’t mess up your other pods :sweat_smile:

1 Like