Skip to content
Open
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
2 changes: 1 addition & 1 deletion .atomic-server-ref
Original file line number Diff line number Diff line change
@@ -1 +1 @@
30994948b5adadd765ecfcdccdf0aad14ef5be1a
7e98fa8577c55f3aa8f252689f97fbebecb608a7
1 change: 1 addition & 0 deletions apps/google-keep-view/0.3.0/ui.js

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions apps/google-keep/0.2.0/ui.js

Large diffs are not rendered by default.

6 changes: 4 additions & 2 deletions integrations/calendar/e2e/calendar.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -970,7 +970,7 @@ async function classesOf(
async function connectThroughHost(page: Page, app: FrameLocator) {
await app.getByRole('button', { name: 'Connect Google Calendar' }).click();
// Drawn by the host page, outside the frame: only a click here navigates.
const consent = page.getByRole('group', { name: 'Connect an account' });
const consent = page.getByRole('dialog', { name: /^Connect / });
await expect(consent).toContainText('Google Calendar');
await consent.getByRole('button', { name: 'Connect', exact: true }).click();
await expect(
Expand Down Expand Up @@ -1197,7 +1197,9 @@ async function tableOf(page: Page): Promise<string> {
* Returns the card, for its "Installed <version>" line.
*/
async function installFromCatalog(page: Page) {
await page.goto(new URL('/app/integrations', page.url()).href);
await page.goto(
new URL('/app/integrations?preview=plugins', page.url()).href,
);
const experimental = page.getByRole('checkbox', {
name: 'Show experimental plugins',
});
Expand Down
16 changes: 10 additions & 6 deletions integrations/catalog.json
Original file line number Diff line number Diff line change
Expand Up @@ -197,11 +197,13 @@
"https://atomicdata.dev/integrations/properties/events": "Install from Drive apps and connect Google Keep through the host's consent bar. The app syncs when it is opened, on Sync now and every minute while it stays open; two open browsers take turns. Nothing runs while no one has it open.",
"https://atomicdata.dev/integrations/properties/limitation": "Google offers the Keep API to Google Workspace accounts only; a personal Google account cannot connect (Google refuses the scope). The API has no update: an edited note is sent as a new note and the old one deleted, losing its colour, labels, pins and reminders, so a note that is shared or has an attachment is never replaced and its edit is held back instead. Images and drawings are not imported. One Google account per table. Declared, and tested against a synthetic fixture only: not verified against a live Workspace account.",
"https://atomicdata.dev/integrations/properties/keywords": "google keep notes lists checklists two-way sync drive app workspace",
"https://atomicdata.dev/integrations/properties/version": "0.1.0",
"https://atomicdata.dev/integrations/properties/version": "0.2.0",
"https://atomicdata.dev/integrations/properties/app-row-name": "Note",
"https://atomicdata.dev/integrations/properties/app-row-name-plural": "Notes",
"https://atomicdata.dev/integrations/properties/app-module": "https://ontola.github.io/atomic-plugins/apps/google-keep/0.1.0/ui.js",
"https://atomicdata.dev/integrations/properties/app-module-integrity": "sha384-3OzvVJHaPQWxKk7FF6VqMHg4QnFxFwObW40SiIqcFssjUhJ0U2KzJqEPMVLj5G8+"
"https://atomicdata.dev/integrations/properties/app-module": "https://ontola.github.io/atomic-plugins/apps/google-keep/0.2.0/ui.js",
"https://atomicdata.dev/integrations/properties/app-module-integrity": "sha384-Kmupvx6LuhBviwLEPSJIbkKYcvNpuGYt7vv3m9r0s5utVYxGpo6GE/A3s948Jelg",
"https://atomicdata.dev/integrations/properties/app-data-kind": "folder",
"https://atomicdata.dev/integrations/properties/app-installation-hidden": true
},
{
"https://atomicdata.dev/properties/localId": "google-tasks",
Expand Down Expand Up @@ -554,8 +556,10 @@
"https://atomicdata.dev/integrations/properties/events": "Install Google Keep View separately from the Google Keep connection app and open it once after importing notes. It discovers compatible tables and makes itself available under Add view on them. Reopen or Refresh after another import. Table subscriptions refresh rows; no provider calls or background sync.",
"https://atomicdata.dev/integrations/properties/limitation": "Imported tables need a typed text column (Array item, Text or Name) and boolean Checked or Done column. The standalone and notes-table views are read-only; ticking requires adding the view to the checklist table and allowing row edits. Does not add, delete, rename or reorder rows. Sending edits to Google remains controlled by the importer or sync app. Synthetic tests only; no live Google evidence.",
"https://atomicdata.dev/integrations/properties/keywords": "google keep view checklist notes cards unchecked completed",
"https://atomicdata.dev/integrations/properties/version": "0.2.0",
"https://atomicdata.dev/integrations/properties/app-module": "https://ontola.github.io/atomic-plugins/apps/google-keep-view/0.2.0/ui.js",
"https://atomicdata.dev/integrations/properties/app-module-integrity": "sha384-iMQ7lCjJtodxpsCI1or++HQ6QPs72ooMDWU1cDTBvP7hQjz8M4GYmuLOqYTfHSey"
"https://atomicdata.dev/integrations/properties/version": "0.3.0",
"https://atomicdata.dev/integrations/properties/app-module": "https://ontola.github.io/atomic-plugins/apps/google-keep-view/0.3.0/ui.js",
"https://atomicdata.dev/integrations/properties/app-module-integrity": "sha384-0UsISUlU+hol6LNfdWHIoSJOb0XrNEWrG7Vb0XTPtyeb17dckXXl1B1xA7GtSbV2",
"https://atomicdata.dev/integrations/properties/app-data-kind": "none",
"https://atomicdata.dev/integrations/properties/app-installation-hidden": true
}
]
25 changes: 17 additions & 8 deletions integrations/google-keep-view/README.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
# Google Keep View

A Keep-style table view of imported checklists and the Google Keep sync app's
notes. Install the separate **Google Keep View** entry from Integrations →
A Keep-style view of account folders, individual notes and imported checklist
tables. Install the separate **Google Keep View** entry from Integrations →
Drive apps (under **Show experimental plugins**), then open it once. Installing
**Google Keep** alone installs the connection and sync app, not this view.
If the experimental checkbox is hidden, open `/app/integrations?preview=plugins`
first. Existing installations can update to 0.2.0 from the same catalog entry.
first. Existing installations update from the same catalog entry. On a host
with resource-view support, the view installation stays in Integrations rather
than ordinary drive contents, and a new view installation needs no Items table.

The view discovers imported checklist tables with a text column (**Array item**,
**Text** or **Name**) and a boolean **Checked** or **Done** column, as well as
Expand All @@ -15,9 +17,15 @@ tables are available, choose one in the view. No Google connection or another
copy of the notes is needed. Open the view again, or Refresh it, after importing
another list so it becomes available on that table too.

The **Google Keep <account>** folder opens as cards. Opening a card navigates
to the existing note resource, where only that checklist or text note appears.
The renderer resolves the sync installation through the note class and its
ontology, so moving the notes container into the drive does not lose its hidden
list-item data. Resource-page context grants no checkbox editing access.

Notes appear as a responsive grid of cards, with a list layout toggle and
search across titles, note text and all checklist items. Unchecked items
appear first in each note, followed by a collapsible completed section with
appear first in each note, followed by an initially collapsed completed section with the actual count and
struck-through text. Positions within each section and child indentation are
preserved; sorting the display never writes new positions. Text, trashed or
missing notes, and sync holds remain visible. The host's light/dark theme is
Expand All @@ -27,16 +35,17 @@ does not invent them.
To tick items, open the imported checklist or **Google Keep list items** table,
add **Google Keep View** as a view, and allow it to edit that table's rows. The
view rechecks the host's grant before each save and writes only the actual
Checked or Done column. Notes tables
and the standalone view remain read-only. It never changes the sync app's
Checked or Done column. Account folders, individual note pages, notes tables and the standalone view
remain read-only. It never changes the sync app's
settings, calls Google, creates or deletes rows, or starts syncing. Sending
edits to Google remains the sync app's job, under its existing settings and
replacement-note limits.

Declared evidence: unit tests for both data models, discovery and class
registration, table isolation, unchecked-first ordering, search, grants and DOM
interactions; a synthetic Chromium test for checkbox edits, search, layout,
mobile overflow and dark mode. The host E2E covers catalog installation and
interactions, resource-target isolation, folder reuse and a 485-completed-item
checklist; synthetic Chromium tests for checkbox edits, search, layout,
mobile overflow, dark mode, folder-card navigation and single-note pages. The host E2E covers catalog installation and
adding the view to an imported checklist through Add view, including a granted
edit to its Checked column. No Google live evidence is claimed.

Expand Down
6 changes: 3 additions & 3 deletions integrations/google-keep-view/app/build.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ describe('Keep view bundle', async () => {
expect(Object.keys(module)).toEqual(['view']);
expect(text).toContain('.keep-card{');
expect(text).not.toMatch(/^\s*import\s|\bimport\(|require\(/m);
// Measured 13,669 bytes minified on 2026-10-30 with imported checklist
// recognition; approximately 10% headroom.
expect(bytes).toBeLessThan(15_100);
// Measured 16,232 bytes minified on 2026-10-30 with account-folder
// cards and individual note pages; approximately 10% headroom.
expect(bytes).toBeLessThan(17_700);
});
it('has no provider calls, credentials, persistent browser state or test fixture', () => {
expect(text).not.toMatch(
Expand Down
153 changes: 152 additions & 1 deletion integrations/google-keep-view/app/data.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ import {
P,
partition,
read,
readResource,
register,
setDone,
sources,
} from './data.js';
import { fixture, importedFixture } from './fixture.js';
import { fixture, folderFixture, importedFixture } from './fixture.js';

const source = {
table: 'did:test:notes',
Expand Down Expand Up @@ -201,3 +203,152 @@ describe('imported checklist tables', () => {
expect(await sources(fake.store)).toHaveLength(1);
});
});

describe('Keep resource pages', () => {
it('reads a migrated account folder without moving or copying any note', async () => {
const fake = folderFixture();
const before = Object.keys(fake.resources);
const snapshot = await readResource(fake.store, 'did:test:notes');
expect(snapshot.title).toBe('Google Keep fictional@example.test');
expect(snapshot.single).toBe(false);
expect(snapshot.notes.map(note => note.subject)).toEqual([
'did:test:groceries',
'did:test:thought',
]);
expect(snapshot.notes[0].items).toHaveLength(4);
expect(Object.keys(fake.resources)).toEqual(before);
expect(fake.writes).toEqual(['did:test:view-app']);
expect(fake.resources['did:test:groceries'][P.parent]).toBe(
'did:test:notes',
);
expect(
(
await read(fake.store, {
table: 'did:test:items',
rowClass: 'did:test:item-class',
name: 'Items',
})
).notes,
).toHaveLength(2);
});

it('opens only the requested note and keeps its nested and empty items', async () => {
const fake = folderFixture();
fake.resources['did:test:item-2'][P.name] = '';
const snapshot = await readResource(fake.store, 'did:test:groceries');
expect(snapshot.single).toBe(true);
expect(snapshot.container).toBe('did:test:notes');
expect(snapshot.notes).toHaveLength(1);
expect(snapshot.notes[0].subject).toBe('did:test:groceries');
expect(snapshot.notes[0].items[2]).toMatchObject({
text: '',
under: 'did:test:item-1',
});
const text = await readResource(fake.store, 'did:test:thought');
expect(text.notes).toHaveLength(1);
expect(text.notes[0].body).toContain('Take the scenic route.');
});

it('reads a single note without enumerating sibling notes or the account items table', async () => {
const fake = folderFixture();
fake.resources['did:test:foreign-item'] = {
...fake.resources['did:test:item-0'],
[P.parent]: 'did:test:other-items',
};
fake.resources['did:test:wrong-class'] = {
...fake.resources['did:test:item-0'],
[P.isA]: ['did:test:note-class'],
};
fake.resources['did:test:sibling-item'] = {
...fake.resources['did:test:item-0'],
['did:test:google-keep-item-note']: 'did:test:thought',
};
const queries: { property: string; value: string }[] = [];
const reads: string[] = [];
const query = fake.store.query;
const getResource = fake.store.getResource;

fake.store.query = async args => {
queries.push(args);

return query(args);
};

fake.store.getResource = async subject => {
reads.push(subject);

return getResource(subject);
};

const snapshot = await readResource(fake.store, 'did:test:groceries');
expect(snapshot.notes).toHaveLength(1);
expect(snapshot.notes[0].items).toHaveLength(4);
expect(snapshot.notes[0].items.map(item => item.subject)).not.toContain(
'did:test:foreign-item',
);
expect(snapshot.notes[0].items.map(item => item.subject)).not.toContain(
'did:test:wrong-class',
);
expect(queries).toContainEqual({
property: 'did:test:google-keep-item-note',
value: 'did:test:groceries',
});
expect(queries).not.toContainEqual({
property: P.parent,
value: 'did:test:notes',
});
expect(queries).not.toContainEqual({
property: P.parent,
value: 'did:test:items',
});
expect(reads).not.toContain('did:test:thought');
expect(reads).not.toContain('did:test:sibling-item');
});

it('rejects an unrelated resource instead of falling back to another account', async () => {
const fake = folderFixture();
await expect(readResource(fake.store, 'did:test:sync-app')).rejects.toThrow(
'not a Google Keep note or account folder',
);
fake.resources['did:test:groceries'][P.parent] = 'did:test:items';
await expect(
readResource(fake.store, 'did:test:groceries'),
).rejects.toThrow('not a Google Keep note or account folder');
});

it('does not turn a resource target into a row editing grant', async () => {
const fake = folderFixture();
const snapshot = await readResource(fake.store, 'did:test:groceries');
fake.grant(true);
fake.store.getData = async () => ({
resource: 'did:test:groceries',
table: 'did:test:items',
});
await expect(
setDone(fake.store, snapshot, 'did:test:item-1', true),
).rejects.toThrow('Allow editing');
expect(fake.resources['did:test:item-1'][fake.done]).toBe(false);
});
});

it('advertises only note classes for resource pages and keeps table capabilities separate', async () => {
const fake = fixture();
await register(fake.store, ['did:test:note-class', 'did:test:item-class']);
expect(fake.resources['did:test:view-app']['did:test:renders']).toEqual([
'did:test:note-class',
'did:test:item-class',
]);
expect(
fake.resources['did:test:view-app']['did:test:resource-renders'],
).toEqual(['did:test:note-class']);
await register(fake.store, ['did:test:note-class', 'did:test:item-class']);
expect(fake.writes).toEqual(['did:test:view-app']);
const imported = importedFixture();
await register(imported.store, [imported.rowClass]);
expect(imported.resources['did:test:view-app']['did:test:renders']).toEqual([
imported.rowClass,
]);
expect(
imported.resources['did:test:view-app']['did:test:resource-renders'],
).toBeUndefined();
});
Loading
Loading