Skip to content

Commit

Permalink
Merge 2a73e7b into e46f53c
Browse files Browse the repository at this point in the history
  • Loading branch information
angelo-v authored Oct 29, 2021
2 parents e46f53c + 2a73e7b commit fcbb26d
Show file tree
Hide file tree
Showing 15 changed files with 830 additions and 177 deletions.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
{
"name": "webclip-chrome-ext",
"version": "0.4.1",
"version": "0.5.0",
"description": "",
"main": "src/content.tsx",
"scripts": {
"test": "jest",
"test:unit": "jest src --testPathIgnorePatterns=src/integration-tests",
"test:integration": "jest src/integration-tests",
"lint": "eslint ./src",
"start": "NODE_ENV=development webpack --mode development --watch",
"build": "webpack",
Expand Down
209 changes: 189 additions & 20 deletions src/api/SolidApi.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,19 @@ import {
fetch as authenticatedFetch,
login,
} from '@inrupt/solid-client-authn-browser';
import { graph, parse, Store as RdflibStore } from 'rdflib';
import { Parser as SparqlParser, Update } from 'sparqljs';
import { subscribeOption } from '../options/optionsStorageApi';
import { Store } from '../store/Store';
import { SessionInfo, SolidApi } from './SolidApi';
import { Parser as SparqlParser, Update } from 'sparqljs';
import { givenStoreContaining } from '../test/givenStoreContaining';
import { generateUuid } from './generateUuid';
import { now } from './now';
import { Bookmark, SessionInfo, SolidApi } from './SolidApi';

jest.mock('@inrupt/solid-client-authn-browser');
jest.mock('./generateUuid');
jest.mock('./now');
jest.mock('../options/optionsStorageApi');

interface MockStore extends RdflibStore {
and: (base: string, turtle: string) => MockStore;
}

describe('SolidApi', () => {
beforeEach(() => {
jest.resetAllMocks();
Expand Down Expand Up @@ -123,16 +119,6 @@ describe('SolidApi', () => {
});
});

function givenStoreContaining(base: string, turtle: string): MockStore {
const store = graph() as MockStore;
parse(turtle, store, base);
store.and = (base: string, turtle: string) => {
parse(turtle, store, base);
return store;
};
return store;
}

describe('bookmark', () => {
it("stores a bookmark in the user's pod when storage is available", async () => {
mockFetchWithResponse('');
Expand Down Expand Up @@ -179,6 +165,45 @@ describe('SolidApi', () => {
);
});

it('adds the bookmark to the webclip index', async () => {
mockFetchWithResponse('');
givenNowIs(Date.UTC(2021, 2, 12, 9, 10, 11, 12));
givenGeneratedUuidWillBe('some-uuid');

const store = givenStoreContaining(
'https://pod.example/',
`
<#me>
<http://www.w3.org/ns/pim/space#storage> <https://storage.example/> .
`
);

const solidApi = new SolidApi(
{
webId: 'https://pod.example/#me',
isLoggedIn: true,
} as SessionInfo,
new Store(store)
);

await solidApi.bookmark({
type: 'WebPage',
url: 'https://myfavouriteurl.example',
name: 'I love this page',
});

thenSparqlUpdateIsSentToUrl(
'https://storage.example/webclip/index.ttl',
`
INSERT DATA {
<https://storage.example/webclip/2021/03/12/some-uuid#it>
a <http://schema.org/BookmarkAction> ;
<http://schema.org/object> <https://myfavouriteurl.example>
.
}`
);
});

it('throws an exception if no storage is available', async () => {
mockFetchWithResponse('');
(generateUuid as jest.Mock).mockReturnValue('some-uuid');
Expand Down Expand Up @@ -291,6 +316,148 @@ describe('SolidApi', () => {
});
});
});

describe('load bookmark', () => {
describe('when no index is found', () => {
let result: Bookmark;
beforeEach(async () => {
(authenticatedFetch as jest.Mock).mockResolvedValue({
ok: true,
headers: new Headers({
'Content-Type': 'text/plain',
'wac-allow': 'user="read write append control",public=""',
'ms-author-via': 'SPARQL',
}),
status: 404,
statusText: 'Not Found',
text: async () => 'Cannot find requested file',
});

const store = givenStoreContaining(
'https://pod.example/',
`
<#me>
<http://www.w3.org/ns/pim/space#storage> <https://storage.example/> .
`
);

const solidApi = new SolidApi(
{
webId: 'https://pod.example/#me',
isLoggedIn: true,
} as SessionInfo,
new Store(store)
);

result = await solidApi.loadBookmark({
type: 'WebPage',
url: 'https://myfavouriteurl.example',
name: 'I love this page',
});
});

it('it returns null', async () => {
expect(result).toEqual(null);
});

it('has tried to load the index document', () => {
expect(authenticatedFetch).toHaveBeenCalledWith(
'https://storage.example/webclip/index.ttl',
expect.anything()
);
});
});

describe('when page is not found at the index', () => {
let result: Bookmark;
beforeEach(async () => {
mockFetchWithResponse(`
@prefix schema: <http://schema.org/> .
<http://storage.example/webclip/irrelevant#it> a schema:BookmarkAction; schema:object <https://irrelevant.example> .
`);

const store = givenStoreContaining(
'https://pod.example/',
`
<#me>
<http://www.w3.org/ns/pim/space#storage> <https://storage.example/> .
`
);

const solidApi = new SolidApi(
{
webId: 'https://pod.example/#me',
isLoggedIn: true,
} as SessionInfo,
new Store(store)
);

result = await solidApi.loadBookmark({
type: 'WebPage',
url: 'https://myfavouriteurl.example',
name: 'I love this page',
});
});

it('it returns null', async () => {
expect(result).toEqual(null);
});

it('has tried to load the index document', () => {
expect(authenticatedFetch).toHaveBeenCalledWith(
'https://storage.example/webclip/index.ttl',
expect.anything()
);
});
});

describe('when page is found at the index', () => {
let result: Bookmark;
beforeEach(async () => {
mockFetchWithResponse(`
@prefix schema: <http://schema.org/> .
<http://storage.example/webclip/relevant#it> a schema:BookmarkAction; schema:object <https://myfavouriteurl.example> .
`);

const store = givenStoreContaining(
'https://pod.example/',
`
<#me>
<http://www.w3.org/ns/pim/space#storage> <https://storage.example/> .
`
);

const solidApi = new SolidApi(
{
webId: 'https://pod.example/#me',
isLoggedIn: true,
} as SessionInfo,
new Store(store)
);

result = await solidApi.loadBookmark({
type: 'WebPage',
url: 'https://myfavouriteurl.example',
name: 'I love this page',
});
});

it('it returns the existing bookmark', async () => {
expect(result).toEqual({
uri: 'http://storage.example/webclip/relevant#it',
});
});

it('has tried to load the index document', () => {
expect(authenticatedFetch).toHaveBeenCalledWith(
'https://storage.example/webclip/index.ttl',
expect.anything()
);
});
});
});
});

function mockFetchWithResponse(bodyText: string) {
Expand Down Expand Up @@ -327,10 +494,12 @@ function thenSparqlUpdateIsSentToUrl(url: string, query: string) {

const parser = new SparqlParser();

const sparqlUpdateCall = (authenticatedFetch as jest.Mock).mock.calls[1];
const calls = (authenticatedFetch as jest.Mock).mock.calls;
const sparqlUpdateCall = calls.find(
(it) => it[0] === url && it[1].method === 'PATCH'
);

const uri = sparqlUpdateCall[0];
expect(uri).toBe(url);
expect(sparqlUpdateCall).toBeDefined();

const body = sparqlUpdateCall[1].body;
const actualQuery = parser.parse(body) as Update;
Expand Down
68 changes: 56 additions & 12 deletions src/api/SolidApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ export interface Bookmark {
uri: string;
}

function getIndex(storageUrl: string): NamedNode {
return sym(urlJoin(storageUrl, 'webclip', 'index.ttl'));
}

export class SolidApi {
private readonly me: NamedNode;
private readonly sessionInfo: SessionInfo;
Expand Down Expand Up @@ -86,13 +90,9 @@ export class SolidApi {
}

async bookmark(page: PageMetaData): Promise<Bookmark> {
const storageUrl = this.graph.anyValue(this.me, this.ns.space('storage'));

if (!storageUrl) {
throw new Error('No storage available.');
}
const storageUrl = this.getStorageUrl();

const it = sym(
const clip = sym(
urlJoin(
storageUrl,
'webclip',
Expand All @@ -101,26 +101,70 @@ export class SolidApi {
'#it'
)
);

const index = getIndex(storageUrl);

await this.savePageData(clip, page);
await this.updateIndex(index, clip, page);

return { uri: clip.uri };
}

private getStorageUrl() {
const storageUrl = this.graph.anyValue(this.me, this.ns.space('storage'));
if (!storageUrl) {
throw new Error('No storage available.');
}
return storageUrl;
}

private async savePageData(clip: NamedNode, page: PageMetaData) {
const a = this.ns.rdf('type');
const BookmarkAction = this.ns.schema('BookmarkAction');
const document = it.doc();
const pageUrl = sym(page.url);
const document = clip.doc();
const WebPage = this.ns.schema('WebPage');

const about: Statement[] = this.store.createRelations(pageUrl, document);

const insertions = [
st(it, a, BookmarkAction, document),
st(it, this.ns.schema('startTime'), schemaDateTime(now()), document),
st(it, this.ns.schema('object'), pageUrl, document),
st(clip, a, BookmarkAction, document),
st(clip, this.ns.schema('startTime'), schemaDateTime(now()), document),
st(clip, this.ns.schema('object'), pageUrl, document),
st(pageUrl, a, WebPage, document),
st(pageUrl, this.ns.schema('url'), pageUrl, document),
st(pageUrl, this.ns.schema('name'), lit(page.name), document),
...about,
];

await this.updater.update([], insertions);
return { uri: it.uri };
}

private async updateIndex(
index: NamedNode,
it: NamedNode,
page: PageMetaData
) {
const a = this.ns.rdf('type');
const BookmarkAction = this.ns.schema('BookmarkAction');
const pageUrl = sym(page.url);

const indexUpdate = [
st(it, a, BookmarkAction, index),
st(it, this.ns.schema('object'), pageUrl, index),
];

await this.updater.update([], indexUpdate);
}

async loadBookmark(page: PageMetaData): Promise<Bookmark> {
const index = getIndex(this.getStorageUrl());
try {
await this.fetcher.load(index);
} catch (err) {
// no index found, that's ok
}
const bookmarkNode = this.store.getIndexedBookmark(sym(page.url), index);
return bookmarkNode ? { uri: bookmarkNode.value } : null;
}
}

Expand Down
Loading

0 comments on commit fcbb26d

Please sign in to comment.