一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
218 行
5.3 KiB
TypeScript
218 行
5.3 KiB
TypeScript
import { strict as assert } from 'node:assert';
|
|
import type { FolderData, ItemId } from './types';
|
|
import Serializable, { BaseSchema } from './Serializable';
|
|
|
|
export type ShareRecord = {
|
|
email: string;
|
|
readOnly: boolean;
|
|
};
|
|
|
|
interface InitializationOptions extends FolderData {
|
|
childIds: readonly ItemId[];
|
|
sharedWith: readonly ShareRecord[];
|
|
isShared: boolean;
|
|
// Email of the Joplin Server account that controls the item
|
|
ownedByEmail: string;
|
|
}
|
|
|
|
const validateId = (id: string) => {
|
|
return !!id.match(/^[a-zA-Z0-9]{32}$/);
|
|
};
|
|
|
|
const schema = {
|
|
isFolder: 'boolean',
|
|
parentId: 'id',
|
|
id: 'id',
|
|
title: 'string',
|
|
ownedByEmail: 'string',
|
|
childIds: 'id[]',
|
|
isShared: 'boolean',
|
|
sharedWith: [
|
|
{ email: 'string', readOnly: 'boolean' },
|
|
'...',
|
|
],
|
|
} satisfies BaseSchema;
|
|
|
|
const normalizeFolderTitle = (title: string) => {
|
|
// Joplin removes / and \ from the beginning of folder titles.
|
|
// For consistency, this must be done in the fuzzer, too.
|
|
const illegalStartCharRegex = /^[/\\]+/;
|
|
return title.replace(illegalStartCharRegex, '');
|
|
};
|
|
|
|
export default class FolderRecord extends Serializable<typeof schema> implements FolderData {
|
|
public readonly parentId: ItemId;
|
|
public readonly id: ItemId;
|
|
public readonly title: string;
|
|
public readonly ownedByEmail: string;
|
|
public readonly childIds: readonly ItemId[];
|
|
|
|
private readonly isShared_: boolean;
|
|
private readonly sharedWith_: readonly ShareRecord[];
|
|
|
|
public constructor(options: InitializationOptions) {
|
|
super(schema);
|
|
|
|
this.parentId = options.parentId;
|
|
this.id = options.id;
|
|
this.title = normalizeFolderTitle(options.title);
|
|
this.childIds = options.childIds;
|
|
this.isShared_ = options.isShared ?? options.sharedWith.length > 0;
|
|
this.ownedByEmail = options.ownedByEmail;
|
|
this.sharedWith_ = options.sharedWith;
|
|
|
|
if (this.parentId !== '' && !validateId(this.parentId)) {
|
|
throw new Error(`Invalid parent ID: ${this.parentId}`);
|
|
}
|
|
|
|
if (!validateId(this.id)) {
|
|
throw new Error(`Invalid ID: ${this.id}`);
|
|
}
|
|
}
|
|
|
|
public static fromSerialized(serialized: unknown) {
|
|
const data = this.deserialize(schema, serialized);
|
|
return new FolderRecord(data);
|
|
}
|
|
|
|
public serialize() {
|
|
return {
|
|
isFolder: true,
|
|
parentId: this.parentId,
|
|
id: this.id,
|
|
title: this.title,
|
|
ownedByEmail: this.ownedByEmail,
|
|
childIds: [...this.childIds],
|
|
sharedWith: [...this.sharedWith_],
|
|
isShared: this.isShared_,
|
|
};
|
|
}
|
|
|
|
public get shareRecipients() {
|
|
return this.sharedWith_.map(sharee => sharee.email);
|
|
}
|
|
|
|
private get metadata_(): InitializationOptions {
|
|
return {
|
|
parentId: this.parentId,
|
|
id: this.id,
|
|
title: this.title,
|
|
isShared: this.isShared_,
|
|
ownedByEmail: this.ownedByEmail,
|
|
childIds: [...this.childIds],
|
|
sharedWith: [...this.sharedWith_],
|
|
};
|
|
}
|
|
|
|
public get isRootSharedItem() {
|
|
return this.isShared_ && this.parentId === '';
|
|
}
|
|
|
|
public isSharedWith(email: string) {
|
|
assert.equal(this.parentId, '', 'only supported for toplevel folders');
|
|
return this.sharedWith_.some(record => record.email === email);
|
|
}
|
|
|
|
public isReadOnlySharedWith(email: string) {
|
|
assert.equal(this.parentId, '', 'only supported for toplevel folders');
|
|
return this.sharedWith_.some(record => record.email === email && record.readOnly);
|
|
}
|
|
|
|
public withTitle(title: string) {
|
|
return new FolderRecord({
|
|
...this.metadata_,
|
|
title,
|
|
});
|
|
}
|
|
|
|
public withParent(parentId: ItemId) {
|
|
return new FolderRecord({
|
|
...this.metadata_,
|
|
parentId,
|
|
});
|
|
}
|
|
|
|
public withId(id: ItemId) {
|
|
return new FolderRecord({
|
|
...this.metadata_,
|
|
id,
|
|
});
|
|
}
|
|
|
|
public withChildren(childIds: readonly ItemId[]) {
|
|
return new FolderRecord({
|
|
...this.metadata_,
|
|
childIds: [...childIds],
|
|
});
|
|
}
|
|
|
|
public withChildAdded(childId: ItemId) {
|
|
if (this.childIds.includes(childId)) {
|
|
return this;
|
|
}
|
|
|
|
return this.withChildren([...this.childIds, childId]);
|
|
}
|
|
|
|
public withChildRemoved(childId: ItemId) {
|
|
return this.withChildren(
|
|
this.childIds.filter(id => id !== childId),
|
|
);
|
|
}
|
|
|
|
private withShareState_(isShared: boolean, shareState: readonly ShareRecord[]) {
|
|
return new FolderRecord({
|
|
...this.metadata_,
|
|
isShared,
|
|
sharedWith: [...shareState],
|
|
});
|
|
}
|
|
|
|
public withShareOwner(email: string) {
|
|
if (email === this.ownedByEmail) return this;
|
|
|
|
const sharedWith = this.metadata_.sharedWith.filter(recipient => recipient.email !== email);
|
|
return new FolderRecord({
|
|
...this.metadata_,
|
|
sharedWith: sharedWith,
|
|
isShared: sharedWith.length > 0,
|
|
ownedByEmail: email,
|
|
});
|
|
}
|
|
|
|
public withShared(recipientEmail: string, readOnly: boolean) {
|
|
if (this.isSharedWith(recipientEmail) && this.isReadOnlySharedWith(recipientEmail) === readOnly) {
|
|
return this;
|
|
}
|
|
|
|
if (this.parentId !== '') {
|
|
throw new Error('Cannot share non-top-level folder');
|
|
}
|
|
|
|
return this.withShareState_(true, [
|
|
...this.sharedWith_.filter(record => record.email !== recipientEmail),
|
|
{ email: recipientEmail, readOnly },
|
|
]);
|
|
}
|
|
|
|
public withRemovedFromShare(recipientEmail: string) {
|
|
if (!this.isSharedWith(recipientEmail)) {
|
|
return this;
|
|
}
|
|
|
|
return this.withShareState_(
|
|
// Even if no users are present in the share, Joplin still considers the folder to be shared
|
|
this.isShared_,
|
|
this.sharedWith_.filter(record => record.email !== recipientEmail),
|
|
);
|
|
}
|
|
|
|
public withUnshared() {
|
|
if (!this.isShared_) {
|
|
return this;
|
|
}
|
|
|
|
return this.withShareState_(false, []);
|
|
}
|
|
}
|