mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
697fa58b97 | ||
|
|
ba9c0a8971 | ||
|
|
290e01fe18 | ||
|
|
5c2b4da9f0 |
No files matched your search
@@ -50,7 +50,7 @@ import { parent_path_for, parse_shared_path, shared_crumbs_for, shared_uids_from
|
||||
import resolve_shared_item from '../../helpers/resolveSharedItem.js';
|
||||
import { is_owned_by_me } from '../../helpers/pathOwner.js';
|
||||
import parse_item_metadata from '../../helpers/parseItemMetadata.js';
|
||||
import { compareSharers, filterBySharer, fitSharedByWidth, sharedByCellHtml, sharerFilterHtml, summarizeSharers } from './sharedBy.js';
|
||||
import { compareSharers, filterBySharer, fitSharedByWidth, sharedByCellHtml, sharerFilterHtml, sharerOf, summarizeSharers } from './sharedBy.js';
|
||||
import { paintAvatars } from '../../helpers/profilePictures.js';
|
||||
|
||||
const { html_encode, SelectionArea } = window;
|
||||
@@ -2983,7 +2983,7 @@ const TabFiles = {
|
||||
// Rebuilding the chips drops focus and scroll; carry both across.
|
||||
const focused = $bar.find('.sharer-chip:focus').attr('data-sharer');
|
||||
const scrollLeft = bar.scrollLeft;
|
||||
const html = sharerFilterHtml(sharers, this.sharerFilter);
|
||||
const html = sharerFilterHtml(sharers, this.sharerFilter, rows.filter((row) => sharerOf(row)).length);
|
||||
$bar.html(html).prop('hidden', ! html);
|
||||
paintAvatars(bar);
|
||||
bar.scrollLeft = scrollLeft;
|
||||
@@ -6011,8 +6011,10 @@ const TabFiles = {
|
||||
shared_with_me: true,
|
||||
share_mode: share.mode,
|
||||
shared_by: share.issuer,
|
||||
shared_by_all: share.issuers,
|
||||
owner: share.owner,
|
||||
holder_team: share.holderTeam,
|
||||
also_team_share: share.alsoHeldByTeam,
|
||||
}));
|
||||
|
||||
const linked = this.pendingSharedLinks ?? [];
|
||||
|
||||
@@ -1667,6 +1667,34 @@ describe('TabFiles listSharedView', () => {
|
||||
});
|
||||
expect(rows[1].holder_team).toEqual(team);
|
||||
});
|
||||
|
||||
it('merges the grants on one item into a single row', async () => {
|
||||
const team = { uid: 't-1', name: 'Acme', handle: 'acme' };
|
||||
const grant = { entryUid: 'u-1', name: 'a.txt', path: '/bob/u-1/a.txt', isDir: false, owner: 'bob' };
|
||||
window.puter = {
|
||||
fs: {
|
||||
listShared: vi.fn(async () => ({
|
||||
items: [
|
||||
{ ...grant, mode: 'read', issuer: 'bob', holderTeam: team },
|
||||
{ ...grant, mode: 'manage', issuer: 'bob' },
|
||||
{ ...grant, mode: 'read', issuer: 'carol' },
|
||||
],
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
const rows = await TabFiles.listSharedView();
|
||||
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({
|
||||
uid: 'u-1',
|
||||
share_mode: 'manage',
|
||||
shared_by: 'bob',
|
||||
shared_by_all: ['bob', 'carol'],
|
||||
also_team_share: true,
|
||||
});
|
||||
expect(rows[0].holder_team).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TabFiles path field', () => {
|
||||
|
||||
@@ -22,9 +22,10 @@
|
||||
|
||||
import { avatarHue, avatarInitial } from '../../helpers/shareAvatar.js';
|
||||
import { avatarUserAttr } from '../../helpers/profilePictures.js';
|
||||
import { intlLocale } from '../../i18n/intlLocale.js';
|
||||
|
||||
/**
|
||||
* @typedef {{ shared_by?: string | null, owner?: string | null }} SharedRow
|
||||
* @typedef {{ shared_by?: string | null, shared_by_all?: string[], owner?: string | null }} SharedRow
|
||||
* @typedef {{ name: string, count: number }} Sharer
|
||||
*/
|
||||
|
||||
@@ -37,6 +38,19 @@ import { avatarUserAttr } from '../../helpers/profilePictures.js';
|
||||
*/
|
||||
export const sharerOf = (row) => row?.shared_by || row?.owner || '';
|
||||
|
||||
/**
|
||||
* Everyone who shared a row with the user, `sharerOf` first. More than one
|
||||
* when the item was granted by several people.
|
||||
*
|
||||
* @param {SharedRow} [row]
|
||||
* @returns {string[]} usernames
|
||||
*/
|
||||
export const sharersOf = (row) => {
|
||||
const first = sharerOf(row);
|
||||
if ( ! row?.shared_by_all?.length ) return first ? [first] : [];
|
||||
return [...new Set([first, ...row.shared_by_all])].filter(Boolean);
|
||||
};
|
||||
|
||||
/**
|
||||
* Everyone who shared something in `rows`, with how many items each, most
|
||||
* items first and ties by name.
|
||||
@@ -47,8 +61,7 @@ export const sharerOf = (row) => row?.shared_by || row?.owner || '';
|
||||
export const summarizeSharers = (rows) => {
|
||||
const counts = new Map();
|
||||
for ( const row of rows ) {
|
||||
const name = sharerOf(row);
|
||||
if ( name ) counts.set(name, (counts.get(name) ?? 0) + 1);
|
||||
for ( const name of sharersOf(row) ) counts.set(name, (counts.get(name) ?? 0) + 1);
|
||||
}
|
||||
return [...counts]
|
||||
.map(([name, count]) => ({ name, count }))
|
||||
@@ -56,7 +69,8 @@ export const summarizeSharers = (rows) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* The rows `sharer` shared; every row when `sharer` is empty.
|
||||
* The rows `sharer` shared, each shown as shared by them; every row when
|
||||
* `sharer` is empty.
|
||||
*
|
||||
* @template {SharedRow} T
|
||||
* @param {T[]} rows
|
||||
@@ -64,7 +78,9 @@ export const summarizeSharers = (rows) => {
|
||||
* @returns {T[]}
|
||||
*/
|
||||
export const filterBySharer = (rows, sharer) => (sharer
|
||||
? rows.filter((row) => sharerOf(row) === sharer)
|
||||
? rows
|
||||
.filter((row) => sharersOf(row).includes(sharer))
|
||||
.map((row) => (sharerOf(row) === sharer ? row : { ...row, shared_by: sharer }))
|
||||
: rows);
|
||||
|
||||
/**
|
||||
@@ -80,6 +96,21 @@ export const compareSharers = (a, b) => compareNames(sharerOf(a), sharerOf(b));
|
||||
// one per call, which made sorting a large Shared listing over 20x slower.
|
||||
const compareNames = new Intl.Collator(undefined, { sensitivity: 'base' }).compare;
|
||||
|
||||
/**
|
||||
* Names joined the way the user's language lists them ("ann and bob").
|
||||
*
|
||||
* @param {string[]} names
|
||||
* @returns {string}
|
||||
*/
|
||||
const listNames = (names) => {
|
||||
if ( names.length === 1 ) return names[0];
|
||||
try {
|
||||
return new Intl.ListFormat(intlLocale(window.locale ?? 'en'), { type: 'conjunction' }).format(names);
|
||||
} catch {
|
||||
return names.join(', ');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* The "Shared by" column's width beside an auto-width name column: `width`,
|
||||
* held to 40% of the `room` the two split so the name keeps the larger part,
|
||||
@@ -102,15 +133,16 @@ export const fitSharedByWidth = (width, room) => Math.max(60, Math.min(width, Ma
|
||||
const avatarHtml = (name, className) => `<span class="${className}" style="--share-avatar-hue: ${avatarHue(name)}"${avatarUserAttr(name)} aria-hidden="true">${window.html_encode(avatarInitial(name))}</span>`;
|
||||
|
||||
/**
|
||||
* Tooltip for a row's sharer, naming the owner too when someone else re-shared it.
|
||||
* Tooltip for a row's sharers, naming the owner too when none of them owns it.
|
||||
*
|
||||
* @param {SharedRow} row
|
||||
* @returns {string} plain text
|
||||
*/
|
||||
export const sharedByTitle = (row) => {
|
||||
const username = sharerOf(row);
|
||||
if ( ! username ) return '';
|
||||
return row.owner && row.owner !== username
|
||||
const sharers = sharersOf(row);
|
||||
if ( sharers.length === 0 ) return '';
|
||||
const username = listNames(sharers);
|
||||
return row.owner && ! sharers.includes(row.owner)
|
||||
? i18n('shared_by_user_owned_by', { username, owner: row.owner }, false)
|
||||
: i18n('shared_by_user', { username }, false);
|
||||
};
|
||||
@@ -137,11 +169,11 @@ export const sharedByCellHtml = (row) => {
|
||||
*
|
||||
* @param {Sharer[]} sharers - From summarizeSharers
|
||||
* @param {string | null} active - The sharer filtered to, or null for everyone
|
||||
* @param {number} total - Items anyone shared; an item counts once however many shared it
|
||||
* @returns {string} markup
|
||||
*/
|
||||
export const sharerFilterHtml = (sharers, active) => {
|
||||
export const sharerFilterHtml = (sharers, active, total) => {
|
||||
if ( sharers.length < 2 ) return '';
|
||||
const total = sharers.reduce((sum, sharer) => sum + sharer.count, 0);
|
||||
const chip = (value, inner, count, title) => {
|
||||
const pressed = (active ?? '') === value;
|
||||
return `<button type="button" class="sharer-chip${pressed ? ' active' : ''}" data-sharer="${window.html_encode(value)}" aria-pressed="${pressed}"${title ? ` title="${window.html_encode(title)}"` : ''}>`
|
||||
|
||||
@@ -40,6 +40,13 @@ const {
|
||||
} = await import('./sharedBy.js');
|
||||
|
||||
const row = (shared_by, owner = shared_by, name = 'f') => ({ shared_by, owner, name });
|
||||
// An item granted by more than one person: `shared_by` holds the strongest grant's.
|
||||
const multiRow = (shared_by_all, owner = shared_by_all[0], name = 'f') => ({
|
||||
shared_by: shared_by_all[0],
|
||||
shared_by_all,
|
||||
owner,
|
||||
name,
|
||||
});
|
||||
|
||||
describe('sharerOf', () => {
|
||||
it('names whoever granted the share', () => {
|
||||
@@ -74,6 +81,13 @@ describe('summarizeSharers', () => {
|
||||
it('skips rows that name nobody', () => {
|
||||
expect(summarizeSharers([{}, row('ann')])).toEqual([{ name: 'ann', count: 1 }]);
|
||||
});
|
||||
|
||||
it('counts an item for everyone who shared it', () => {
|
||||
expect(summarizeSharers([multiRow(['bob', 'ann']), row('ann')])).toEqual([
|
||||
{ name: 'ann', count: 2 },
|
||||
{ name: 'bob', count: 1 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterBySharer', () => {
|
||||
@@ -83,6 +97,20 @@ describe('filterBySharer', () => {
|
||||
expect(filterBySharer(rows, 'ann').map((r) => r.name)).toEqual(['a', 'c']);
|
||||
});
|
||||
|
||||
it('keeps an item for each person who shared it', () => {
|
||||
const shared = [multiRow(['bob', 'ann'], 'ann', 'both'), row('cy', 'cy', 'c')];
|
||||
expect(filterBySharer(shared, 'ann').map((r) => r.name)).toEqual(['both']);
|
||||
expect(filterBySharer(shared, 'bob').map((r) => r.name)).toEqual(['both']);
|
||||
});
|
||||
|
||||
it('shows a kept item as shared by the person filtered to', () => {
|
||||
const [both, own] = filterBySharer([multiRow(['bob', 'ann'], 'ann', 'both'), row('ann', 'ann', 'own')], 'ann');
|
||||
expect(sharerOf(both)).toBe('ann');
|
||||
expect(sharedByCellHtml(both)).toContain('<span class="item-shared-by-name">ann</span>');
|
||||
expect(sharedByTitle(both)).toBe('shared_by_user:ann and bob');
|
||||
expect(sharerOf(own)).toBe('ann');
|
||||
});
|
||||
|
||||
it('keeps everything without a filter', () => {
|
||||
expect(filterBySharer(rows, null)).toBe(rows);
|
||||
expect(filterBySharer(rows, '')).toBe(rows);
|
||||
@@ -123,6 +151,11 @@ describe('sharedByTitle', () => {
|
||||
it('names the owner too when someone else re-shared it', () => {
|
||||
expect(sharedByTitle(row('ann', 'bob'))).toBe('shared_by_user_owned_by:ann,bob');
|
||||
});
|
||||
|
||||
it('names everyone who shared the item', () => {
|
||||
expect(sharedByTitle(multiRow(['bob', 'ann'], 'ann'))).toBe('shared_by_user:bob and ann');
|
||||
expect(sharedByTitle(multiRow(['bob', 'cy'], 'ann'))).toBe('shared_by_user_owned_by:bob and cy,ann');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sharedByCellHtml', () => {
|
||||
@@ -155,7 +188,7 @@ describe('sharerFilterHtml', () => {
|
||||
});
|
||||
|
||||
it('leads with Everyone, counting every item', () => {
|
||||
const h = sharerFilterHtml([{ name: 'bob', count: 2 }, { name: 'ann', count: 1 }], null);
|
||||
const h = sharerFilterHtml([{ name: 'bob', count: 2 }, { name: 'ann', count: 1 }], null, 3);
|
||||
const chips = h.match(/<button[^>]*>/g);
|
||||
expect(chips).toHaveLength(3);
|
||||
expect(chips[0]).toContain('data-sharer=""');
|
||||
@@ -165,6 +198,11 @@ describe('sharerFilterHtml', () => {
|
||||
expect(h).toContain('data-avatar-user="bob"');
|
||||
});
|
||||
|
||||
it('counts an item shared by two people once under Everyone', () => {
|
||||
const h = sharerFilterHtml([{ name: 'ann', count: 2 }, { name: 'bob', count: 1 }], null, 2);
|
||||
expect(h).toContain('shared_filter_everyone:</span><span class="sharer-chip-count">2</span>');
|
||||
});
|
||||
|
||||
it('presses the active sharer only', () => {
|
||||
const h = sharerFilterHtml([{ name: 'bob', count: 2 }, { name: 'ann', count: 1 }], 'ann');
|
||||
const pressed = h.match(/<button[^>]*aria-pressed="true"[^>]*>/g);
|
||||
|
||||
@@ -35,6 +35,7 @@ import { is_owned_by_me } from '../helpers/pathOwner.js';
|
||||
import { can_rename, can_restructure, invalidate_shared_roots, shared_mode_for } from '../helpers/sharedAccess.js';
|
||||
import { is_default_app } from '../helpers/defaultApp.js';
|
||||
import openWithAI from '../helpers/openWithAI.js';
|
||||
import refresh_item_container from '../helpers/refreshItemContainer.js';
|
||||
|
||||
async function UIItem (options) {
|
||||
const matching_appendto_count = $(options.appendTo).length;
|
||||
@@ -64,6 +65,7 @@ async function UIItem (options) {
|
||||
options.share_mode = options.share_mode ?? '';
|
||||
options.shared_by = options.shared_by ?? '';
|
||||
options.is_team_share = options.is_team_share ?? false;
|
||||
options.is_also_team_share = options.is_also_team_share ?? false;
|
||||
options.owner = options.owner ?? '';
|
||||
options.metadata = options.metadata ?? '';
|
||||
options.multiselectable = (options.multiselectable === undefined || options.multiselectable === true) ? true : false;
|
||||
@@ -102,6 +104,7 @@ async function UIItem (options) {
|
||||
data-share_mode="${html_encode(options.share_mode)}"
|
||||
data-shared_by="${html_encode(options.shared_by)}"
|
||||
data-is_team_share="${options.is_team_share ? 1 : 0}"
|
||||
data-is_also_team_share="${options.is_also_team_share ? 1 : 0}"
|
||||
data-owner="${html_encode(options.owner)}"
|
||||
data-has_website="${show_website_badge ? 1 : 0 }"
|
||||
data-website_url = "${website_url ? html_encode(website_url) : ''}"
|
||||
@@ -1529,7 +1532,13 @@ async function UIItem (options) {
|
||||
// Or mode lookups keep answering for a share we
|
||||
// just walked away from.
|
||||
invalidate_shared_roots();
|
||||
$(el_item).removeItems();
|
||||
// A team that still shares it keeps it listed,
|
||||
// as the team's share.
|
||||
if ( $(el_item).attr('data-is_also_team_share') === '1' ) {
|
||||
refresh_item_container($(el_item).closest('.item-container'));
|
||||
} else {
|
||||
$(el_item).removeItems();
|
||||
}
|
||||
} catch (e) {
|
||||
UIAlert({ message: e?.message ?? i18n('error_unknown_cause') });
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ vi.mock('../helpers/launchApp.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('../helpers/openItem.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('../helpers/publishAsWebsite.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('../helpers/openWithAI.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('../helpers/refreshItemContainer.js', () => ({ default: vi.fn() }));
|
||||
|
||||
globalThis.$ = jQuery;
|
||||
globalThis.jQuery = jQuery;
|
||||
@@ -67,6 +68,8 @@ Object.assign(window, {
|
||||
|
||||
const { default: UIItem } = await import('./UIItem.js');
|
||||
const { default: UIContextMenu } = await import('./UIContextMenu.js');
|
||||
const { default: refresh_item_container } =
|
||||
await import('../helpers/refreshItemContainer.js');
|
||||
|
||||
const menuLabels = async (options) => {
|
||||
document.body.innerHTML =
|
||||
@@ -90,14 +93,25 @@ const menuLabels = async (options) => {
|
||||
return UIContextMenu.mock.calls[0][0].items.map((item) => item?.html);
|
||||
};
|
||||
|
||||
const removeFromShared = async (options) => {
|
||||
await menuLabels(options);
|
||||
await UIContextMenu.mock.calls[0][0].items
|
||||
.find((item) => item?.html === 'share_remove_from_shared')
|
||||
.onClick();
|
||||
};
|
||||
|
||||
describe('UIItem Remove from Shared', () => {
|
||||
beforeEach(() => {
|
||||
window.user = { username: 'alice' };
|
||||
window.trash_path = '/alice/Trash';
|
||||
window.puter = {
|
||||
fs: { listShared: vi.fn(async () => ({ items: [] })) },
|
||||
fs: {
|
||||
listShared: vi.fn(async () => ({ items: [] })),
|
||||
unshare: vi.fn(async () => ({ revoked: 1 })),
|
||||
},
|
||||
};
|
||||
globalThis.puter = window.puter;
|
||||
refresh_item_container.mockClear();
|
||||
});
|
||||
|
||||
it('is offered on an item shared with the user directly', async () => {
|
||||
@@ -109,4 +123,19 @@ describe('UIItem Remove from Shared', () => {
|
||||
expect($('.item').attr('data-is_team_share')).toBe('1');
|
||||
expect(labels).not.toContain('share_remove_from_shared');
|
||||
});
|
||||
|
||||
it('takes the item away once the user has left it', async () => {
|
||||
await removeFromShared({});
|
||||
expect(puter.fs.unshare).toHaveBeenCalledWith('/bob/u-1/Photos', 'alice');
|
||||
expect($('.item')).toHaveLength(0);
|
||||
expect(refresh_item_container).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lists the item again when a team still shares it', async () => {
|
||||
await removeFromShared({ is_also_team_share: true });
|
||||
expect(puter.fs.unshare).toHaveBeenCalledWith('/bob/u-1/Photos', 'alice');
|
||||
expect($('.item')).toHaveLength(1);
|
||||
expect(refresh_item_container).toHaveBeenCalledTimes(1);
|
||||
expect(refresh_item_container.mock.calls[0][0][0]).toBe($('.item-container')[0]);
|
||||
});
|
||||
});
|
||||
@@ -50,7 +50,7 @@ import { is_default_app } from './defaultApp.js';
|
||||
* @param {Function} options.onPublishWebsite - Optional custom publish-as-website handler (used by Dashboard); receives {uid, name, path}
|
||||
* @param {Function} options.onPublishWorker - Optional custom publish-as-worker handler (used by Dashboard); receives {uid, name, path}
|
||||
* @param {Function} options.onPasteInto - Optional custom paste handler (used by Dashboard); receives the folder's path
|
||||
* @param {Function} options.onRemovedFromShared - Optional, called once Remove from Shared has taken the item's row away (used by Dashboard)
|
||||
* @param {Function} options.onRemovedFromShared - Optional, called once Remove from Shared has dropped the user's own access (used by Dashboard)
|
||||
* @returns {Promise<Array>} Array of context menu items
|
||||
*/
|
||||
const generate_file_context_menu = async function (options) {
|
||||
@@ -389,7 +389,9 @@ const generate_file_context_menu = async function (options) {
|
||||
// Or mode lookups keep answering for a share we just
|
||||
// walked away from.
|
||||
invalidate_shared_roots();
|
||||
$(el_item).remove();
|
||||
// A team that still shares it keeps it listed, as the
|
||||
// team's share; the refreshed listing shows it that way.
|
||||
if ( fsentry.also_team_share !== true ) $(el_item).remove();
|
||||
options.onRemovedFromShared?.();
|
||||
} catch (e) {
|
||||
UIAlert({ message: e?.message ?? i18n('error_unknown_cause') });
|
||||
|
||||
@@ -89,6 +89,38 @@ it('does not offer it on a share held through a team', async () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('Remove from Shared', () => {
|
||||
const remove = async (fsentry) => {
|
||||
const row = sharedRow();
|
||||
document.body.append(row);
|
||||
const onRemovedFromShared = vi.fn();
|
||||
window.user = { username: 'me' };
|
||||
globalThis.puter = { fs: { unshare: vi.fn(async () => ({ revoked: 1 })) } };
|
||||
const items = await generate_file_context_menu({
|
||||
element: row,
|
||||
fsentry: { name: 'notes.txt', path: '/ann/uid-1/notes.txt', ...fsentry },
|
||||
associated_app_name: 'editor',
|
||||
onRemovedFromShared,
|
||||
});
|
||||
await items.find((item) => item.html === 'share_remove_from_shared').onClick();
|
||||
expect(puter.fs.unshare).toHaveBeenCalledWith('/ann/uid-1/notes.txt', 'me');
|
||||
expect(onRemovedFromShared).toHaveBeenCalledTimes(1);
|
||||
return row;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('takes the row away once the user has left it', async () => {
|
||||
expect((await remove({})).isConnected).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the row for the listing to update when a team still shares it', async () => {
|
||||
expect((await remove({ also_team_share: true })).isConnected).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Open With', () => {
|
||||
const editor = { name: 'editor', title: 'Editor', uuid: 'app-editor' };
|
||||
const viewer = { name: 'viewer', title: 'Viewer', uuid: 'app-viewer' };
|
||||
|
||||
@@ -17,13 +17,50 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { strongerMode } from './shareModes.js';
|
||||
|
||||
// Largest page the backend will serve (ShareStore.MAX_HOLDER_PAGE_SIZE).
|
||||
const PAGE_SIZE = 200;
|
||||
|
||||
/**
|
||||
* Every share the current user holds, across all pages. A page can be short
|
||||
* once unreachable items are filtered out, so it pages on `cursor` rather than
|
||||
* on the item count.
|
||||
* One row per shared item. The API lists a row per grant and grants add up, so
|
||||
* an item granted twice keeps its strongest row. It stays a team share only
|
||||
* when no grant names the user directly, since a direct grant is theirs to leave.
|
||||
* A merged row lists everyone who granted it in `issuers`, strongest grant first,
|
||||
* and sets `alsoHeldByTeam` when leaving the direct grant leaves a team's in place.
|
||||
*
|
||||
* @param {Array<Object>} shares
|
||||
* @returns {Array<Object>}
|
||||
*/
|
||||
const issuersOf = (share) => share.issuers ?? [share.issuer];
|
||||
const heldByTeam = (share) => Boolean(share.holderTeam) || share.alsoHeldByTeam === true;
|
||||
|
||||
export const mergeGrants = (shares) => {
|
||||
const merged = new Map();
|
||||
for ( const share of shares ) {
|
||||
const key = share.entryUid ?? share.path;
|
||||
const held = merged.get(key);
|
||||
if ( ! held ) {
|
||||
merged.set(key, share);
|
||||
continue;
|
||||
}
|
||||
const [best, other] = strongerMode(held.mode, share.mode) === held.mode ? [held, share] : [share, held];
|
||||
const teamOnly = held.holderTeam && share.holderTeam;
|
||||
const issuers = [...issuersOf(best), ...issuersOf(other)].filter(Boolean);
|
||||
merged.set(key, {
|
||||
...best,
|
||||
holderTeam: teamOnly ? best.holderTeam : undefined,
|
||||
alsoHeldByTeam: ! teamOnly && (heldByTeam(held) || heldByTeam(share)),
|
||||
issuers: [...new Set(issuers)],
|
||||
});
|
||||
}
|
||||
return [...merged.values()];
|
||||
};
|
||||
|
||||
/**
|
||||
* Every item shared with the current user, across all pages. A page can be
|
||||
* short once unreachable items are filtered out, so it pages on `cursor`
|
||||
* rather than on the item count.
|
||||
*
|
||||
* @returns {Promise<Array<Object>>}
|
||||
*/
|
||||
@@ -40,7 +77,7 @@ const list_all_shared = async () => {
|
||||
cursor = page.cursor;
|
||||
} while ( cursor );
|
||||
|
||||
return shares;
|
||||
return mergeGrants(shares);
|
||||
};
|
||||
|
||||
export default list_all_shared;
|
||||
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import list_all_shared, { mergeGrants } from './listAllShared.js';
|
||||
|
||||
const BUDGET = '33333333-3333-3333-3333-333333333333';
|
||||
const REPORT = '44444444-4444-4444-4444-444444444444';
|
||||
const TEAM = { uid: 'team-1', name: 'Finance', handle: 'finance' };
|
||||
|
||||
const grant = (entryUid, name, extra) => ({
|
||||
entryUid,
|
||||
path: `/jf/${entryUid}/${name}`,
|
||||
name,
|
||||
...extra,
|
||||
});
|
||||
|
||||
describe('mergeGrants', () => {
|
||||
it('lists an item once, with its strongest grant', () => {
|
||||
const merged = mergeGrants([
|
||||
grant(BUDGET, 'Budget', { mode: 'manage', issuer: 'jf' }),
|
||||
grant(REPORT, 'report.pdf', { mode: 'read', issuer: 'jf' }),
|
||||
grant(BUDGET, 'Budget', { mode: 'read', issuer: 'delegate' }),
|
||||
grant(REPORT, 'report.pdf', { mode: 'write', issuer: 'delegate' }),
|
||||
]);
|
||||
expect(merged.map((share) => [share.entryUid, share.mode, share.issuer])).toEqual([
|
||||
[BUDGET, 'manage', 'jf'],
|
||||
[REPORT, 'write', 'delegate'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('names everyone who granted the item, strongest grant first', () => {
|
||||
const [share] = mergeGrants([
|
||||
grant(BUDGET, 'Budget', { mode: 'read', issuer: 'jf' }),
|
||||
grant(BUDGET, 'Budget', { mode: 'write', issuer: 'delegate' }),
|
||||
grant(BUDGET, 'Budget', { mode: 'read', issuer: 'jf', holderTeam: TEAM }),
|
||||
]);
|
||||
expect(share.issuer).toBe('delegate');
|
||||
expect(share.issuers).toEqual(['delegate', 'jf']);
|
||||
});
|
||||
|
||||
it('keeps the first of two equal grants', () => {
|
||||
const [share] = mergeGrants([
|
||||
grant(BUDGET, 'Budget', { mode: 'read', issuer: 'jf' }),
|
||||
grant(BUDGET, 'Budget', { mode: 'read', issuer: 'delegate' }),
|
||||
]);
|
||||
expect(share.issuer).toBe('jf');
|
||||
});
|
||||
|
||||
it('is not a team share when one grant names the user directly', () => {
|
||||
const [share] = mergeGrants([
|
||||
grant(BUDGET, 'Budget', { mode: 'read', issuer: 'jf' }),
|
||||
grant(BUDGET, 'Budget', { mode: 'write', issuer: 'jf', holderTeam: TEAM }),
|
||||
]);
|
||||
expect(share.mode).toBe('write');
|
||||
expect(share.holderTeam).toBeUndefined();
|
||||
expect(share.alsoHeldByTeam).toBe(true);
|
||||
});
|
||||
|
||||
it('marks only an item held both ways as also held by a team', () => {
|
||||
const [direct, team] = mergeGrants([
|
||||
grant(BUDGET, 'Budget', { mode: 'read', issuer: 'jf' }),
|
||||
grant(BUDGET, 'Budget', { mode: 'write', issuer: 'delegate' }),
|
||||
grant(REPORT, 'report.pdf', { mode: 'read', holderTeam: TEAM }),
|
||||
grant(REPORT, 'report.pdf', { mode: 'write', holderTeam: TEAM }),
|
||||
]);
|
||||
expect(direct.alsoHeldByTeam).toBe(false);
|
||||
expect(team.alsoHeldByTeam).toBe(false);
|
||||
});
|
||||
|
||||
it('still marks it when a later grant is direct again', () => {
|
||||
const [share] = mergeGrants([
|
||||
grant(BUDGET, 'Budget', { mode: 'read', holderTeam: TEAM }),
|
||||
grant(BUDGET, 'Budget', { mode: 'read', issuer: 'jf' }),
|
||||
grant(BUDGET, 'Budget', { mode: 'write', issuer: 'delegate' }),
|
||||
]);
|
||||
expect(share.holderTeam).toBeUndefined();
|
||||
expect(share.alsoHeldByTeam).toBe(true);
|
||||
});
|
||||
|
||||
it('stays a team share when every grant is to a team', () => {
|
||||
const [share] = mergeGrants([
|
||||
grant(BUDGET, 'Budget', { mode: 'read', holderTeam: { ...TEAM, uid: 'team-2' } }),
|
||||
grant(BUDGET, 'Budget', { mode: 'write', holderTeam: TEAM }),
|
||||
]);
|
||||
expect(share.holderTeam).toEqual(TEAM);
|
||||
});
|
||||
});
|
||||
|
||||
describe('list_all_shared', () => {
|
||||
beforeEach(() => {
|
||||
globalThis.window = { puter: { fs: { listShared: vi.fn() } } };
|
||||
});
|
||||
|
||||
it('merges grants for one item that arrive on different pages', async () => {
|
||||
window.puter.fs.listShared
|
||||
.mockResolvedValueOnce({
|
||||
items: [grant(BUDGET, 'Budget', { mode: 'manage' })],
|
||||
cursor: 'next',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
items: [grant(BUDGET, 'Budget', { mode: 'read' })],
|
||||
});
|
||||
|
||||
const shares = await list_all_shared();
|
||||
|
||||
expect(shares).toHaveLength(1);
|
||||
expect(shares[0].mode).toBe('manage');
|
||||
expect(window.puter.fs.listShared).toHaveBeenLastCalledWith({ limit: 200, cursor: 'next' });
|
||||
});
|
||||
});
|
||||
@@ -159,6 +159,7 @@ const refresh_item_container = function (el_item_container, options) {
|
||||
shared_by: share.issuer,
|
||||
owner: share.owner,
|
||||
holder_team: share.holderTeam,
|
||||
also_team_share: share.alsoHeldByTeam,
|
||||
metadata: '',
|
||||
})))
|
||||
: puter.fs.readdir({ path: container_path, consistency: options.consistency ?? 'eventual' });
|
||||
@@ -264,6 +265,7 @@ const refresh_item_container = function (el_item_container, options) {
|
||||
shared_by: fsentry.shared_by,
|
||||
is_shared: fsentry.is_shared,
|
||||
is_team_share: !!fsentry.holder_team,
|
||||
is_also_team_share: fsentry.also_team_share === true,
|
||||
owner: fsentry.owner?.username ?? fsentry.owner,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -107,6 +107,31 @@ describe('refresh_item_container in the Shared view', () => {
|
||||
is_team_share: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('flags a direct share the team holds too', async () => {
|
||||
list_all_shared.mockResolvedValue([
|
||||
{
|
||||
entryUid: 'u-1',
|
||||
name: 'a.txt',
|
||||
path: '/bob/u-1/a.txt',
|
||||
isDir: false,
|
||||
mode: 'read',
|
||||
issuer: 'bob',
|
||||
owner: 'bob',
|
||||
alsoHeldByTeam: true,
|
||||
},
|
||||
]);
|
||||
document.body.innerHTML =
|
||||
'<div class="window"><div class="item-container" data-path="/alice/Shared"></div></div>';
|
||||
|
||||
refresh_item_container($('.item-container'));
|
||||
|
||||
await vi.waitFor(() => expect(UIItem).toHaveBeenCalledTimes(1));
|
||||
expect(UIItem.mock.calls[0][0]).toMatchObject({
|
||||
is_team_share: false,
|
||||
is_also_team_share: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const deferred = () => {
|
||||
|
||||
@@ -23,6 +23,20 @@
|
||||
// place in these dialogs; a row already set to one is shown as-is.
|
||||
export const MODES = ['read', 'write', 'manage'];
|
||||
|
||||
// Every mode the API grants, weakest first. Each one implies those before it.
|
||||
const STRENGTH = ['see', 'list', 'read', 'write', 'manage'];
|
||||
|
||||
/**
|
||||
* The stronger of two access modes. `held` wins a tie, and a mode outside the
|
||||
* API's five never beats a known one.
|
||||
*
|
||||
* @param {string} held
|
||||
* @param {string} other
|
||||
* @returns {string}
|
||||
*/
|
||||
export const strongerMode = (held, other) =>
|
||||
STRENGTH.indexOf(other) > STRENGTH.indexOf(held) ? other : held;
|
||||
|
||||
/**
|
||||
* Human-readable label for an access mode, ready to drop into HTML.
|
||||
*
|
||||
|
||||
@@ -6,12 +6,12 @@ import { decode, encode } from 'html-entities';
|
||||
globalThis.window = globalThis.window ?? {};
|
||||
globalThis.html_encode = (str) => encode(str);
|
||||
|
||||
let MODES, mode_label, options_for;
|
||||
let MODES, mode_label, options_for, strongerMode;
|
||||
|
||||
beforeAll(async () => {
|
||||
await import('../i18n/i18n.js'); // installs window.i18n
|
||||
globalThis.i18n = window.i18n;
|
||||
({ MODES, mode_label, options_for } = await import('./shareModes.js'));
|
||||
({ MODES, mode_label, options_for, strongerMode } = await import('./shareModes.js'));
|
||||
});
|
||||
|
||||
// What the browser renders for a fragment of HTML-safe markup.
|
||||
@@ -41,6 +41,21 @@ describe('mode_label', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('strongerMode', () => {
|
||||
it('ranks all five API modes', () => {
|
||||
const modes = ['see', 'list', 'read', 'write', 'manage'];
|
||||
for ( let i = 1; i < modes.length; i++ ) {
|
||||
expect(strongerMode(modes[i - 1], modes[i])).toBe(modes[i]);
|
||||
expect(strongerMode(modes[i], modes[i - 1])).toBe(modes[i]);
|
||||
}
|
||||
});
|
||||
|
||||
it('never lets an unrecognized mode beat a known one', () => {
|
||||
expect(strongerMode('see', 'owner')).toBe('see');
|
||||
expect(strongerMode('owner', 'see')).toBe('see');
|
||||
});
|
||||
});
|
||||
|
||||
describe('options_for', () => {
|
||||
const values = (html) => [...html.matchAll(/value="([^"]*)"/g)].map((m) => m[1]);
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
import list_all_shared from './listAllShared.js';
|
||||
import { is_owned_by_me } from './pathOwner.js';
|
||||
import { strongerMode } from './shareModes.js';
|
||||
import { is_share_root } from './sharePaths.js';
|
||||
|
||||
// What we hold on each shared root, by path: `{ mode, name }`. A `readdir`
|
||||
@@ -29,13 +30,16 @@ let inflight = null;
|
||||
|
||||
/**
|
||||
* Record what a Shared listing reported. Replaces rather than merges, so a
|
||||
* share withdrawn elsewhere does not linger.
|
||||
* share withdrawn elsewhere does not linger. Grants add up, so a path listed
|
||||
* more than once keeps its strongest mode.
|
||||
*/
|
||||
export const remember_shared_roots = (shares) => {
|
||||
roots.clear();
|
||||
for ( const share of shares ) {
|
||||
if ( ! share?.path || ! share?.mode ) continue;
|
||||
roots.set(share.path, { mode: share.mode, name: share.name });
|
||||
const held = roots.get(share.path);
|
||||
const mode = held ? strongerMode(held.mode, share.mode) : share.mode;
|
||||
roots.set(share.path, { mode, name: share.name });
|
||||
}
|
||||
loaded = true;
|
||||
};
|
||||
|
||||
@@ -64,6 +64,19 @@ describe('shared_access', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the strongest mode when a path is granted more than once', async () => {
|
||||
remember_shared_roots([
|
||||
{ path: `/jf/${BUDGET}/Budget`, mode: 'manage' },
|
||||
{ path: `/jf/${BUDGET}/Budget`, mode: 'read' },
|
||||
{ path: `/jf/${REPORT}/report.pdf`, mode: 'read' },
|
||||
{ path: `/jf/${REPORT}/report.pdf`, mode: 'write' },
|
||||
]);
|
||||
expect(await shared_mode_for(`/jf/${BUDGET}/Budget`)).toBe('manage');
|
||||
expect(await shared_mode_for(`/jf/${REPORT}/report.pdf`)).toBe('write');
|
||||
expect(await can_share(`/jf/${BUDGET}/Budget/q1.xlsx`)).toBe(true);
|
||||
expect(await can_rename(`/jf/${REPORT}/report.pdf`)).toBe(true);
|
||||
});
|
||||
|
||||
it('reports nothing outside every shared root', async () => {
|
||||
expect(await shared_mode_for(`/jf/${CONTENTS}/Private/a.txt`)).toBe(null);
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user