diff --git a/archive-static-sites/facebook-archive/src/components/PostComponent.vue b/archive-static-sites/facebook-archive/src/components/PostComponent.vue
index c1c2172d..2d33aa69 100644
--- a/archive-static-sites/facebook-archive/src/components/PostComponent.vue
+++ b/archive-static-sites/facebook-archive/src/components/PostComponent.vue
@@ -14,6 +14,14 @@ const formattedText = computed(() => {
text = text.replace(/(?:\r\n|\r|\n)/g, '
');
return text.trim();
});
+
+const shouldShowText = computed(() => {
+ if (!props.post.text) return false;
+ if (!props.post.media || props.post.media.length === 0) return true;
+
+ // Show text if it's different from all media descriptions
+ return !props.post.media.some(media => media.description === props.post.text);
+});
@@ -28,20 +36,41 @@ const formattedText = computed(() => {
unknown date
archived {{ formattedDate(post.archivedAt) }}
-
\ No newline at end of file
diff --git a/archive-static-sites/facebook-archive/src/types.ts b/archive-static-sites/facebook-archive/src/types.ts
index 4908ac72..9359f247 100644
--- a/archive-static-sites/facebook-archive/src/types.ts
+++ b/archive-static-sites/facebook-archive/src/types.ts
@@ -1,9 +1,18 @@
-export type Post = {
+export interface Post {
postID: string;
createdAt: string;
text: string;
+ title: string;
+ isReposted: boolean;
archivedAt: string | null;
-};
+ media?: {
+ mediaId: string;
+ type: string;
+ uri: string;
+ description: string | null;
+ createdAt: string | null;
+ }[];
+}
export type FacebookArchive = {
posts: Post[];
diff --git a/archive-static-sites/facebook-archive/src/views/PostsView.vue b/archive-static-sites/facebook-archive/src/views/PostsView.vue
index 568f0e3e..26681a0e 100644
--- a/archive-static-sites/facebook-archive/src/views/PostsView.vue
+++ b/archive-static-sites/facebook-archive/src/views/PostsView.vue
@@ -16,7 +16,10 @@ const filteredPosts = computed(() => {
const lowerCaseFilterText = filterText.value.toLowerCase();
const postText = post.text ? post.text.toLowerCase() : '';
const postUsername = archiveData.value.username ? archiveData.value.username.toLowerCase() : '';
- return postText.includes(lowerCaseFilterText) || postUsername.includes(lowerCaseFilterText);
+ const postTitle = post.title ? post.title.toLowerCase() : '';
+ return postText.includes(lowerCaseFilterText) ||
+ postUsername.includes(lowerCaseFilterText) ||
+ postTitle.includes(lowerCaseFilterText);
});
});
@@ -25,11 +28,19 @@ const filteredPosts = computed(() => {
diff --git a/src/account_facebook/facebook_account_controller.ts b/src/account_facebook/facebook_account_controller.ts
index 300e26d2..6f345067 100644
--- a/src/account_facebook/facebook_account_controller.ts
+++ b/src/account_facebook/facebook_account_controller.ts
@@ -32,6 +32,8 @@ import {
FacebookJobRow,
convertFacebookJobRowToFacebookJob,
FacebookArchivePost,
+ FacebookArchiveMedia,
+ FacebookPostWithMedia,
FacebookPostRow
} from './types'
import * as FacebookArchiveTypes from '../../archive-static-sites/facebook-archive/src/types';
@@ -162,6 +164,40 @@ export class FacebookAccountController {
);`
]
},
+ {
+ name: "20250220_add_isReposted_to_post",
+ sql: [
+ `CREATE TABLE post_new (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ postID TEXT NOT NULL UNIQUE,
+ createdAt DATETIME NOT NULL,
+ title TEXT,
+ text TEXT,
+ isReposted BOOLEAN NOT NULL DEFAULT 0,
+ addedToDatabaseAt DATETIME NOT NULL
+ );`,
+ `INSERT INTO post_new (id, postID, createdAt, title, text, addedToDatabaseAt)
+ SELECT id, postID, createdAt, title, text, addedToDatabaseAt FROM post;`,
+ `DROP TABLE post;`,
+ `ALTER TABLE post_new RENAME TO post;`
+ ]
+ },
+ {
+ name: "20250302_add_media_table",
+ sql: [
+ `CREATE TABLE post_media (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ mediaId TEXT NOT NULL UNIQUE,
+ postId TEXT NOT NULL,
+ type TEXT NOT NULL,
+ uri TEXT NOT NULL,
+ description TEXT,
+ createdAt DATETIME,
+ addedToDatabaseAt DATETIME NOT NULL,
+ FOREIGN KEY(postId) REFERENCES post(postID)
+ );`
+ ]
+ },
])
log.info("FacebookAccountController.initDB: database initialized");
}
@@ -216,14 +252,38 @@ export class FacebookAccountController {
}
log.info("FacebookAccountController.archiveBuild: building archive");
-
- // Posts
- const posts: FacebookPostRow[] = exec(
+ // Posts with optional media
+ const postsFromDb = exec(
this.db,
- "SELECT * FROM post ORDER BY createdAt DESC",
+ `SELECT
+ p.*,
+ CASE
+ WHEN pm.mediaId IS NOT NULL
+ THEN GROUP_CONCAT(
+ json_object(
+ 'mediaId', pm.mediaId,
+ 'postId', pm.postId,
+ 'type', pm.type,
+ 'uri', pm.uri,
+ 'description', pm.description,
+ 'createdAt', pm.createdAt,
+ 'addedToDatabaseAt', pm.addedToDatabaseAt
+ )
+ )
+ ELSE NULL
+ END as media
+ FROM post p
+ LEFT JOIN post_media pm ON p.postID = pm.postId
+ GROUP BY p.postID
+ ORDER BY p.createdAt DESC`,
[],
"all"
- ) as FacebookPostRow[];
+ );
+ // Transform into FacebookPostWithMedia
+ const posts: FacebookPostWithMedia[] = (postsFromDb as Array).map((post) => ({
+ ...post,
+ media: post.media ? JSON.parse(`[${post.media}]`) : undefined
+ }));
// Get the current account's userID
// const accountUser = users.find((user) => user.screenName == this.account?.username);
@@ -231,6 +291,7 @@ export class FacebookAccountController {
const postRowToArchivePost = (post: FacebookPostRow): FacebookArchiveTypes.Post => {
const decodeUnicode = (text: string): string => {
+ if (!text) return ''; // Return empty string if text is null/undefined
return text.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) =>
String.fromCharCode(parseInt(hex, 16))
);
@@ -240,9 +301,18 @@ export class FacebookAccountController {
postID: post.postID,
createdAt: post.createdAt,
text: decodeUnicode(post.text),
+ title: post.title,
+ isReposted: post.isReposted,
archivedAt: post.archivedAt,
+ media: (post as FacebookPostWithMedia).media?.map(m => ({
+ mediaId: m.mediaId,
+ type: m.type,
+ uri: m.uri,
+ description: m.description,
+ createdAt: m.createdAt
+ }))
};
- return archivePost
+ return archivePost;
}
// Build the archive object
@@ -510,32 +580,46 @@ export class FacebookAccountController {
const posts = JSON.parse(postsFile);
for (const post of posts) {
- // Skip if no post text
const postText = post.data?.find((d: { post?: string }) => 'post' in d && typeof d.post === 'string')?.post;
- if (!postText) {
- log.info("FacebookAccountController.importFacebookArchive: skipping post with no text", JSON.stringify(post));
- continue;
- }
// Check if it's a shared post by looking for external_context in attachments
const isSharedPost = post.attachments?.[0]?.data?.[0]?.external_context !== undefined;
-
- // Skip if it's a shared/repost, group post, shares a group, etc. We will extend the import logic
- // to include other data types in the future.
- if (isSharedPost) {
- log.info("FacebookAccountController.importFacebookArchive: skipping shared post", JSON.stringify(post));
- continue;
- }
- else if (post.attachments) {
- log.info("FacebookAccountController.importFacebookArchive: skipping unknown post type", JSON.stringify(post));
- continue;
+ log.info("FacebookAccountController.importFacebookArchive: isSharedPost", isSharedPost);
+
+ // Check if it's a share of a group post
+ const isGroupPost = post.attachments?.[0]?.data?.[0]?.name !== undefined;
+ const groupName = isGroupPost ? post.attachments[0].data[0].name : undefined;
+
+ // For group posts, if there's no explicit post text, use the group name
+ const finalText = isGroupPost
+ ? (postText || `Shared the group: ${groupName}`)
+ : postText;
+
+ // Process media attachments
+ const media: FacebookArchiveMedia[] = [];
+ if (post.attachments) {
+ for (const attachment of post.attachments) {
+ for (const data of attachment.data) {
+ if (data.media) {
+ media.push({
+ uri: data.media.uri,
+ type: data.media.uri.endsWith('.mp4') ? 'video' : 'photo',
+ description: data.media.description,
+ creationTimestamp: data.media.creation_timestamp
+ });
+ }
+ }
+ }
}
+ log.info("FacebookAccountController.importFacebookArchive: media", media);
postsData.push({
id_str: post.timestamp.toString(),
title: post.title || '',
- full_text: postText,
+ full_text: finalText,
created_at: new Date(post.timestamp * 1000).toISOString(),
+ isReposted: isSharedPost || isGroupPost, // Group shares are reposts too
+ media: media.length > 0 ? media : undefined,
});
}
} catch (e) {
@@ -549,7 +633,7 @@ export class FacebookAccountController {
// Loop through the posts and add them to the database
try {
- postsData.forEach((post) => {
+ postsData.forEach(async (post) => {
// Is this post already there?
const existingPost = exec(this.db, 'SELECT * FROM post WHERE postID = ?', [post.id_str], "get") as FacebookPostRow;
if (existingPost) {
@@ -557,20 +641,26 @@ export class FacebookAccountController {
exec(this.db, 'DELETE FROM post WHERE postID = ?', [post.id_str]);
}
- // TODO: implement media import for facebook
// TODO: implement urls import for facebook
// Import it
- exec(this.db, 'INSERT INTO post (postID, createdAt, title, text, addedToDatabaseAt) VALUES (?, ?, ?, ?, ?)', [
+ exec(this.db, 'INSERT INTO post (postID, createdAt, title, text, isReposted, addedToDatabaseAt) VALUES (?, ?, ?, ?, ?, ?)', [
post.id_str,
new Date(post.created_at),
post.title,
post.full_text,
+ post.isReposted ? 1 : 0,
new Date(),
]);
+
+ if (post.media && post.media.length > 0) {
+ log.info("FacebookAccountController.importFacebookArchive: importing media for post", post.id_str);
+ await this.importFacebookArchiveMedia(post.id_str, post.media, archivePath);
+ }
importCount++;
});
} catch (e) {
+ log.error("FacebookAccountController.importFacebookArchive: error importing posts", e);
return {
status: "error",
errorMessage: "Error importing posts: " + e,
@@ -595,4 +685,37 @@ export class FacebookAccountController {
skipCount: skipCount,
};
}
-}
\ No newline at end of file
+
+ async importFacebookArchiveMedia(postId: string, media: FacebookArchiveMedia[], archivePath: string): Promise {
+ for (const mediaItem of media) {
+ const sourcePath = path.join(archivePath, mediaItem.uri);
+ const mediaId = `${postId}_${path.basename(mediaItem.uri)}`;
+
+ // Create destination directory if it doesn't exist
+ const mediaDir = path.join(this.accountDataPath, 'media');
+ if (!fs.existsSync(mediaDir)) {
+ fs.mkdirSync(mediaDir, { recursive: true });
+ }
+
+ const destPath = path.join(mediaDir, path.basename(mediaItem.uri));
+ try {
+ await fs.promises.copyFile(sourcePath, destPath);
+
+ exec(this.db,
+ 'INSERT INTO post_media (mediaId, postId, type, uri, description, createdAt, addedToDatabaseAt) VALUES (?, ?, ?, ?, ?, ?, ?)',
+ [
+ mediaId,
+ postId,
+ mediaItem.type,
+ path.basename(mediaItem.uri),
+ mediaItem.description || null,
+ mediaItem.creationTimestamp ? new Date(mediaItem.creationTimestamp * 1000) : null,
+ new Date()
+ ]
+ );
+ } catch (error) {
+ log.error(`FacebookAccountController.importFacebookArchiveMedia: Error importing media: ${error}`);
+ }
+ }
+ }
+}
diff --git a/src/account_facebook/types.ts b/src/account_facebook/types.ts
index def4a15d..866fbf42 100644
--- a/src/account_facebook/types.ts
+++ b/src/account_facebook/types.ts
@@ -37,18 +37,43 @@ export interface FacebookArchivePost {
created_at: string;
full_text: string;
title: string;
+ isReposted: boolean;
+ media?: FacebookArchiveMedia[]; // Media attachments
// lang: string;
}
+export interface FacebookArchiveMedia {
+ uri: string;
+ type: 'photo' | 'video';
+ description?: string; // Some media items have descriptions
+ creationTimestamp?: number; // From media.creation_timestamp
+}
+
export interface FacebookPostRow {
id: number;
username: string;
postID: string;
createdAt: string;
+ title: string;
text: string;
path: string;
addedToDatabaseAt: string;
archivedAt: string | null;
deletedPostAt: string | null;
hasMedia: boolean;
+ isReposted: boolean;
+}
+
+export interface FacebookPostMediaRow {
+ mediaId: string;
+ postId: string; // Foreign key to post.postID
+ type: string;
+ uri: string;
+ description: string | null;
+ createdAt: string | null;
+ addedToDatabaseAt: string;
+}
+
+export interface FacebookPostWithMedia extends FacebookPostRow {
+ media?: FacebookPostMediaRow[];
}