Yet another Solid Hello World

Thank you for the encouragement! All valid points and ideas.
Before we overcomplicate, I would just prototype smth up real quick (even like a typeform) to discover fast what is missing and what problems could appear before we commit to a ‘production ready’ solution. This could be useful to also dicover the schema that needs to describe a Hello World example.
I’ll come up with something that can be feedbacked and improved.
(Secretly, not so secretly, I want a solution like it was mentioned:

similar to the new Glossary from Jeff https://jeff-zucker.solidcommunity.net/sp/, which I would not know how to code up just yet but sure will soon enough :slight_smile: ).

1 Like

Yes, I had planned to do an RDF of Solid apps but the team decided (rightly I think) that the apps listing should live here in the forum rather than on solidproject.org and that it would be mostly user maintained. I had ideas for a categorization scheme and ontology for the apps but dropped it. If it’s of interest, I could dig it up. Timea, you are spot on that this is related to the Glossary and my work on the sp demo.

1 Like

Sure, if anyone wants to make an additional “simple example” using solid-client or something else, go ahead. But I’m not sure where I’d put it in the existing repository without over-complicating the structure.

I’ve added a couple of FAQs and also pointed to other examples, let me know if there is any other doubts I’m not answering or further examples I should point to.

Personally, I’ve already made a more advanced Hello World using Soukai called Ramen, and I haven’t really used any of the other libraries so I prefer someone else to do it.

1 Like

Yes anyone be welcome to add this: you might just duplicate the /solid folder and replace the lower-level parts with library calls. I think two seperate Solid examples can co-exist together, just needs to be linked and documented.

I realize I hijack a bit the topic here. I just wanted to share this preliminary excel (sorry): Solid Hello World - Google Sheets where I collected the basics about the mentioned basic Solid examples.
Maybe I should create a dedicated forum post about just this.
All the examples are not complete. Maybe the creators can help: @hochstenbach, @NoelDeMartin, @rosano, @Virginia .
Feel free to add your questions, point of views (column) and we can see how it develops. Then I attempt to do the data model and the end application.
P.s. With this view, it is fast clear where we have gaps. And maybe we can also collect what beginners would like to see.

2 Likes

@NoelDeMartin @rosano
Using Noel’s code I made an other example of solid Hello World using the solid-file-client high level library.

The project is hosted at GitHub - bourgeoa/hello: A simple Hello World for 0data protocols.
App working link 0data Hello World.

If you like it I can push it to a duplicate folder.

3 Likes

@Timea I think this is a way to ‘synthesize’ more than ‘hijack’ :wink: Looks great so far. Once the structure has been solidified (pardon the pun) and gaps are closed, I would move to something like GitHub so that we have more control and transparency over the data and edits.

2 Likes

@bourgeoa Wow, that’s great! I think you meant the github.io domain https://bourgeoa.github.io/hello/solid/. I tried it and it seems to work seamlessly with data created by Noel’s version.

We should merge this but it seems like your current changes would overwrite Noel’s code, so I would suggest:

  1. Make a new fork from 0data/hello to bourgeoa/hello2
  2. Duplicate /solid to /solid2 and replace all the files with the ones in bourgeoa/hello
  3. Submit a pull request to the 0data/hello

We can clean it up more later.

Thanks @bourgeoa! I have a couple of comments, if you open a PR in the 0data repo as @rosano suggested we can discuss it there.

folder and app name : have you any suggestion solid-file-client ?

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