mirror of
https://github.com/toeverything/AFFiNE.git
synced 2025-10-26 11:37:06 +00:00
Compare commits
17 Commits
v2025.10.1
...
canary
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c102e2454f | ||
|
|
5fc3258a3d | ||
|
|
1a9863d36f | ||
|
|
35c2ad262f | ||
|
|
a0613b6306 | ||
|
|
c18840038f | ||
|
|
e2de0e0e3d | ||
|
|
6fb0ff9177 | ||
|
|
c2fb6adfd8 | ||
|
|
8aeb8bd0ca | ||
|
|
a47042cbd5 | ||
|
|
2c44d3abc6 | ||
|
|
01c164a78a | ||
|
|
5c0e3b8a7f | ||
|
|
e4f9d42990 | ||
|
|
59d8d0fbae | ||
|
|
50f41c2212 |
@ -323,7 +323,8 @@ export class EmbedLinkedDocBlockComponent extends EmbedBlockComponent<EmbedLinke
|
||||
|
||||
private readonly _renderEmbedView = () => {
|
||||
const linkedDoc = this.linkedDoc;
|
||||
const isDeleted = !linkedDoc;
|
||||
const trash = linkedDoc?.meta?.trash;
|
||||
const isDeleted = trash || !linkedDoc;
|
||||
const isLoading = this._loading;
|
||||
const isError = this.isError;
|
||||
const isEmpty = this._isDocEmpty() && this.isBannerEmpty;
|
||||
@ -521,11 +522,6 @@ export class EmbedLinkedDocBlockComponent extends EmbedBlockComponent<EmbedLinke
|
||||
);
|
||||
|
||||
this._setDocUpdatedAt();
|
||||
this.disposables.add(
|
||||
this.store.workspace.slots.docListUpdated.subscribe(() => {
|
||||
this._setDocUpdatedAt();
|
||||
})
|
||||
);
|
||||
|
||||
if (this._referenceToNode) {
|
||||
this._linkedDocMode = this.model.props.params?.mode ?? 'page';
|
||||
@ -554,6 +550,13 @@ export class EmbedLinkedDocBlockComponent extends EmbedBlockComponent<EmbedLinke
|
||||
})
|
||||
);
|
||||
|
||||
this.disposables.add(
|
||||
this.store.workspace.slots.docListUpdated.subscribe(() => {
|
||||
this._setDocUpdatedAt();
|
||||
this.refreshData();
|
||||
})
|
||||
);
|
||||
|
||||
this._trackCitationDeleteEvent();
|
||||
}
|
||||
|
||||
|
||||
@ -357,10 +357,14 @@ export class EmbedSyncedDocBlockComponent extends EmbedBlockComponent<EmbedSynce
|
||||
};
|
||||
|
||||
refreshData = () => {
|
||||
this._load().catch(e => {
|
||||
console.error(e);
|
||||
this._error = true;
|
||||
});
|
||||
this._load()
|
||||
.then(() => {
|
||||
this._isEmptySyncedDoc = isEmptyDoc(this.syncedDoc, this.editorMode);
|
||||
})
|
||||
.catch(e => {
|
||||
console.error(e);
|
||||
this._error = true;
|
||||
});
|
||||
};
|
||||
|
||||
title$ = computed(() => {
|
||||
@ -445,7 +449,8 @@ export class EmbedSyncedDocBlockComponent extends EmbedBlockComponent<EmbedSynce
|
||||
this._cycle = false;
|
||||
|
||||
const syncedDoc = this.syncedDoc;
|
||||
if (!syncedDoc) {
|
||||
const trash = syncedDoc?.meta?.trash;
|
||||
if (trash || !syncedDoc) {
|
||||
this._deleted = true;
|
||||
this._loading = false;
|
||||
return;
|
||||
@ -521,6 +526,7 @@ export class EmbedSyncedDocBlockComponent extends EmbedBlockComponent<EmbedSynce
|
||||
this.disposables.add(
|
||||
this.store.workspace.slots.docListUpdated.subscribe(() => {
|
||||
this._setDocUpdatedAt();
|
||||
this.refreshData();
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@ -22,7 +22,7 @@ export const updateBlockAlign: Command<UpdateBlockAlignConfig> = (
|
||||
) => {
|
||||
let { std, textAlign, selectedBlocks } = ctx;
|
||||
|
||||
if (selectedBlocks === null) {
|
||||
if (!selectedBlocks) {
|
||||
const [result, ctx] = std.command
|
||||
.chain()
|
||||
.tryAll(chain => [
|
||||
|
||||
@ -134,7 +134,7 @@ export class ImportDoc extends WithDisposable(LitElement) {
|
||||
);
|
||||
return;
|
||||
}
|
||||
this._onImportSuccess([entryId], {
|
||||
this._onImportSuccess(entryId ? [entryId] : [], {
|
||||
isWorkspaceFile,
|
||||
importedCount: pageIds.length,
|
||||
});
|
||||
|
||||
@ -21,6 +21,28 @@ type ImportNotionZipOptions = {
|
||||
extensions: ExtensionType[];
|
||||
};
|
||||
|
||||
type PageIcon = {
|
||||
type: 'emoji' | 'image';
|
||||
content: string; // emoji unicode or image URL/data
|
||||
};
|
||||
|
||||
type FolderHierarchy = {
|
||||
name: string;
|
||||
path: string;
|
||||
children: Map<string, FolderHierarchy>;
|
||||
pageId?: string;
|
||||
parentPath?: string;
|
||||
icon?: PageIcon;
|
||||
};
|
||||
|
||||
type ImportNotionZipResult = {
|
||||
entryId: string | undefined;
|
||||
pageIds: string[];
|
||||
isWorkspaceFile: boolean;
|
||||
hasMarkdown: boolean;
|
||||
folderHierarchy?: FolderHierarchy;
|
||||
};
|
||||
|
||||
function getProvider(extensions: ExtensionType[]) {
|
||||
const container = new Container();
|
||||
extensions.forEach(ext => {
|
||||
@ -29,6 +51,197 @@ function getProvider(extensions: ExtensionType[]) {
|
||||
return container.provider();
|
||||
}
|
||||
|
||||
function parseFolderPath(filePath: string): {
|
||||
folderParts: string[];
|
||||
fileName: string;
|
||||
} {
|
||||
const parts = filePath.split('/');
|
||||
const fileName = parts.pop() || '';
|
||||
return { folderParts: parts.filter(part => part.length > 0), fileName };
|
||||
}
|
||||
|
||||
function extractPageIcon(doc: Document): PageIcon | undefined {
|
||||
// Look for Notion page icon in the HTML
|
||||
// Notion export format: <div class="page-header-icon undefined"><span class="icon">✅</span></div>
|
||||
|
||||
console.log('=== Extracting page icon ===');
|
||||
|
||||
// Check if there's a head section with title for debugging
|
||||
const headTitle = doc.querySelector('head title');
|
||||
if (headTitle) {
|
||||
console.log('Page title from head:', headTitle.textContent);
|
||||
}
|
||||
|
||||
// Look for the exact Notion export structure: .page-header-icon .icon
|
||||
const notionIconSpan = doc.querySelector('.page-header-icon .icon');
|
||||
if (notionIconSpan && notionIconSpan.textContent) {
|
||||
const iconContent = notionIconSpan.textContent.trim();
|
||||
console.log('Found Notion icon (.page-header-icon .icon):', iconContent);
|
||||
if (/\p{Emoji}/u.test(iconContent)) {
|
||||
return {
|
||||
type: 'emoji',
|
||||
content: iconContent,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Look for page header area for debugging
|
||||
const pageHeader = doc.querySelector('.page-header-icon');
|
||||
if (pageHeader) {
|
||||
console.log(
|
||||
'Found .page-header-icon:',
|
||||
pageHeader.outerHTML.substring(0, 300) + '...'
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback: try to find emoji icons with older selectors
|
||||
const emojiIcon = doc.querySelector('.page-header-icon .notion-emoji');
|
||||
if (emojiIcon && emojiIcon.textContent) {
|
||||
console.log(
|
||||
'Found emoji icon (.page-header-icon .notion-emoji):',
|
||||
emojiIcon.textContent
|
||||
);
|
||||
return {
|
||||
type: 'emoji',
|
||||
content: emojiIcon.textContent.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
// Try alternative emoji selectors
|
||||
const altEmojiIcon = doc.querySelector('[role="img"][aria-label]');
|
||||
if (
|
||||
altEmojiIcon &&
|
||||
altEmojiIcon.textContent &&
|
||||
/\p{Emoji}/u.test(altEmojiIcon.textContent)
|
||||
) {
|
||||
console.log(
|
||||
'Found emoji icon ([role="img"][aria-label]):',
|
||||
altEmojiIcon.textContent
|
||||
);
|
||||
return {
|
||||
type: 'emoji',
|
||||
content: altEmojiIcon.textContent.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
// Look for image icons in the page header
|
||||
const imageIcon = doc.querySelector('.page-header-icon img');
|
||||
if (imageIcon) {
|
||||
const src = imageIcon.getAttribute('src');
|
||||
console.log('Found image icon (.page-header-icon img):', src);
|
||||
if (src) {
|
||||
return {
|
||||
type: 'image',
|
||||
content: src,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Look for any span with emoji class "icon" in page header area
|
||||
const iconSpans = doc.querySelectorAll('span.icon');
|
||||
for (const span of iconSpans) {
|
||||
if (span.textContent && /\p{Emoji}/u.test(span.textContent.trim())) {
|
||||
const parent = span.parentElement;
|
||||
console.log(
|
||||
'Found emoji in span.icon:',
|
||||
span.textContent,
|
||||
'parent classes:',
|
||||
parent?.className
|
||||
);
|
||||
// Check if this is in a page header context
|
||||
if (
|
||||
parent &&
|
||||
(parent.classList.contains('page-header-icon') ||
|
||||
parent.closest('.page-header-icon'))
|
||||
) {
|
||||
console.log(
|
||||
'Using emoji from span.icon in page header:',
|
||||
span.textContent
|
||||
);
|
||||
return {
|
||||
type: 'emoji',
|
||||
content: span.textContent.trim(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Try to find icons in the page title area that might contain emoji
|
||||
const pageTitle = doc.querySelector('.page-title, h1');
|
||||
if (pageTitle && pageTitle.textContent) {
|
||||
console.log('Page title element found:', pageTitle.textContent);
|
||||
const text = pageTitle.textContent.trim();
|
||||
// Check if the title starts with an emoji
|
||||
const emojiMatch = text.match(/^(\p{Emoji}+)/u);
|
||||
if (emojiMatch) {
|
||||
console.log('Found emoji in title:', emojiMatch[1]);
|
||||
return {
|
||||
type: 'emoji',
|
||||
content: emojiMatch[1],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
console.log('No page icon found');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildFolderHierarchy(
|
||||
pagePaths: Array<{ path: string; pageId: string; icon?: PageIcon }>
|
||||
): FolderHierarchy {
|
||||
const root: FolderHierarchy = {
|
||||
name: '',
|
||||
path: '',
|
||||
children: new Map(),
|
||||
};
|
||||
|
||||
for (const { path, pageId, icon } of pagePaths) {
|
||||
const { folderParts, fileName } = parseFolderPath(path);
|
||||
let current = root;
|
||||
let currentPath = '';
|
||||
|
||||
// Navigate/create folder structure
|
||||
for (const folderName of folderParts) {
|
||||
const parentPath = currentPath;
|
||||
currentPath = currentPath ? `${currentPath}/${folderName}` : folderName;
|
||||
|
||||
if (!current.children.has(folderName)) {
|
||||
current.children.set(folderName, {
|
||||
name: folderName,
|
||||
path: currentPath,
|
||||
parentPath: parentPath || undefined,
|
||||
children: new Map(),
|
||||
});
|
||||
}
|
||||
current = current.children.get(folderName)!;
|
||||
}
|
||||
|
||||
// If this is a page file, associate it with the current folder
|
||||
if (fileName.endsWith('.html') && !fileName.startsWith('index.html')) {
|
||||
const pageName = fileName.replace(/\.html$/, '');
|
||||
if (!current.children.has(pageName)) {
|
||||
current.children.set(pageName, {
|
||||
name: pageName,
|
||||
path: path,
|
||||
parentPath: current.path || undefined,
|
||||
children: new Map(),
|
||||
pageId: pageId,
|
||||
icon: icon,
|
||||
});
|
||||
} else {
|
||||
// Update existing entry with pageId and icon
|
||||
const existingPage = current.children.get(pageName)!;
|
||||
existingPage.pageId = pageId;
|
||||
if (icon) {
|
||||
existingPage.icon = icon;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports a Notion zip file into the BlockSuite collection.
|
||||
*
|
||||
@ -42,18 +255,24 @@ function getProvider(extensions: ExtensionType[]) {
|
||||
* - pageIds: An array of imported page IDs.
|
||||
* - isWorkspaceFile: Whether the imported file is a workspace file.
|
||||
* - hasMarkdown: Whether the zip contains markdown files.
|
||||
* - folderHierarchy: The parsed folder hierarchy from the Notion export.
|
||||
*/
|
||||
async function importNotionZip({
|
||||
collection,
|
||||
schema,
|
||||
imported,
|
||||
extensions,
|
||||
}: ImportNotionZipOptions) {
|
||||
}: ImportNotionZipOptions): Promise<ImportNotionZipResult> {
|
||||
const provider = getProvider(extensions);
|
||||
const pageIds: string[] = [];
|
||||
let isWorkspaceFile = false;
|
||||
let hasMarkdown = false;
|
||||
let entryId: string | undefined;
|
||||
const pagePathsWithIds: Array<{
|
||||
path: string;
|
||||
pageId: string;
|
||||
icon?: PageIcon;
|
||||
}> = [];
|
||||
const parseZipFile = async (path: File | Blob) => {
|
||||
const unzip = new Unzip();
|
||||
await unzip.load(path);
|
||||
@ -80,6 +299,8 @@ async function importNotionZip({
|
||||
isWorkspaceFile = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
let pageIcon: PageIcon | undefined;
|
||||
if (lastSplitIndex !== -1) {
|
||||
const text = await content.text();
|
||||
const doc = new DOMParser().parseFromString(text, 'text/html');
|
||||
@ -88,7 +309,10 @@ async function importNotionZip({
|
||||
// Skip empty pages
|
||||
continue;
|
||||
}
|
||||
// Extract page icon from the HTML
|
||||
pageIcon = extractPageIcon(doc);
|
||||
}
|
||||
|
||||
const id = collection.idGenerator();
|
||||
const splitPath = path.split('/');
|
||||
while (splitPath.length > 0) {
|
||||
@ -96,6 +320,7 @@ async function importNotionZip({
|
||||
splitPath.shift();
|
||||
}
|
||||
pagePaths.push(path);
|
||||
pagePathsWithIds.push({ path, pageId: id, icon: pageIcon });
|
||||
if (entryId === undefined && lastSplitIndex === -1) {
|
||||
entryId = id;
|
||||
}
|
||||
@ -166,7 +391,14 @@ async function importNotionZip({
|
||||
const allPromises = await parseZipFile(imported);
|
||||
await Promise.all(allPromises.flat());
|
||||
entryId = entryId ?? pageIds[0];
|
||||
return { entryId, pageIds, isWorkspaceFile, hasMarkdown };
|
||||
|
||||
// Build folder hierarchy from collected paths
|
||||
const folderHierarchy =
|
||||
pagePathsWithIds.length > 0
|
||||
? buildFolderHierarchy(pagePathsWithIds)
|
||||
: undefined;
|
||||
|
||||
return { entryId, pageIds, isWorkspaceFile, hasMarkdown, folderHierarchy };
|
||||
}
|
||||
|
||||
export const NotionHtmlTransformer = {
|
||||
|
||||
@ -51,6 +51,7 @@ const flatTableSchema = defineBlockSchema({
|
||||
textCols: {} as Record<string, Text>,
|
||||
rows: {} as Record<string, { color: string }>,
|
||||
labels: [] as Array<string>,
|
||||
optional: undefined as string | undefined,
|
||||
}),
|
||||
metadata: {
|
||||
role: 'content',
|
||||
@ -494,6 +495,16 @@ describe('flat', () => {
|
||||
expect(model.props.textCols$.value.a.toDelta()).toEqual([
|
||||
{ insert: 'test' },
|
||||
]);
|
||||
|
||||
onChange.mockClear();
|
||||
expect(model.props).not.toHaveProperty('optional');
|
||||
expect(model.props).toHaveProperty('optional$');
|
||||
model.props.optional$.value = 'test';
|
||||
expect(model.props.optional).toBe('test');
|
||||
expect(model.props.optional$.value).toBe('test');
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
expect(onChange).toHaveBeenCalledWith(expect.anything(), 'optional', true);
|
||||
expect(yBlock.get('prop:optional')).toBe('test');
|
||||
});
|
||||
|
||||
test('stash and pop', () => {
|
||||
|
||||
@ -17,6 +17,7 @@ export interface DocMeta {
|
||||
createDate: number;
|
||||
updatedDate?: number;
|
||||
favorite?: boolean;
|
||||
trash?: boolean;
|
||||
}
|
||||
|
||||
export interface WorkspaceMeta {
|
||||
|
||||
@ -47,6 +47,7 @@ export class FlatSyncController {
|
||||
}
|
||||
|
||||
const model = schema.model.toModel?.() ?? new BlockModel<object>();
|
||||
const defaultProps = schema.model.props?.(internalPrimitives);
|
||||
model.schema = schema;
|
||||
|
||||
model.id = this.id;
|
||||
@ -55,7 +56,8 @@ export class FlatSyncController {
|
||||
const reactive = new ReactiveFlatYMap(
|
||||
this.yBlock,
|
||||
model.deleted,
|
||||
this.onChange
|
||||
this.onChange,
|
||||
defaultProps
|
||||
);
|
||||
this._reactive = reactive;
|
||||
const proxy = reactive.proxy;
|
||||
@ -66,17 +68,6 @@ export class FlatSyncController {
|
||||
model.store = this.doc;
|
||||
}
|
||||
|
||||
const defaultProps = schema.model.props?.(internalPrimitives);
|
||||
if (defaultProps) {
|
||||
Object.entries(defaultProps).forEach(([key, value]) => {
|
||||
if (key in proxy) {
|
||||
return;
|
||||
}
|
||||
if (value === undefined) return;
|
||||
proxy[key] = value;
|
||||
});
|
||||
}
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
|
||||
@ -99,7 +99,8 @@ export class ReactiveFlatYMap extends BaseReactiveYData<
|
||||
constructor(
|
||||
protected readonly _ySource: YMap<unknown>,
|
||||
private readonly _onDispose: Subject<void>,
|
||||
private readonly _onChange?: OnChange
|
||||
private readonly _onChange?: OnChange,
|
||||
defaultProps?: Record<string, unknown>
|
||||
) {
|
||||
super();
|
||||
this._initialized = false;
|
||||
@ -112,7 +113,7 @@ export class ReactiveFlatYMap extends BaseReactiveYData<
|
||||
|
||||
const proxy = this._getProxy(source, source);
|
||||
|
||||
Object.entries(source).forEach(([key, value]) => {
|
||||
const initSignals = (key: string, value: unknown) => {
|
||||
const signalData = signal(value);
|
||||
source[`${key}$`] = signalData;
|
||||
const unsubscribe = signalData.subscribe(next => {
|
||||
@ -128,11 +129,30 @@ export class ReactiveFlatYMap extends BaseReactiveYData<
|
||||
subscription.unsubscribe();
|
||||
unsubscribe();
|
||||
});
|
||||
};
|
||||
|
||||
Object.entries(source).forEach(([key, value]) => {
|
||||
initSignals(key, value);
|
||||
});
|
||||
|
||||
if (defaultProps) {
|
||||
Object.entries(defaultProps).forEach(([key, value]) => {
|
||||
if (!(key in proxy) && value === undefined) {
|
||||
initSignals(key, value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this._proxy = proxy;
|
||||
this._ySource.observe(this._observer);
|
||||
this._initialized = true;
|
||||
|
||||
if (defaultProps) {
|
||||
Object.entries(defaultProps).forEach(([key, value]) => {
|
||||
if (key in proxy || value === undefined) return;
|
||||
proxy[key] = value;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pop = (prop: string): void => {
|
||||
|
||||
@ -41,7 +41,7 @@
|
||||
],
|
||||
"devDependencies": {
|
||||
"@vanilla-extract/vite-plugin": "^5.0.0",
|
||||
"vite": "^6.1.0",
|
||||
"vite": "^7.0.0",
|
||||
"vite-plugin-istanbul": "^7.0.0",
|
||||
"vite-plugin-wasm": "^3.4.1",
|
||||
"vitest": "3.1.3"
|
||||
|
||||
@ -41,7 +41,7 @@
|
||||
"@vanilla-extract/vite-plugin": "^5.0.0",
|
||||
"graphql": "^16.9.0",
|
||||
"magic-string": "^0.30.11",
|
||||
"vite": "^6.0.3",
|
||||
"vite": "^7.0.0",
|
||||
"vite-plugin-istanbul": "^7.0.0",
|
||||
"vite-plugin-wasm": "^3.3.0",
|
||||
"vite-plugin-web-components-hmr": "^0.1.3"
|
||||
|
||||
@ -78,7 +78,7 @@
|
||||
"eslint-plugin-simple-import-sort": "^12.1.1",
|
||||
"eslint-plugin-sonarjs": "^3.0.1",
|
||||
"eslint-plugin-unicorn": "^59.0.0",
|
||||
"happy-dom": "^18.0.0",
|
||||
"happy-dom": "^20.0.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.0.0",
|
||||
"msw": "^2.6.8",
|
||||
@ -89,7 +89,7 @@
|
||||
"typescript": "^5.7.2",
|
||||
"typescript-eslint": "^8.18.0",
|
||||
"unplugin-swc": "^1.5.1",
|
||||
"vite": "^6.0.3",
|
||||
"vite": "^7.0.0",
|
||||
"vitest": "3.1.3"
|
||||
},
|
||||
"packageManager": "yarn@4.9.1",
|
||||
|
||||
@ -56,20 +56,20 @@
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"@node-rs/crc32": "^1.10.6",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/core": "^1.29.0",
|
||||
"@opentelemetry/exporter-prometheus": "^0.57.0",
|
||||
"@opentelemetry/exporter-zipkin": "^1.29.0",
|
||||
"@opentelemetry/host-metrics": "^0.35.4",
|
||||
"@opentelemetry/instrumentation": "^0.57.0",
|
||||
"@opentelemetry/instrumentation-graphql": "^0.47.0",
|
||||
"@opentelemetry/instrumentation-http": "^0.57.0",
|
||||
"@opentelemetry/instrumentation-ioredis": "^0.47.0",
|
||||
"@opentelemetry/instrumentation-nestjs-core": "^0.44.0",
|
||||
"@opentelemetry/instrumentation-socket.io": "^0.46.0",
|
||||
"@opentelemetry/resources": "^1.29.0",
|
||||
"@opentelemetry/sdk-metrics": "^1.29.0",
|
||||
"@opentelemetry/sdk-node": "^0.57.0",
|
||||
"@opentelemetry/sdk-trace-node": "^1.29.0",
|
||||
"@opentelemetry/core": "^1.30.1",
|
||||
"@opentelemetry/exporter-prometheus": "^0.57.2",
|
||||
"@opentelemetry/exporter-zipkin": "^1.30.1",
|
||||
"@opentelemetry/host-metrics": "^0.36.0",
|
||||
"@opentelemetry/instrumentation": "^0.57.2",
|
||||
"@opentelemetry/instrumentation-graphql": "^0.55.0",
|
||||
"@opentelemetry/instrumentation-http": "^0.57.2",
|
||||
"@opentelemetry/instrumentation-ioredis": "^0.55.0",
|
||||
"@opentelemetry/instrumentation-nestjs-core": "^0.54.0",
|
||||
"@opentelemetry/instrumentation-socket.io": "^0.54.0",
|
||||
"@opentelemetry/resources": "^1.30.1",
|
||||
"@opentelemetry/sdk-metrics": "^1.30.1",
|
||||
"@opentelemetry/sdk-node": "^0.57.2",
|
||||
"@opentelemetry/sdk-trace-node": "^1.30.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.28.0",
|
||||
"@prisma/client": "^6.6.0",
|
||||
"@prisma/instrumentation": "^6.7.0",
|
||||
|
||||
@ -3,6 +3,8 @@ import { z } from 'zod';
|
||||
|
||||
import { Config, EventBus } from '../../../base';
|
||||
import { Public } from '../../../core/auth';
|
||||
import { FeatureService } from '../../../core/features';
|
||||
import { Models } from '../../../models';
|
||||
|
||||
const RcEventSchema = z
|
||||
.object({
|
||||
@ -52,7 +54,9 @@ export class RevenueCatWebhookController {
|
||||
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly event: EventBus
|
||||
private readonly event: EventBus,
|
||||
private readonly models: Models,
|
||||
private readonly feature: FeatureService
|
||||
) {}
|
||||
|
||||
@Public()
|
||||
@ -70,28 +74,49 @@ export class RevenueCatWebhookController {
|
||||
if (parsed.success) {
|
||||
const event = parsed.data.event;
|
||||
const { id, app_user_id: appUserId, type } = event;
|
||||
|
||||
if (
|
||||
event.environment.toLowerCase() === environment?.toLowerCase()
|
||||
) {
|
||||
const logParams = {
|
||||
appUserId,
|
||||
familyShare: event.is_family_share,
|
||||
environment: event.environment,
|
||||
};
|
||||
this.logger.log(
|
||||
`[${id}] RevenueCat Webhook {${type}} received for appUserId=${appUserId}.`
|
||||
);
|
||||
|
||||
if (
|
||||
appUserId &&
|
||||
(typeof event.is_family_share !== 'boolean' ||
|
||||
!event.is_family_share)
|
||||
) {
|
||||
// immediately ack and process asynchronously
|
||||
this.event
|
||||
.emitAsync('revenuecat.webhook', { appUserId, event })
|
||||
.catch((e: Error) => {
|
||||
this.logger.error(
|
||||
'Failed to handle RevenueCat Webhook event.',
|
||||
e
|
||||
if (appUserId) {
|
||||
const user = await this.models.user.get(appUserId);
|
||||
if (user) {
|
||||
if (
|
||||
(typeof event.is_family_share !== 'boolean' ||
|
||||
!event.is_family_share) &&
|
||||
(environment.toLowerCase() === 'production' ||
|
||||
this.feature.isStaff(user.email))
|
||||
) {
|
||||
// immediately ack and process asynchronously
|
||||
this.event
|
||||
.emitAsync('revenuecat.webhook', { appUserId, event })
|
||||
.catch((e: Error) => {
|
||||
this.logger.error(
|
||||
'Failed to handle RevenueCat Webhook event.',
|
||||
e
|
||||
);
|
||||
});
|
||||
return;
|
||||
} else {
|
||||
this.logger.warn(
|
||||
`[${id}] RevenueCat Webhook received for non-acceptable params.`,
|
||||
logParams
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
this.logger.warn(
|
||||
`RevenueCat Webhook received for unknown user`,
|
||||
logParams
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.logger.warn(
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Config } from '../../../base';
|
||||
@ -23,18 +23,19 @@ const zRcV2RawProduct = z
|
||||
.object({ duration: z.string().nullable() })
|
||||
.partial()
|
||||
.nullable(),
|
||||
app: z.object({ type: Store }).partial(),
|
||||
app: z.object({ type: Store }).partial().nullish(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const zRcV2RawEntitlementItem = z
|
||||
.object({
|
||||
id: z.string().nonempty(),
|
||||
lookup_key: z.string().nonempty(),
|
||||
display_name: z.string().nonempty(),
|
||||
products: z
|
||||
.object({ items: z.array(zRcV2RawProduct).default([]) })
|
||||
.partial()
|
||||
.nullable(),
|
||||
.nullish(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
@ -45,6 +46,8 @@ const zRcV2RawEntitlements = z
|
||||
const zRcV2RawSubscription = z
|
||||
.object({
|
||||
object: z.enum(['subscription']),
|
||||
id: z.string().nonempty(),
|
||||
product_id: z.string().nonempty().nullable(),
|
||||
entitlements: zRcV2RawEntitlements,
|
||||
starts_at: z.number(),
|
||||
current_period_ends_at: z.number().nullable(),
|
||||
@ -75,7 +78,7 @@ const zRcV2RawEnvelope = z
|
||||
.object({
|
||||
app_user_id: z.string().optional(),
|
||||
id: z.string().optional(),
|
||||
subscriptions: z.array(zRcV2RawSubscription).default([]),
|
||||
items: z.array(zRcV2RawSubscription).default([]),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
@ -93,9 +96,14 @@ export const Subscription = z.object({
|
||||
});
|
||||
|
||||
export type Subscription = z.infer<typeof Subscription>;
|
||||
type Entitlement = z.infer<typeof zRcV2RawEntitlementItem>;
|
||||
type Product = z.infer<typeof zRcV2RawProduct>;
|
||||
|
||||
@Injectable()
|
||||
export class RevenueCatService {
|
||||
private readonly logger = new Logger(RevenueCatService.name);
|
||||
private readonly productsCache = new Map<string, Product[]>();
|
||||
|
||||
constructor(private readonly config: Config) {}
|
||||
|
||||
private get apiKey(): string {
|
||||
@ -114,6 +122,49 @@ export class RevenueCatService {
|
||||
return id;
|
||||
}
|
||||
|
||||
async getProducts(ent: Entitlement): Promise<Product[] | null> {
|
||||
if (ent.products?.items && ent.products.items.length > 0) {
|
||||
return ent.products.items;
|
||||
}
|
||||
const entId = ent.id;
|
||||
if (this.productsCache.has(entId)) {
|
||||
return this.productsCache.get(entId)!;
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`https://api.revenuecat.com/v2/projects/${this.projectId}/entitlements/${entId}?expand=product`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
this.logger.warn(
|
||||
`RevenueCat getProducts failed: ${res.status} ${res.statusText} - ${text}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
const entParsed = zRcV2RawEntitlementItem.safeParse(json);
|
||||
if (entParsed.success) {
|
||||
const products = entParsed.data.products?.items || null;
|
||||
if (products) {
|
||||
this.productsCache.set(entId, products);
|
||||
}
|
||||
return products;
|
||||
}
|
||||
this.logger.error(
|
||||
`RevenueCat entitlement ${entId} parse failed: ${JSON.stringify(
|
||||
entParsed.error.format()
|
||||
)}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
async getSubscriptions(customerId: string): Promise<Subscription[] | null> {
|
||||
const res = await fetch(
|
||||
`https://api.revenuecat.com/v2/projects/${this.projectId}/customers/${customerId}/subscriptions`,
|
||||
@ -132,39 +183,51 @@ export class RevenueCatService {
|
||||
);
|
||||
}
|
||||
|
||||
const envParsed = zRcV2RawEnvelope.safeParse(await res.json());
|
||||
const json = await res.json();
|
||||
const envParsed = zRcV2RawEnvelope.safeParse(json);
|
||||
|
||||
if (envParsed.success) {
|
||||
return envParsed.data.subscriptions
|
||||
.flatMap(sub => {
|
||||
const parsedSubs = await Promise.all(
|
||||
envParsed.data.items.flatMap(async sub => {
|
||||
const items = sub.entitlements.items ?? [];
|
||||
return items.map(ent => {
|
||||
const product = ent.products?.items?.[0];
|
||||
if (!product) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
identifier: ent.lookup_key,
|
||||
isTrial: sub.status === 'trialing',
|
||||
isActive:
|
||||
sub.gives_access === true ||
|
||||
sub.status === 'active' ||
|
||||
sub.status === 'trialing',
|
||||
latestPurchaseDate: sub.starts_at
|
||||
? new Date(sub.starts_at * 1000)
|
||||
: null,
|
||||
expirationDate: sub.current_period_ends_at
|
||||
? new Date(sub.current_period_ends_at * 1000)
|
||||
: null,
|
||||
productId: product.store_identifier,
|
||||
store: sub.store ?? product.app.type,
|
||||
willRenew: sub.auto_renewal_status === 'will_renew',
|
||||
duration: product.subscription?.duration ?? null,
|
||||
};
|
||||
});
|
||||
const products = (
|
||||
await Promise.all(items.map(this.getProducts.bind(this)))
|
||||
)
|
||||
.filter((p): p is Product[] => p !== null)
|
||||
.flat();
|
||||
const product = products.find(p => p.id === sub.product_id);
|
||||
if (!product) {
|
||||
this.logger.warn(
|
||||
`RevenueCat subscription ${sub.id} missing product for product_id=${sub.product_id}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
identifier: product.display_name,
|
||||
isTrial: sub.status === 'trialing',
|
||||
isActive:
|
||||
sub.gives_access === true ||
|
||||
sub.status === 'active' ||
|
||||
sub.status === 'trialing',
|
||||
latestPurchaseDate: sub.starts_at ? new Date(sub.starts_at) : null,
|
||||
expirationDate: sub.current_period_ends_at
|
||||
? new Date(sub.current_period_ends_at)
|
||||
: null,
|
||||
productId: product.store_identifier,
|
||||
store: sub.store ?? product.app?.type,
|
||||
willRenew: sub.auto_renewal_status === 'will_renew',
|
||||
duration: product.subscription?.duration ?? null,
|
||||
};
|
||||
})
|
||||
.filter((s): s is Subscription => s !== null);
|
||||
);
|
||||
return parsedSubs.filter((s): s is Subscription => s !== null);
|
||||
}
|
||||
this.logger.error(
|
||||
`RevenueCat subscription parse failed: ${JSON.stringify(
|
||||
envParsed.error.format()
|
||||
)}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -36,15 +36,13 @@ impl Array {
|
||||
pub fn get(&self, index: u64) -> Option<Value> {
|
||||
let (item, offset) = self.get_item_at(index)?;
|
||||
|
||||
if let Some(item) = item.get() {
|
||||
item.get().and_then(|item| {
|
||||
// TODO: rewrite to content.read(&mut [Any])
|
||||
return match &item.content {
|
||||
Content::Any(any) => return any.get(offset as usize).map(|any| Value::Any(any.clone())),
|
||||
match &item.content {
|
||||
Content::Any(any) => any.get(offset as usize).map(|any| Value::Any(any.clone())),
|
||||
_ => Some(Value::from(&item.content)),
|
||||
};
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn iter(&self) -> ArrayIter {
|
||||
|
||||
@ -75,6 +75,7 @@ export const KNOWN_CONFIG_GROUPS = [
|
||||
name: 'Notification',
|
||||
module: 'mailer',
|
||||
fields: [
|
||||
'SMTP.name',
|
||||
'SMTP.host',
|
||||
'SMTP.port',
|
||||
'SMTP.username',
|
||||
|
||||
@ -48,7 +48,7 @@
|
||||
"@electron-forge/plugin-fuses": "^7.8.2",
|
||||
"@electron-forge/shared-types": "^7.6.0",
|
||||
"@pengx17/electron-forge-maker-appimage": "^1.2.1",
|
||||
"@sentry/electron": "^6.1.0",
|
||||
"@sentry/electron": "^7.0.0",
|
||||
"@sentry/esbuild-plugin": "^3.0.0",
|
||||
"@sentry/react": "^9.2.0",
|
||||
"@toeverything/infra": "workspace:*",
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 77;
|
||||
objectVersion = 56;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
|
||||
@ -45,6 +45,15 @@
|
||||
"version" : "3.4.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "purchases-ios-spm",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/RevenueCat/purchases-ios-spm.git",
|
||||
"state" : {
|
||||
"revision" : "249432af6b37a3665e26ea6f4ffc869dcd445f01",
|
||||
"version" : "5.43.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "snapkit",
|
||||
"kind" : "remoteSourceControl",
|
||||
|
||||
@ -11,6 +11,7 @@ public class PayWallPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
) {
|
||||
controller = associatedController
|
||||
super.init()
|
||||
Paywall.setup()
|
||||
}
|
||||
|
||||
weak var controller: UIViewController?
|
||||
|
||||
@ -17,11 +17,15 @@ let package = Package(
|
||||
],
|
||||
dependencies: [
|
||||
.package(path: "../AffineResources"),
|
||||
.package(url: "https://github.com/RevenueCat/purchases-ios-spm.git", from: "5.0.1"),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "AffinePaywall",
|
||||
dependencies: ["AffineResources"]
|
||||
dependencies: [
|
||||
"AffineResources",
|
||||
.product(name: "RevenueCat", package: "purchases-ios-spm"),
|
||||
]
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RevenueCat
|
||||
import UIKit
|
||||
|
||||
extension ViewModel {
|
||||
@ -117,13 +118,43 @@ nonisolated extension ViewModel {
|
||||
if !initial { throw error }
|
||||
}
|
||||
|
||||
// fetch external items by executing on webview's JS context
|
||||
guard let webView = await associatedWebContext else {
|
||||
throw NSError(domain: "Paywall", code: -1, userInfo: [
|
||||
NSLocalizedDescriptionKey: String(localized: "Missing required information"),
|
||||
])
|
||||
}
|
||||
|
||||
// fetch current user identifier
|
||||
do {
|
||||
guard let webView = await associatedWebContext else {
|
||||
let result = try await webView.callAsyncJavaScript(
|
||||
"return await window.getCurrentUserIdentifier();",
|
||||
contentWorld: .page
|
||||
)
|
||||
let userIdentifier = (result as? String)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
// for too long it might be a problem on front end returning what we dont want
|
||||
guard !userIdentifier.isEmpty, userIdentifier.count < 256 else {
|
||||
throw NSError(domain: "Paywall", code: -1, userInfo: [
|
||||
NSLocalizedDescriptionKey: String(localized: "Missing required information"),
|
||||
])
|
||||
}
|
||||
print("[*] using user identifier:", userIdentifier)
|
||||
let configuration = Configuration
|
||||
.builder(withAPIKey: Paywall.revenueCatToken)
|
||||
.with(appUserID: userIdentifier)
|
||||
.with(showStoreMessagesAutomatically: false)
|
||||
.build()
|
||||
Purchases.configure(with: configuration)
|
||||
_ = try? await Purchases.shared.logOut()
|
||||
let loginItem = try await Purchases.shared.logIn(userIdentifier)
|
||||
print("[*] log in to RevenueCat finished: \(loginItem)")
|
||||
} catch {
|
||||
print("unable to login with error:", error.localizedDescription)
|
||||
throw error
|
||||
}
|
||||
|
||||
// fetch external items by executing on webview's JS context
|
||||
do {
|
||||
let result = try await webView.callAsyncJavaScript(
|
||||
"return await window.getSubscriptionState();",
|
||||
contentWorld: .page
|
||||
@ -133,6 +164,7 @@ nonisolated extension ViewModel {
|
||||
await MainActor.run { self.externalPurchasedItems = purchased }
|
||||
} catch {
|
||||
print("fetchExternalEntitlements error:", error.localizedDescription)
|
||||
throw error
|
||||
}
|
||||
|
||||
// select the package under purchased items if any
|
||||
|
||||
@ -5,11 +5,29 @@
|
||||
// Created by qaq on 9/18/25.
|
||||
//
|
||||
|
||||
import RevenueCat
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import WebKit
|
||||
|
||||
public enum Paywall {
|
||||
package static let revenueCatToken: String = "appl_FIzFhieVpSSmJRYJWwhVrgtnsVf"
|
||||
package static let revenueCatProxyEndpoit = URL(string: "https://iap.affine.pro/")!
|
||||
package static var isPurchasesConfigured = false
|
||||
|
||||
private static let setupExecution: Void = {
|
||||
#if DEBUG
|
||||
Purchases.logLevel = .debug
|
||||
#endif
|
||||
Purchases.proxyURL = revenueCatProxyEndpoit
|
||||
return ()
|
||||
}()
|
||||
|
||||
nonisolated
|
||||
public static func setup() {
|
||||
_ = setupExecution
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public static func presentWall(
|
||||
toController controller: UIViewController,
|
||||
|
||||
@ -85,7 +85,7 @@ public class IntelligentContext {
|
||||
}
|
||||
webViewGroup.wait()
|
||||
webViewMetadata = webViewMetadataResult
|
||||
|
||||
|
||||
if webViewMetadataResult[.currentAiButtonFeatureFlag] as? Bool == false {
|
||||
completion(.failure(IntelligentError.featureClosed))
|
||||
return
|
||||
|
||||
@ -236,6 +236,16 @@ const frameworkProvider = framework.provider();
|
||||
const globalContextService = frameworkProvider.get(GlobalContextService);
|
||||
return globalContextService.globalContext.docId.get();
|
||||
};
|
||||
(window as any).getCurrentUserIdentifier = () => {
|
||||
const globalContextService = frameworkProvider.get(GlobalContextService);
|
||||
const currentServerId = globalContextService.globalContext.serverId.get();
|
||||
const serversService = frameworkProvider.get(ServersService);
|
||||
const defaultServerService = frameworkProvider.get(DefaultServerService);
|
||||
const currentServer =
|
||||
(currentServerId ? serversService.server$(currentServerId).value : null) ??
|
||||
defaultServerService.server;
|
||||
return currentServer.account$.value?.id;
|
||||
};
|
||||
(window as any).getCurrentDocContentInMarkdown = async () => {
|
||||
const globalContextService = frameworkProvider.get(GlobalContextService);
|
||||
const currentWorkspaceId =
|
||||
@ -248,6 +258,7 @@ const frameworkProvider = framework.provider();
|
||||
if (!workspaceRef) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { workspace, dispose: disposeWorkspace } = workspaceRef;
|
||||
|
||||
const docsService = workspace.scope.get(DocsService);
|
||||
|
||||
@ -85,7 +85,7 @@
|
||||
"storybook": "^9.0.0",
|
||||
"typescript": "^5.7.2",
|
||||
"unplugin-swc": "^1.5.1",
|
||||
"vite": "^6.0.3",
|
||||
"vite": "^7.0.0",
|
||||
"vitest": "3.1.3"
|
||||
},
|
||||
"version": "0.22.4"
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { Button, IconButton, Modal } from '@affine/component';
|
||||
import { IconType } from '@affine/component';
|
||||
import { getStoreManager } from '@affine/core/blocksuite/manager/store';
|
||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||
import { useNavigateHelper } from '@affine/core/components/hooks/use-navigate-helper';
|
||||
@ -7,6 +8,8 @@ import {
|
||||
GlobalDialogService,
|
||||
type WORKSPACE_DIALOG_SCHEMA,
|
||||
} from '@affine/core/modules/dialogs';
|
||||
import { ExplorerIconService } from '@affine/core/modules/explorer-icon/services/explorer-icon';
|
||||
import { OrganizeService } from '@affine/core/modules/organize';
|
||||
import { UrlService } from '@affine/core/modules/url';
|
||||
import {
|
||||
getAFFiNEWorkspaceSchema,
|
||||
@ -48,6 +51,135 @@ import * as style from './styles.css';
|
||||
|
||||
const logger = new DebugLogger('import');
|
||||
|
||||
type NotionPageIcon = {
|
||||
type: 'emoji' | 'image';
|
||||
content: string; // emoji unicode or image URL/data
|
||||
};
|
||||
|
||||
type FolderHierarchy = {
|
||||
name: string;
|
||||
path: string;
|
||||
children: Map<string, FolderHierarchy>;
|
||||
pageId?: string;
|
||||
parentPath?: string;
|
||||
icon?: NotionPageIcon;
|
||||
};
|
||||
|
||||
// Helper function to create folder structure using OrganizeService
|
||||
function createFolderStructure(
|
||||
organizeService: OrganizeService,
|
||||
hierarchy: FolderHierarchy,
|
||||
parentFolderId: string | null = null,
|
||||
explorerIconService?: ExplorerIconService
|
||||
): {
|
||||
folderId: string | null;
|
||||
docLinks: Array<{ folderId: string; docId: string }>;
|
||||
} {
|
||||
const docLinks: Array<{ folderId: string; docId: string }> = [];
|
||||
const rootFolder = organizeService.folderTree.rootFolder;
|
||||
|
||||
function processHierarchyNode(
|
||||
node: FolderHierarchy,
|
||||
currentParentId: string | null
|
||||
): string | null {
|
||||
let currentFolderId = currentParentId;
|
||||
|
||||
// If this node represents a folder (has children but no pageId), create it
|
||||
if (node.children.size > 0 && !node.pageId && node.name) {
|
||||
const parent = currentParentId
|
||||
? organizeService.folderTree.folderNode$(currentParentId).value
|
||||
: rootFolder;
|
||||
|
||||
if (parent) {
|
||||
const index = parent.indexAt('after');
|
||||
currentFolderId = parent.createFolder(node.name, index);
|
||||
}
|
||||
}
|
||||
|
||||
// Process all children
|
||||
for (const child of node.children.values()) {
|
||||
if (child.pageId) {
|
||||
// This is a document, link it to the current folder
|
||||
if (currentFolderId) {
|
||||
docLinks.push({ folderId: currentFolderId, docId: child.pageId });
|
||||
}
|
||||
|
||||
// Set icon for the document if available
|
||||
if (child.icon && explorerIconService) {
|
||||
logger.debug('=== Setting icon for document ===');
|
||||
logger.debug('Document ID:', child.pageId);
|
||||
logger.debug('Icon data:', child.icon);
|
||||
|
||||
try {
|
||||
let iconData;
|
||||
if (child.icon.type === 'emoji') {
|
||||
iconData = {
|
||||
type: IconType.Emoji as const,
|
||||
unicode: child.icon.content,
|
||||
};
|
||||
logger.debug('Created emoji icon data:', iconData);
|
||||
} else if (child.icon.type === 'image') {
|
||||
// For image icons, we'd need to handle blob conversion
|
||||
// For now, let's skip image icons or convert them to default
|
||||
// This could be enhanced later to download and convert images to blobs
|
||||
logger.debug(
|
||||
'Skipping image icon (not implemented):',
|
||||
child.icon.content
|
||||
);
|
||||
iconData = undefined;
|
||||
}
|
||||
|
||||
if (iconData) {
|
||||
logger.debug('Calling explorerIconService.setIcon with:', {
|
||||
where: 'doc',
|
||||
id: child.pageId,
|
||||
icon: iconData,
|
||||
});
|
||||
explorerIconService.setIcon({
|
||||
where: 'doc',
|
||||
id: child.pageId,
|
||||
icon: iconData,
|
||||
});
|
||||
logger.debug('Icon set successfully for document:', child.pageId);
|
||||
} else {
|
||||
logger.debug('No valid icon data to set');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
'Error setting icon for document:',
|
||||
child.pageId,
|
||||
error
|
||||
);
|
||||
logger.warn(
|
||||
'Failed to set icon for document:',
|
||||
child.pageId,
|
||||
error
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (!child.icon) {
|
||||
logger.debug('No icon found for document:', child.pageId);
|
||||
}
|
||||
if (!explorerIconService) {
|
||||
logger.debug(
|
||||
'ExplorerIconService not available for document:',
|
||||
child.pageId
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (child.children.size > 0) {
|
||||
// This is a subfolder, process it recursively
|
||||
processHierarchyNode(child, currentFolderId);
|
||||
}
|
||||
}
|
||||
|
||||
return currentFolderId;
|
||||
}
|
||||
|
||||
const rootFolderId = processHierarchyNode(hierarchy, parentFolderId);
|
||||
return { folderId: rootFolderId, docLinks };
|
||||
}
|
||||
|
||||
type ImportType =
|
||||
| 'markdown'
|
||||
| 'markdownZip'
|
||||
@ -61,6 +193,7 @@ type ImportResult = {
|
||||
docIds: string[];
|
||||
entryId?: string;
|
||||
isWorkspaceFile?: boolean;
|
||||
rootFolderId?: string;
|
||||
};
|
||||
|
||||
type ImportConfig = {
|
||||
@ -68,7 +201,9 @@ type ImportConfig = {
|
||||
importFunction: (
|
||||
docCollection: Workspace,
|
||||
files: File[],
|
||||
handleImportAffineFile: () => Promise<WorkspaceMetadata | undefined>
|
||||
handleImportAffineFile: () => Promise<WorkspaceMetadata | undefined>,
|
||||
organizeService?: OrganizeService,
|
||||
explorerIconService?: ExplorerIconService
|
||||
) => Promise<ImportResult>;
|
||||
};
|
||||
|
||||
@ -160,7 +295,13 @@ const importOptions = [
|
||||
const importConfigs: Record<ImportType, ImportConfig> = {
|
||||
markdown: {
|
||||
fileOptions: { acceptType: 'Markdown', multiple: true },
|
||||
importFunction: async (docCollection, files) => {
|
||||
importFunction: async (
|
||||
docCollection,
|
||||
files,
|
||||
_handleImportAffineFile,
|
||||
_organizeService,
|
||||
_explorerIconService
|
||||
) => {
|
||||
const docIds: string[] = [];
|
||||
for (const file of files) {
|
||||
const text = await file.text();
|
||||
@ -181,7 +322,13 @@ const importConfigs: Record<ImportType, ImportConfig> = {
|
||||
},
|
||||
markdownZip: {
|
||||
fileOptions: { acceptType: 'Zip', multiple: false },
|
||||
importFunction: async (docCollection, files) => {
|
||||
importFunction: async (
|
||||
docCollection,
|
||||
files,
|
||||
_handleImportAffineFile,
|
||||
_organizeService,
|
||||
_explorerIconService
|
||||
) => {
|
||||
const file = files.length === 1 ? files[0] : null;
|
||||
if (!file) {
|
||||
throw new Error('Expected a single zip file for markdownZip import');
|
||||
@ -199,7 +346,13 @@ const importConfigs: Record<ImportType, ImportConfig> = {
|
||||
},
|
||||
html: {
|
||||
fileOptions: { acceptType: 'Html', multiple: true },
|
||||
importFunction: async (docCollection, files) => {
|
||||
importFunction: async (
|
||||
docCollection,
|
||||
files,
|
||||
_handleImportAffineFile,
|
||||
_organizeService,
|
||||
_explorerIconService
|
||||
) => {
|
||||
const docIds: string[] = [];
|
||||
for (const file of files) {
|
||||
const text = await file.text();
|
||||
@ -220,28 +373,74 @@ const importConfigs: Record<ImportType, ImportConfig> = {
|
||||
},
|
||||
notion: {
|
||||
fileOptions: { acceptType: 'Zip', multiple: false },
|
||||
importFunction: async (docCollection, files) => {
|
||||
importFunction: async (
|
||||
docCollection,
|
||||
files,
|
||||
_handleImportAffineFile,
|
||||
organizeService,
|
||||
explorerIconService
|
||||
) => {
|
||||
const file = files.length === 1 ? files[0] : null;
|
||||
if (!file) {
|
||||
throw new Error('Expected a single zip file for notion import');
|
||||
}
|
||||
const { entryId, pageIds, isWorkspaceFile } =
|
||||
const { entryId, pageIds, isWorkspaceFile, folderHierarchy } =
|
||||
await NotionHtmlTransformer.importNotionZip({
|
||||
collection: docCollection,
|
||||
schema: getAFFiNEWorkspaceSchema(),
|
||||
imported: file,
|
||||
extensions: getStoreManager().config.init().value.get('store'),
|
||||
});
|
||||
|
||||
let rootFolderId: string | undefined;
|
||||
|
||||
// Create folder structure if hierarchy exists and OrganizeService is available
|
||||
if (
|
||||
folderHierarchy &&
|
||||
organizeService &&
|
||||
folderHierarchy.children.size > 0
|
||||
) {
|
||||
try {
|
||||
const { folderId, docLinks } = createFolderStructure(
|
||||
organizeService,
|
||||
folderHierarchy,
|
||||
null,
|
||||
explorerIconService
|
||||
);
|
||||
rootFolderId = folderId || undefined;
|
||||
|
||||
// Create links for all documents to their respective folders
|
||||
for (const { folderId, docId } of docLinks) {
|
||||
const folder =
|
||||
organizeService.folderTree.folderNode$(folderId).value;
|
||||
if (folder) {
|
||||
const index = folder.indexAt('after');
|
||||
folder.createLink('doc', docId, index);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to create folder structure:', error);
|
||||
// Continue with import even if folder creation fails
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
docIds: pageIds,
|
||||
entryId,
|
||||
isWorkspaceFile,
|
||||
rootFolderId,
|
||||
};
|
||||
},
|
||||
},
|
||||
snapshot: {
|
||||
fileOptions: { acceptType: 'Zip', multiple: false },
|
||||
importFunction: async (docCollection, files) => {
|
||||
importFunction: async (
|
||||
docCollection,
|
||||
files,
|
||||
_handleImportAffineFile,
|
||||
_organizeService,
|
||||
_explorerIconService
|
||||
) => {
|
||||
const file = files.length === 1 ? files[0] : null;
|
||||
if (!file) {
|
||||
throw new Error('Expected a single zip file for snapshot import');
|
||||
@ -263,7 +462,13 @@ const importConfigs: Record<ImportType, ImportConfig> = {
|
||||
},
|
||||
dotaffinefile: {
|
||||
fileOptions: { acceptType: 'Skip', multiple: false },
|
||||
importFunction: async (_, __, handleImportAffineFile) => {
|
||||
importFunction: async (
|
||||
_,
|
||||
__,
|
||||
handleImportAffineFile,
|
||||
_organizeService,
|
||||
_explorerIconService
|
||||
) => {
|
||||
await handleImportAffineFile();
|
||||
return {
|
||||
docIds: [],
|
||||
@ -441,6 +646,8 @@ export const ImportDialog = ({
|
||||
const [importResult, setImportResult] = useState<ImportResult | null>(null);
|
||||
const workspace = useService(WorkspaceService).workspace;
|
||||
const docCollection = workspace.docCollection;
|
||||
const organizeService = useService(OrganizeService);
|
||||
const explorerIconService = useService(ExplorerIconService);
|
||||
|
||||
const globalDialogService = useService(GlobalDialogService);
|
||||
|
||||
@ -514,14 +721,16 @@ export const ImportDialog = ({
|
||||
});
|
||||
}
|
||||
|
||||
const { docIds, entryId, isWorkspaceFile } =
|
||||
const { docIds, entryId, isWorkspaceFile, rootFolderId } =
|
||||
await importConfig.importFunction(
|
||||
docCollection,
|
||||
files,
|
||||
handleImportAffineFile
|
||||
handleImportAffineFile,
|
||||
organizeService,
|
||||
explorerIconService
|
||||
);
|
||||
|
||||
setImportResult({ docIds, entryId, isWorkspaceFile });
|
||||
setImportResult({ docIds, entryId, isWorkspaceFile, rootFolderId });
|
||||
setStatus('success');
|
||||
track.$.importModal.$.import({
|
||||
type,
|
||||
@ -546,7 +755,13 @@ export const ImportDialog = ({
|
||||
logger.error('Failed to import', error);
|
||||
}
|
||||
},
|
||||
[docCollection, handleImportAffineFile, t]
|
||||
[
|
||||
docCollection,
|
||||
explorerIconService,
|
||||
handleImportAffineFile,
|
||||
organizeService,
|
||||
t,
|
||||
]
|
||||
);
|
||||
|
||||
const handleComplete = useCallback(() => {
|
||||
|
||||
@ -25,7 +25,6 @@ export const body = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flex: 1,
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
containerName: 'docs-body',
|
||||
containerType: 'size',
|
||||
|
||||
@ -0,0 +1,49 @@
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { keyframes, style } from '@vanilla-extract/css';
|
||||
|
||||
const shineAnimation = keyframes({
|
||||
'0%': {
|
||||
backgroundPosition: 'calc(var(--shine-size) * -1) 0, 0 0',
|
||||
},
|
||||
'100%': {
|
||||
backgroundPosition: 'calc(100% + var(--shine-size)) 0, 0 0',
|
||||
},
|
||||
});
|
||||
export const hotTag = style({
|
||||
background: cssVarV2('chip/tag/red'),
|
||||
padding: '0px 8px',
|
||||
borderRadius: 20,
|
||||
lineHeight: '20px',
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
color: 'white',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
|
||||
border: '0.5px solid red',
|
||||
boxShadow: '0px 2px 3px rgba(0,0,0,0.1), 0px 0px 3px rgba(255,0,0, 0.5)',
|
||||
|
||||
vars: {
|
||||
'--shine-size': '100px',
|
||||
'--shine-color': 'rgba(255,255,255,0.5)',
|
||||
},
|
||||
|
||||
selectors: {
|
||||
'&::after': {
|
||||
content: "''",
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
pointerEvents: 'none',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
// animate a shine effect
|
||||
animation: `${shineAnimation} 3.6s infinite`,
|
||||
|
||||
backgroundImage: `linear-gradient(90deg, transparent 0%, var(--shine-color) 50%, transparent 100%)`,
|
||||
backgroundRepeat: 'no-repeat, no-repeat',
|
||||
backgroundSize: '100% 100%',
|
||||
backgroundPosition: 'calc(var(--shine-size) * -1) 0, 0 0',
|
||||
},
|
||||
},
|
||||
});
|
||||
@ -3,12 +3,22 @@ import { useI18n } from '@affine/i18n';
|
||||
import { SettingGroup } from '../group';
|
||||
import { RowLayout } from '../row.layout';
|
||||
import { DeleteAccount } from './delete-account';
|
||||
import { hotTag } from './index.css';
|
||||
|
||||
export const OthersGroup = () => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<SettingGroup title={t['com.affine.mobile.setting.others.title']()}>
|
||||
<RowLayout
|
||||
label={
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{t['com.affine.mobile.setting.others.discord']()}
|
||||
<div className={hotTag}>Hot</div>
|
||||
</div>
|
||||
}
|
||||
href="https://discord.com/invite/whd5mjYqVw"
|
||||
/>
|
||||
<RowLayout
|
||||
label={t['com.affine.mobile.setting.others.github']()}
|
||||
href="https://github.com/toeverything/AFFiNE"
|
||||
|
||||
@ -2771,6 +2771,10 @@ export function useAFFiNEI18N(): {
|
||||
* `Star us on GitHub`
|
||||
*/
|
||||
["com.affine.mobile.setting.others.github"](): string;
|
||||
/**
|
||||
* `Discord Group`
|
||||
*/
|
||||
["com.affine.mobile.setting.others.discord"](): string;
|
||||
/**
|
||||
* `Privacy`
|
||||
*/
|
||||
|
||||
@ -691,6 +691,7 @@
|
||||
"com.affine.mobile.setting.appearance.title": "Appearance",
|
||||
"com.affine.mobile.setting.header-title": "Settings",
|
||||
"com.affine.mobile.setting.others.github": "Star us on GitHub",
|
||||
"com.affine.mobile.setting.others.discord": "Discord Group",
|
||||
"com.affine.mobile.setting.others.privacy": "Privacy",
|
||||
"com.affine.mobile.setting.others.terms": "Terms of use",
|
||||
"com.affine.mobile.setting.others.title": "Privacy & others",
|
||||
|
||||
@ -31,7 +31,7 @@
|
||||
"swr": "^2.3.2",
|
||||
"tailwindcss": "^4.0.6",
|
||||
"tsx": "^4.19.2",
|
||||
"vite": "^6.1.0"
|
||||
"vite": "^7.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/fs-extra": "^11",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user